Telegram 409 Conflict: two bots, one token, infinite errors — why a bot registry is infrastructure

The error was in pipeline/telegram.py. The poller was hitting Telegram’s getUpdates endpoint, getting a 409 Conflict back, and retrying. The retry got another 409. That had been happening, silently, for weeks — because pipeline/telegram.py and brain_server.py were both polling the same bot token. One token. Two consumers. Telegram’s long-polling model is explicit about this: only one getUpdates connection per token at a time. The second one doesn’t degrade gracefully. It conflicts, forever.

The fix took an afternoon. The diagnosis — understanding why the system had arrived at this state — took longer, because the logs didn’t name the cause. They named the symptom.

What the A0c retrofit found

Phase A0c was a retrofit job: make the editorial pipeline’s two Telegram modules fetch credentials from the bot registry first, with .env as fallback. Mechanical work. What it revealed wasn’t mechanical.

The discovery, logged on 2026-05-24, was that tunnel-bot and brain_server were using the same Telegram bot. Same token. Both polling. The 409 wasn’t intermittent noise. It was structural. Two independent polling loops against a single long-poll endpoint produce a permanent conflict; the only way out is to remove one of the pollers or give each one a distinct token.

The correct fix was the second option. pipeline/telegram.py was switched to bbbrain-pipeline — a freshly registered v2 bot with its own token, registered via BotFather as @<bbbrain-pipeline-bot>. The conflict cleared immediately. The 409 entries stopped.

This is the part worth pausing on. Both services came from the same codebase, written by the same person. Nothing in the system made token sharing hard. The .env approach — a token in a file, referenced by name in two places — made duplication trivially easy.

v1 usernames were the first mistake

Before the registry, bot names were chosen manually. v1 of the registry took a --prefix argument: you’d supply something like bbb_appr and the tooling would construct a username from it. The intent was readability. The effect was that the bot’s purpose was visible in plaintext in the username — the exact thing the HMAC-derived naming was supposed to prevent.

v2 drops the prefix entirely. The username is "q" + sha256(seed)[:12] + "_bot". No user-chosen component. No semantic content in the identifier. The bot registered for the pipeline is @<bbbrain-pipeline-bot>; the one registered for the article pipeline-triage is @<dvlaw-triage-bot>. Neither name tells an observer what the bot does. That’s the point.

The v1 approach was a reasonable first pass. It failed on the obscurity guarantee it was supposed to provide. v2 is stricter and produces uglier names. Those are the correct trade-offs.

The registry as enforcement mechanism

The bot registry lives at bot-registry/registry.json. Each entry carries a logical name, a derived v2 opaque username, a linked repo, and a launchd label. The cli.py register command writes the entry; nothing else does. That’s deliberate.

The registry isn’t documentation. Documentation gets out of date. The registry is the source of record that the Telegram modules query at runtime. If a module can’t find its credentials in the registry, it falls back to .env. The fallback exists for migration, not for ongoing use.

The structural point: when every bot has a logical name and a unique token that live in one place, sharing a token by accident requires actively working around the system. You’d have to register a bot, copy its token manually into a second module, and ignore the fallback hierarchy. That’s still possible. It’s no longer the path of least resistance.

The alternative makes collision the path of least resistance. Copy a working .env entry to bootstrap a second service quickly, and you’ve silently duplicated a polling consumer. The 409 surfaces later. By then the cause is two layers removed from the symptom.

The dashboard panel closes the loop

Phase A0d added a bot-registry panel to the editorial pipeline dashboard. A new _dash_botregistry() function in brain_server.py reads bot-registry/registry.json and returns dashboard-friendly data: per-bot display_name plus whatever additional fields the panel surfaces. Read-only mirror. No writes through the dashboard.

The panel matters for one reason: visibility. The 409 problem persisted for weeks partly because there was no surface that showed “here are all registered bots, here are their purposes, here is what polls what.” If that panel had existed earlier, the token collision would have been visible at a glance. It would have been a configuration question, not an incident.

Out of scope: the dashboard panel doesn’t enforce uniqueness, de-register bots, or interact with BotFather. Those operations go through cli.py. The panel reads; the CLI writes.

Three bots, three purposes

As of A1, the registry contains three v2 bots:

  • bbbrain-bot: the original, now scoped to brain-server interactions
  • bbbrain-pipeline: the approval-gate poller, @<bbbrain-pipeline-bot>
  • dvlaw-triage: @<dvlaw-triage-bot>, launchd label uk.bytebridges.bbbrain.dvlaw-triage-cron

Each has exactly one polling consumer. The launchd label in the registry entry is the link to the scheduled job that consumes the token. If a label appears twice across entries, that’s the signal to investigate before a 409 tells you to.

What I’d tell past-me

Register the bot before writing the module, not after. The retrofit took an afternoon. Doing it in the right order would have taken five minutes during initial setup.

A token in .env is not a unique assignment. It’s a string in a file. Nothing prevents it from appearing in two files. The registry provides the uniqueness guarantee; .env cannot.

The 409 does not explain itself. Telegram’s error is accurate but opaque — it tells you there’s a polling conflict, not that you have two consumers of the same token. If your poller is throwing 409 persistently, audit token usage across every module before touching retry logic.

Drop the prefix. v1 usernames leaked purpose. v2 is ugly and correct. Obscurity isn’t the primary security mechanism here, but undermining it in the username itself is pointless. Let the logical name carry the meaning inside the registry; keep the Telegram-facing identifier opaque.

Where this sits in the build

The bot registry is Phase A0, now closed with A0d. The work it represents isn’t especially glamorous. A JSON file, a CLI command, a dashboard read-path, a naming convention. None of it is the kind of thing that appears in a PRD as a priority.

It earned its place by making a class of incident impossible by construction. The 409 that had been running silently for weeks stopped the afternoon the second service got its own token. The dashboard panel means the next person to add a bot, or the same person six months from now, has a surface that shows the current allocation before they register anything new.

The registry is bookkeeping that became infrastructure the moment it enforced something. That’s the pattern worth keeping.

All writing