Move the events queue and its subscriptions to the seat's own principal (comms-g5zh.2, comms-g5zh.3) - #209
Merged
Conversation
An event queue and a subscription row are both state the realm holds on the agent's behalf, so they belong under the agent's own principal rather than the shared minter's (docs/agent-experience.md principle 5). The two move together because Zulip couples them. A channel message is delivered only to the users the channel's subscription rows name (zerver/actions/message_send.py builds the recipient set from those rows, and a mention does not widen it), so a seat-owned queue over minter-held subscriptions would receive nothing at all. Queue ownership and subscription ownership are one unit, not two sequenced steps. inbox.subscribe / inbox.unsubscribe therefore reach through boundHttp, which makes reaching for a bound credential the declaration that the realm is about to hold state -- the same seam the write verbs use, with no second list to keep in agreement. A bind failure flows out untouched rather than being flattened into an InboxError, matching publish. Boot-time subscribe supplies the seat's naming inputs by POLLING the shared session-id deferred, never awaiting it. The boot-env feeder completes that deferred earlier in boot, so the only zero-action source has already fired by the time registration runs and there is no race left to lose; an await here would park the BOOT fiber, which is a hung MCP child rather than a merely deaf one. A seat that cannot bind logs what it lost and carries on serving. The hooks manifest guard now traces adapter.inbox.* alongside adapter.publisher.*. Tracing only the publisher receiver is exactly how a guard filed in May 2026 against this drift stayed green for eight months while a binding verb sat outside its compared set. subscribe and unsubscribe are unstamped by the PreToolUse matcher, so they are recorded in a named list the assertion holds to -- widening that matcher reverses 0f0e755 (PR #126) and is held pending ratification. comms-g5zh.2 comms-g5zh.3 Claude-Session: https://claude.ai/code/session_01NdgPHEUtz1xqbFEiN3cxCB
Registering the queue under the seat was only half the move. The producer polls with the same client it registers with (events.ts), and the adapter was still handing it the minter's -- so a seat-owned queue would have been polled by a principal Zulip rejects outright (zerver/tornado/event_queue.py access_client_descriptor raises BadEventQueueIdError when the caller is not the queue's user). EventsConfig now separates the two clients by what they touch. The reads a batch performs -- rendered content, reaction targets, the directory -- stay on the minter, because a read leaves no realm-visible trace and belongs to whoever wrote the state. GET /events and the re-register after a dead queue go out under the queue's owner. That owner is read PASSIVELY, not through the bind seam. Asking which credential owns a queue this seat already registered is not a declaration that the realm is about to hold state -- the declaration happened at subscribe, which bound. Reading through the binding that registration established keeps polling off the mint path, which is what lets it run on the pump's own daemon fiber where no tool-call session context exists. An unbound seat owns no queue and must not mint one here, so the step idles and returns empty rather than failing or registering. Failing would enter the never-give-up retry and spin against the realm; latching would reproduce comms-9iro, where a seat that lost one early race stayed deaf for the pump's whole lifetime. An empty step keeps the unfold alive, so the next step re-reads currentRegistration and adopts a queue a later subscribe registers. The anti-latch property comes from removing the one-shot, not from adding a park. comms-g5zh.2 Claude-Session: https://claude.ai/code/session_01NdgPHEUtz1xqbFEiN3cxCB
Two regression tests for the bugs this rework deletes rather than fixes. comms-g5zh.3's bug: under the shared minter there was ONE subscription row for the whole fleet, so seat B's unsubscribe issued a DELETE against the row seat A was receiving through, and deafened A. Nothing refcounted it -- streamIsListening counts only within one adapter instance across narrow kinds, and inboxRef is per-process -- so there was no cross-seat unwinding to get wrong. With each seat holding its own row the bug has no shape to take. The test pins the structural fact that makes that true: every subscription write in a two-seat exchange goes out under the seat that issued it, and the minter issues none. comms-9iro's bug: the producer consulted the session once at materialisation, got nothing, and latched, leaving a seat that lost that race deaf for the pump's whole lifetime. The replacement property is not an await -- it is the absence of a latch. The test starts the pump on an unbound seat, binds afterwards, and requires the event to arrive. Mutation-checked: replacing the idle step with a park makes it hang and fail, so it genuinely observes the latch rather than the happy path. The behavioural half of the cross-seat proof needs two real principals in a realm and belongs in the live suite; this is the structural half. comms-g5zh.2 comms-g5zh.3 Claude-Session: https://claude.ai/code/session_01NdgPHEUtz1xqbFEiN3cxCB
The two facts this rework turns on are facts about Zulip, not about our code: an event queue is a capability bound to its owner and refuses anyone else, and a channel message is delivered only to principals the channel's subscription rows name. A stub is blind to both -- it will hand the minter a seat's queue, and deliver to a queue whose owner subscribes to nothing. That is the shape of failure comms-hsym recorded: a suite green against a configuration nobody deploys. Three live tests, gated on the existing live-channel env var: Ownership is proved by REFUSAL, not by assertion. Registering under the seat only means something if the minter genuinely cannot use the queue, so the discriminating check is that the minter's poll is rejected with BAD_EVENT_QUEUE_ID while the seat's poll of the same queue succeeds. A queue still registered under the minter passes the second half and fails the first. Delivery is the consequence that makes the move worth anything: a channel message must reach the seat's own queue, which a deaf seat cannot fake. The cross-seat test is comms-g5zh.3's acceptance with two real principals, the only way to show the deafening is gone. Docs corrected where they now describe a fixed bug as live. agent-experience.md's worked example stated the cross-seat deafening in the present tense; it is now recorded as deleted rather than fixed, with the distinction spelled out -- nothing counts seats today either, and nothing needs to, because there is no shared subscription left to unwind. What has NOT moved (the minter's blanket subscription, client-side narrowing) is named so the correction cannot read as more than it is. self-hosting.md gains the operator-facing behaviour change as one entry for the host class it affects: an ephemeral non-CC host that injects no session id can no longer receive, because it cannot name an identity to mint. It was previously carried by the minter's queue; retiring that crutch makes the gap visible rather than masked. Both supported ways out are named. Claude Code seats are unaffected. comms-g5zh.2 comms-g5zh.3 Claude-Session: https://claude.ai/code/session_01NdgPHEUtz1xqbFEiN3cxCB
RATIFIED REVERSAL OF 0f0e755 (PR #126). That commit chose id-blind subscribe and explicitly declined to add subscribe/unsubscribe to the PreToolUse matcher, on the grounds that "subscribe/unsubscribe need not carry session_id". That was correct for the architecture it served: a subscription was written under the SHARED MINTER, so the seat needed no identity of its own to declare interest. comms-g5zh.3 retires that architecture. A subscription is realm state under the seat's own principal, so subscribe MINTS -- and a tool whose adapter path reaches the bind seam must carry a session id or be refused. The premise #126 rested on is gone, so the matcher widens to the seven tools that declare session_id. Graeme ratified this reversal on 2026-07-31; PR #191 attempted the same change in July and was closed because it did not cite #126. Naming it is the point: this is a authorized reversal of a considered decision, not a fourth uncited pass at the same file. THE REASON THIS WAS INVISIBLE. The memory adapter's inbox.subscribe did not bind, so every rig that boots the server against it exercised a seam that differs from the one that ships -- the exact failure comms-hsym recorded. Making the fake honour the seam turned a green suite red and surfaced three real defects that would otherwise have shipped: 1. A REENTRANT BIND DEADLOCK, which hung the first action of every seat with a project slug. Post binds -> acquire -> onAcquire seeds subscriptions -> inbox.subscribe binds -> re-enters the same single-flight while its state still says `pending` -> awaits the Deferred its own caller must complete. Fixed by recording the binding BEFORE running post-acquire work, which is also the honest model: the identity exists the moment acquire returns it, and what follows is work done AS that identity. The documented failure contract is kept by hand, via onError rather than tapError so a hook that THROWS still drops the binding. 2. STICKY ENGAGEMENT ESCAPED THE SESSION CONTEXT. It ran its subscribe at the bare runtime edge rather than through the caller's run, so the bind saw no session id and refused a caller who had done nothing wrong -- they posted; the subscribe is our inference from that post. 3. The deployed-wiring rig reported subscribe/unsubscribe as refused-for-want-of-identity, which is precisely the rig doing its job. Two guards re-aimed rather than relaxed. hooks.test.ts used `subscribe` as its example of a tool the matcher must NOT capture; the guarantee now stands on `read_thread`, which genuinely never binds. Its per-tool list was also missing edit_message, so it is restated against all seven. comms-g5zh.2 comms-g5zh.3 Claude-Session: https://claude.ai/code/session_01NdgPHEUtz1xqbFEiN3cxCB
GET /events holds ~50s waiting for an event that never comes on an idle queue, so the seat's half of the ownership check timed out rather than answering. The probe asks whether the realm accepts the caller, not what is on the queue, so dont_block is the right question to ask. Verified against the realm: the minter's poll of a seat-registered queue is refused with BAD_EVENT_QUEUE_ID, the seat's poll of the same queue succeeds, a channel message reaches the seat's own queue, and one seat unsubscribing leaves another receiving. comms-g5zh.2 Claude-Session: https://claude.ai/code/session_01NdgPHEUtz1xqbFEiN3cxCB
GraemeF
added a commit
that referenced
this pull request
Jul 31, 2026
…comms-g5zh.4, .5, .8) (#211) `COMMY_SUBSCRIBE` bootstraps a bot account and is then inert. A seat's subscriptions 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_SUBSCRIBE` for a bot that already exists now does **nothing**. It is applied when the bot is created and never read again; from then on the agent owns its subscriptions and changes them with the `subscribe` / `unsubscribe` tools. 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_SUBSCRIBE` read at two launches of one bot was indistinguishable from the bot'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 `AcquiredIdentity` now carries the **origin** of the bind — whether the acquire created 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 delivers to 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/topic` and `new-topics:#chan` both read back as plain `#chan` — so rebuilding 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_topics` with `visibility_policy: FOLLOWED`) but does not deliver 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 `startEventPump` materialises as the last step of boot. A seat that bound at boot parked on a deferred only a step it would never reach could complete: every ephemeral seat with `COMMY_SUBSCRIBE` set hung before announcing 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-`#209` plugin, and no existing test could see it: the integration harness pre-completes that deferred, and the one boot test that sets `COMMY_SUBSCRIBE` in ephemeral mode uses a fake whose `subscribe` does 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 and the hook carrying catch-up fires there). The contract suite gains `inbox.subscriptions()`, including that a topic-level narrow reads back as its plain channel — the ceiling every caller designs around. `clients/claude-code/hooks-manifest.test.ts` pins the set of adapter declarations reaching `boundHttp`; `subscriptions` joins it deliberately. It is the one read in 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.
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.
Moves receiving to the seat's own principal: the events queue and the subscriptions that feed it. Closes
comms-g5zh.2andcomms-g5zh.3.Why the two beads land together
Zulip couples them. A channel message is delivered only to the users the channel's subscription rows name (
zerver/actions/message_send.pybuilds the recipient set from those rows, and a mention does not widen it). So a seat-owned queue over minter-held subscriptions receives nothing at all —.2alone ships a deaf seat. Queue ownership and subscription ownership are one unit, not two sequenced steps.What changed
inbox.subscribe/inbox.unsubscribereach throughboundHttp. Reaching for a bound credential is the declaration that the realm is about to hold state — no second list to keep in agreement. Bind failures flow out untouched rather than being flattened intoInboxError, matchingpublish.The producer polls the queue it owns.
events.tspolls with the same client it registers with, so a seat-owned queue polled by the minter fails on every step.EventsConfignow splits its clients by what they touch: reads (rendered content, reaction targets, directory) stay on the minter,GET /eventsand the dead-queue re-register go out under the queue's owner. That owner is read passively, not through the bind seam — polling a queue you already own is not a declaration that the realm is about to hold state, and that is what lets it run on the pump's daemon fiber where no tool-call session context exists.Two live bugs deleted rather than mitigated. One seat's unsubscribe can no longer deafen another — not because refcounting was added, but because there is no shared subscription left to unwind. And
comms-9iro's latch is gone: an unbound producer idles and returns an empty step, so the next step re-readscurrentRegistrationand adopts a queue a later subscribe registers.Boot-time subscribe polls the session-id deferred, never awaits it. The boot-env feeder completes it earlier in boot, so there is no race left to lose — and an await here would park the boot fiber, which is a hung MCP child rather than a merely deaf one. A seat that cannot bind logs what it lost and carries on serving.
Ratified reversal of #126
0f0e755(#126) chose id-blind subscribe and explicitly declined to addsubscribe/unsubscribeto the PreToolUse matcher: "subscribe/unsubscribe need not carry session_id". That was correct for the architecture it served — a subscription was written under the shared minter, so the seat needed no identity of its own to declare interest.comms-g5zh.3retires that architecture. A subscription is realm state under the seat's own principal, so subscribe mints, and a tool whose adapter path reaches the bind seam must carry a session id or be refused. The premise #126 rested on is gone, so the matcher widens to the seven tools that declaresession_id.This is named deliberately. #191 attempted the same change in July and was closed because it reversed #126 without citing it — the third closed-unmerged PR in this area. Graeme ratified this reversal on 2026-07-31.
Three defects the fakes were hiding
The memory adapter's
inbox.subscribedid not bind, so every rig booting the server against it exercised a seam that differs from the one that ships — the exact failurecomms-hsymrecords. Making the fake honour the seam turned a green suite red and surfaced three real defects that would otherwise have shipped:onAcquireseeds subscriptions →inbox.subscribebinds → re-enters the same single-flight while its state still sayspending→ awaits theDeferredits own caller must complete. Fixed by recording the binding before running post-acquire work, which is also the honest model: the identity exists the momentacquirereturns it, and what follows is work done as that identity.subscribe/unsubscribeasrefused-for-want-of-identity— the rig doing exactly its job.Verified against a real realm
Three new live tests, because the two facts this turns on are facts about Zulip that no stub can settle:
BAD_EVENT_QUEUE_IDwhile the seat's poll of the same queue succeeds. A queue still registered under the minter passes the second half and fails the first.All three pass. The anti-latch unit test is mutation-checked: replacing the idle step with a park makes it hang and fail, so it observes the latch rather than the happy path.
Behaviour change for one host class
An ephemeral host (no
COMMY_BOT_NAME) that injects no session id can no longer subscribe or receive: it cannot name an identity to mint, and the minter's queue that used to carry it is being retired. Documented indocs/self-hosting.mdwith both supported ways out. Claude Code seats are unaffected — the plugin injectsCLAUDE_CODE_SESSION_IDat spawn, before the first subscribe.Also in here
docs/agent-experience.md's worked example described the cross-seat deafening in the present tense; it now records the bug as deleted, and names what has not moved (the minter's blanket subscription, client-side narrowing) so the correction cannot read as more than it is.Unrelated, filed separately as
comms-lpjf: the live suite is broken onmain—ZULIP_LIVE_CHANNEL_NAMEnames a channel that no longer exists, and fourserver.live.test.tstests time out even with a working channel. Confirmed by a control run oneb0d2f1(55 pass / 16 fail) and by running one of them onmainwith a corrected channel: identical failure. Not caused by this PR.