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 interactionsbbbrain-pipeline: the approval-gate poller,@<bbbrain-pipeline-bot>dvlaw-triage:@<dvlaw-triage-bot>, launchd labeluk.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.



