Skip to content

Move the events queue and its subscriptions to the seat's own principal (comms-g5zh.2, comms-g5zh.3) - #209

Merged
GraemeF merged 6 commits into
mainfrom
comms-g5zh.2
Jul 31, 2026
Merged

GraemeF merged 6 commits into
mainfrom
comms-g5zh.2

Conversation

@GraemeF

@GraemeF GraemeF commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Moves receiving to the seat's own principal: the events queue and the subscriptions that feed it. Closes comms-g5zh.2 and comms-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.py builds 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.2 alone ships a deaf seat. Queue ownership and subscription ownership are one unit, not two sequenced steps.

What changed

inbox.subscribe / inbox.unsubscribe reach through boundHttp. 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 into InboxError, matching publish.

The producer polls the queue it owns. events.ts polls with the same client it registers with, so a seat-owned queue polled by the minter fails on every step. EventsConfig now splits its clients by what they touch: reads (rendered content, reaction targets, directory) stay on the minter, GET /events and 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-reads currentRegistration and 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 add subscribe/unsubscribe to 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.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.

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.subscribe did not bind, so every rig booting the server against it exercised a seam that differs from the one that ships — the exact failure comms-hsym records. 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 that 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.
  2. Sticky engagement escaped the session context, running its subscribe at the bare runtime edge. 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 — 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:

  • Ownership, proved by refusal: the minter's poll of a seat-registered queue 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: a channel message reaches the seat's own queue — the assertion a deaf seat cannot pass.
  • Cross-seat: with two real principals, one seat unsubscribing leaves the other receiving.

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 in docs/self-hosting.md with both supported ways out. Claude Code seats are unaffected — the plugin injects CLAUDE_CODE_SESSION_ID at 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 on mainZULIP_LIVE_CHANNEL_NAME names a channel that no longer exists, and four server.live.test.ts tests time out even with a working channel. Confirmed by a control run on eb0d2f1 (55 pass / 16 fail) and by running one of them on main with a corrected channel: identical failure. Not caused by this PR.

GraemeF added 6 commits July 31, 2026 08:14
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
GraemeF merged commit dbc1d26 into main Jul 31, 2026
2 checks passed
@GraemeF
GraemeF deleted the comms-g5zh.2 branch July 31, 2026 08:13
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant