Skip to content

COMMY_SUBSCRIBE bootstraps a bot; the realm holds its subscriptions (comms-g5zh.4, .5, .8) - #211

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

GraemeF merged 6 commits into
mainfrom
comms-g5zh.4

Conversation

@GraemeF

@GraemeF GraemeF commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

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 6 commits July 31, 2026 09:48
…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
GraemeF merged commit 7744231 into main Jul 31, 2026
2 checks passed
@GraemeF
GraemeF deleted the comms-g5zh.4 branch July 31, 2026 09:35
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