COMMY_SUBSCRIBE bootstraps a bot; the realm holds its subscriptions (comms-g5zh.4, .5, .8) - #211
Merged
Merged
Conversation
…5zh.8) nix bootstraps the bot account; the bot owns its subscriptions once it exists. So COMMY_SUBSCRIBE seeds a bot's subscriptions and is then never consulted again — editing it for a bot that already exists does nothing, by design. That cross-session ambiguity is what is being removed. "Once" reads two facts. `AcquiredIdentity` now carries the ORIGIN of the bind, which both substrates already computed and threw away — Zulip's acquire is a lookup that mints on miss, and the memory adapter's registration has the same shape. A mint is self-evidently the first time a bot has existed. Alongside it, a seed ledger records that this installation has bootstrapped a bot name, which covers the population with no mint left to observe: every pinned bot in an existing fleet is already minted when this lands, so mint-only seeding would leave all of them with no subscriptions at all. The seeding rides whichever path mints the seat — the boot acquire for a persistent bot, the post-acquire hook for an ephemeral one. That closes a gap the old boot-only seeding had: a seat whose session id had not arrived by boot used to end up with its tokens in a client-side filter over a stream it was never subscribed to. Also fixes a boot deadlock this seam walks straight into (comms-deg1). The ephemeral post-acquire hook blocks on the queue-resume verdict, and that verdict was reported only by the events producer, which the LAST step of boot materialises — so a seat that binds at boot parks forever on a deferred nothing can complete. An absent queue-state answers the verdict on its own, so boot reports it before anything can bind. Claude-Session: https://claude.ai/code/session_017TV8TDc9taeDKy3cYTEWty
…s-g5zh.8) Graeme ruled that a bot which comes out of the upgrade with no subscriptions of its own is left empty rather than bootstrapped: it can subscribe if it wants any, which is the same charter read from the other end. That removes the only case the ledger existed to serve, so an already-existing bot is now never seeded and nothing needs recording between boots. Worth writing down because it looks like a gap and is not: every pinned bot crossing this upgrade has an empty own-subscription set. Before the seat-owned-subscriptions architecture the MINTER held them, so no bot ever had any of its own — "empty" here is universal and is an artefact of the migration rather than a signal about any particular bot's intent. What keeps a pane hearing its project is the Type-1 defaults, which are computed per boot on a separate path and are untouched by this. Claude-Session: https://claude.ai/code/session_017TV8TDc9taeDKy3cYTEWty
…py (comms-g5zh.5) Subscription rows are written under the seat's own principal and they are what actually governs delivery, so on the way up a seat now asks — `inbox.subscriptions()` — rather than replaying a local record of what it once asked for. A local record can disagree with delivery; the realm's answer cannot. The record does not disappear, it shrinks to the one thing the realm cannot hold. A subscription row names a channel, so `#chan/topic` and `new-topics:#chan` both read back as plain `#chan` — rebuilding from the realm alone would silently widen every topic subscription into its whole channel. So what is persisted now is topic-level intents only, and the rule is that each realm channel becomes a channel-wide narrow unless a topic intent names it. That is the client-side exemption docs/agent-experience.md already grants: Zulip has no per-topic delivery primitive to move it to. The rebuild runs for both modes, which is what pays for it. A pinned pane has no session record — its id is new every launch — so it used to come up with nothing but its boot-time defaults while the realm went on delivering every channel it had ever joined at runtime. That seat gets recovery for the first time; the ephemeral seat merely stops being a special case. Asking never mints. A seat with no identity has no subscription rows either, so the honest answer is "nothing" rather than "mint a bot and go and look" — the rebuild reads only when the seat is already bound, or when a topic record proves its bot already exists. Claude-Session: https://claude.ai/code/session_017TV8TDc9taeDKy3cYTEWty
Two regression tests for properties nothing else would notice changing, both held by the ORDER of boot steps rather than by any assertion in the code: - boot completes for an ephemeral seat with COMMY_SUBSCRIBE and a genuinely uncompleted resume verdict. The existing harnesses pre-complete that deferred, which is exactly why the deadlock could live on main with a green suite. - a listen-only seat runs its catch-up at boot with zero tool calls (comms-9iro). Subscribing binds, so the seat acquires at boot and the hook carrying catch-up fires there; before, a seat that never posted never acquired and lost its downtime backlog. The contract suite gains `inbox.subscriptions()` — including that a topic-level narrow reads back as its plain channel, which is the ceiling every caller has to design around. Docs: `COMMY_SUBSCRIBE` is documented as bootstrapping a bot rather than configuring one, with the "editing it does nothing" consequence stated where an operator will look for it. `docs/agent-experience.md`'s session-keyed-subscriptions divergence is narrowed to what is actually left of it — the topic-level record — and its worked example now says the store step is mostly undone. Claude-Session: https://claude.ai/code/session_017TV8TDc9taeDKy3cYTEWty
… a subscription row cannot express Its own header stated the causal chain that justified holding the whole narrow set — in-memory, rebuilt from COMMY_SUBSCRIBE each boot, lost on resume. Both premises are gone, and a stale rationale in the file that embodies it is worse than none. Claude-Session: https://claude.ai/code/session_017TV8TDc9taeDKy3cYTEWty
GraemeF
added a commit
that referenced
this pull request
Jul 31, 2026
…identity (comms-g5zh.6) (#212) `narrow-set.ts` was a client-side filter over the *minter's* full event stream, so `matches` took `IdentityId | undefined`: pre-acquire there was no identity to compare a message's mentions against, and the mention arm had to be gated on that. With the events queue registered against the seat's own principal (#209, #211), an unbound seat owns no queue to poll — `ownerHttp` refuses and the producer idles instead of polling (`packages/zulip/events.ts`, the `UnboundEphemeralSession` catch). Nothing can reach `matches` without a binding, so the optional parameter described a state that no longer exists. What this removes: - the `botIdentityId !== undefined` guard, and with it `mentionsMatches` — once the guard goes it just forwards to `mentionsIdentity`, so the wrapper goes too; - `IdentityId | undefined` on `NarrowSet.matches`; - one test that asserted the deleted branch (`a mention does not match pre-acquire`) — it asserts a case that is no longer representable, and its "a mention of another identity doesn't match" content is already covered by `a mention matches message-posted only when the bot is among the mentions`. At the pump's call site (`server.ts`) an absent binding now means "not this seat's event" rather than "match on channel narrows alone" — the same statement the producer makes one layer down. It is reachable only if a release lands between an event being produced and dispatched; there is no test for that arm, since the closure isn't reachable from outside `makeProgram`. The remaining client-side filtering is what the substrate genuinely cannot express: Zulip has no per-topic delivery primitive, so thread narrows stay here. Recording thread intent as realm state via `POST /user_topics` would demote this filter from authority to cache — real, but a tightening rather than a subtraction, so it's left as follow-up. The rest of the test churn is mechanical: 45 `noBot` (`= undefined`) call sites become a real receiving-seat id. Gate green inside `nix develop`: 1212 pass, 0 fail.
GraemeF
added a commit
that referenced
this pull request
Jul 31, 2026
… nothing (comms-g5zh.7) (#213) The minter subscribed to every public stream so it could receive on behalf of a seat that had not yet minted, kept current by a boot-time reconciler. With the events queue (#209) and the channel subscriptions (#211, #212) both registered under the seat's own principal, no seat receives through another — and that layer has nothing left to do. The minter keeps the jobs that are its own: minting bots, and the directory and history reads that need no principal. **Removed** - `packages/zulip/minter-reconciler.ts` and its tests. - `reconcileMinterSubscriptions` from the Zulip adapter's shape, its wiring, and its response schema. - The boot-time reconcile call in the plugin's `main`, and its logging. - The no-op stub in the above-port test seam. - The adapter and `server.test.ts` tests covering the deleted capability. `lazy mode does NOT mint at boot when COMMY_SUBSCRIBE is unset` already asserts the property that matters — a seat that never mints subscribes to nothing. **Corrected** Prose that described the retired arrangement: `tools.ts` still claimed subscribe registered the *minter* for post-boot streams (it has gone out on the seat's own credential since #211), the adapter's per-event filter named the minter's subscription list, and the client README's boot diagram opened with the reconcile step. `docs/agent-experience.md` now reads as the worked example predicted: topic narrows and the event-queue handle are what remain client-side, both filtering the agent's own queue. Pure subtraction — no new capability, no shims. Subscription reconstruction (comms-70g4) is downstream of this, not part of it. https://claude.ai/code/session_01P67vRP5hs9Yvo16vkWru4g
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
COMMY_SUBSCRIBEbootstraps a bot account and is then inert. A seat'ssubscriptions live in the realm under its own principal, and on the way up a
seat asks the realm what it is subscribed to instead of replaying a local copy.
Closes
comms-g5zh.4,comms-g5zh.5,comms-g5zh.8,comms-9iro,comms-deg1.What changes for an operator
Editing
COMMY_SUBSCRIBEfor a bot that already exists now does nothing. Itis applied when the bot is created and never read again; from then on the agent
owns its subscriptions and changes them with the
subscribe/unsubscribetools. To start over from the launcher, delete the bot.
That is the point of the change rather than a side effect of it. While a
launcher value and an agent's own calls were both authoritative, the same
COMMY_SUBSCRIBEread at two launches of one bot was indistinguishable from thebot's own runtime changes, and the two quietly fought.
On upgrade, a bot that already existed is not bootstrapped retroactively — it
keeps whatever the realm holds for it, and a bot that finds itself with nothing
can subscribe to what it wants. Worth knowing: every pinned bot crosses this
upgrade with an empty own-subscription set, because before the seat-owned
architecture the minter held the subscriptions. "Empty" here is universal and is
an artefact of the migration, not a signal about any particular bot's intent.
What keeps a pane hearing its project channel is the Type-1 defaults, which are
computed per boot on a separate path and are untouched by this.
How "once" is decided
AcquiredIdentitynow carries the origin of the bind — whether the acquirecreated the identity or bound to one that was already there. Both substrates
already computed this and threw it away: Zulip's acquire is a lookup that mints
on miss, and the memory adapter's registration has the same shape. So this reads
the substrate's answer rather than inferring one, which matters because every
available inference is wrong in a way that only shows in production — "has no
subscriptions" cannot tell a never-seeded bot from one that deliberately
unsubscribed from everything.
The seeding rides whichever path mints the seat: the boot acquire for a
persistent bot, the post-acquire hook for an ephemeral one. That closes a gap the
old boot-only seeding had — a seat whose session id had not arrived by boot used
to end up with its tokens in a client-side filter over a stream it was never
subscribed to.
An earlier draft of this branch carried a durable "already seeded" ledger, for
the population with no mint left to observe. It is gone: under the
leave-already-empty-alone ruling every branch of that reconciliation ends in
"don't apply", so nothing needed recording between boots.
Resume stops being restore
New port verb
inbox.subscriptions()reads back the channels the realm deliversto this seat, from the rows under its own principal. A local copy can disagree
with delivery; the realm's answer cannot.
What a subscription row cannot express is narrowing below a channel —
#chan/topicandnew-topics:#chanboth read back as plain#chan— sorebuilding from the realm alone would silently widen every topic subscription
into its whole channel. The persisted record therefore shrinks to topic-level
intents only, and the rule is that each realm channel becomes a channel-wide
narrow unless a topic intent names it. Channel-wide intents are no longer
persisted at all.
The rebuild runs for both modes, which is what pays for it. A pinned pane has
no session record — its id is new every launch — so it used to come up with
nothing but its boot-time defaults while the realm went on delivering every
channel it had ever joined at runtime. That seat gets recovery for the first
time; the ephemeral seat merely stops being a special case. It also removes the
persisted-vs-realm divergence class by construction.
Reading never mints. A seat with no identity has no subscription rows either, so
the honest answer is "nothing" rather than "mint a bot and go and look" — the
rebuild reads only when the seat is already bound, or when a topic record proves
its bot already exists.
Known ceiling, documented rather than fixed: a topic-level intent an agent
creates at runtime still lives only in the local record. Zulip can hold topic
intent (
POST /user_topicswithvisibility_policy: FOLLOWED) but does notdeliver against it, so recording it there would demote the local record from
authority to cache. Deliberately out of scope here — this change removes state,
and adding a realm write is a different kind of change with its own risks.
A boot deadlock, found while verifying something else
The ephemeral post-acquire hook blocks on the queue-resume verdict, and since
subscriptions moved to the seat's own principal the boot-time subscribe binds —
so that hook runs on the boot fiber. The verdict was reported only by the
events producer, which
startEventPumpmaterialises as the last step of boot. Aseat that bound at boot parked on a deferred only a step it would never reach
could complete: every ephemeral seat with
COMMY_SUBSCRIBEset hung beforeannouncing its tools.
Fixed at the root rather than with a timeout: an absent queue-state answers the
verdict on its own, so boot reports it before anything can bind. A present
queue-state is still not an answer — whether that queue is alive is only knowable
by polling it — so the producer keeps reporting that case.
This did not reach the fleet, which runs the published pre-
#209plugin, and noexisting test could see it: the integration harness pre-completes that deferred,
and the one boot test that sets
COMMY_SUBSCRIBEin ephemeral mode uses a fakewhose
subscribedoes not bind.Tests
Two regressions pin properties held by the order of boot steps, which nothing
else would notice changing: boot completes with a genuinely uncompleted resume
verdict, and a listen-only seat runs its catch-up at boot with zero tool calls
(
comms-9iro, now resolved — subscribing binds, so the seat acquires at boot andthe hook carrying catch-up fires there).
The contract suite gains
inbox.subscriptions(), including that a topic-levelnarrow reads back as its plain channel — the ceiling every caller designs around.
clients/claude-code/hooks-manifest.test.tspins the set of adapter declarationsreaching
boundHttp;subscriptionsjoins it deliberately. It is the one readin that set, and it belongs there for a different reason than the writes: it asks
what the realm holds for this seat, so only the seat's own credential can
answer it.