# Row 306: a revoked run's late bus call stops it instead of exiting 22 (#1813, 295 N1) Owner Morgana. Reviewer Dewey. Gate owner Sage. Brief: docs/plans/2026-10-23_gate-env-rows.md, third section (c16e943b9). Go: sage-3478 (Mos-1453 puts 306 next). Base: refactor fe4ee8c7b, after row 272 (d33989230) and row 307 (d243f6b25). Every line below is as it stands at fe4ee8c7b. ## Design round 1 (no code yet), round 2 edits marked r2 ### The window today - `#revokeLost` (packages/bus/src/broker.mjs:1012-1029) ends each lost user thread's held claim in one transaction: `#claimEnd(..., 'revoke', null, 'access-revoked', by)` at :1022. The four callers are the timer `accessSweep` (:1039), a user's request (:4542), `access.reload` (:4561) and `threadsState` (:1107 and the access-expiry sweep at :1113). - The launcher learns of the loss from `threadsState`'s `revoked: true` row, records `thread-revoked` and sends SIGTERM (packages/cli/src/launcher.mjs:710-718). - A runner call between that claim end and the signal goes through `#agent` (:1064-1072). The run's latest claim row is now op `revoke`, so the check at :1069 fails. :1070 answers `launcher-released` only for a run in `#launcherReleased` (:431, row 302), and `not-holder` otherwise. - The runner's `ended()` (packages/harness/src/runner.mjs:488-500) counts `not-holder` as a gone session (`SESSION_GONE`, :55). It sets `code = EXIT.session` and the run exits 22 with `session-invalid`. ### D1: the broker remembers the revoked run in memory, the same way as #launcherReleased - A new field next to `#launcherReleased` (:431): `#accessRevoked = new Set()`, keyed `business:run`, with a comment naming row 306. - `#revokeLost` adds each ended claim's `h.business + ':' + h.holder_run`. It adds them after the transaction at :1018-1026 commits, by iterating `ended`, so a transaction that throws leaves the Set unchanged. This is the only place the Set grows. No other `#claimEnd` caller adds to it: `assistant.stop` (:6078), `role.revoke`/`role.end` (:5333, :5338), `instance.fire` (:4780), the limit stop (:4949), `threadIdle` (:1133) and `landRelease` do not. - `#agent`'s refusal at :1070 becomes three-way, and only on the existing failure branch: `launcher-released` if the run is in `#launcherReleased`, else `thread-revoked` if it is in `#accessRevoked`, else `not-holder`. A run that still holds its claim never reaches the branch. The two sets don't overlap. A released run holds no claim for the sweep to end, and a revoked run holds none for `threadIdle` or `landRelease` to release (both refuse `not-holder` first, :1131 and :4237). - **Where the memory ends.** `endLaunch` deletes the key next to `#launcherReleased.delete(key)` at :1394. That is also where every capability of the run is dropped (:1395-1396), so after it no call of the run reaches `#agent`. - **The alternative I didn't take.** Derive the answer from the store: the run's latest claim row is op `revoke` with reason `access-revoked`. It would survive a broker restart. But `reason` is caller text at :4780, :5333 and :5338, so a human's `role.revoke` with reason `access-revoked` would turn that run's `not-holder` into `thread-revoked`, a stopped exit. `by` doesn't separate the cases either: `access.reload` passes the human's name (:4561). The Set can't be spoofed that way, and it matches row 302's shape. The cost is N1 below. - **Unchanged.** `role.claim` keeps its order: `launcher-released` (:5252), then `run-revoked` for a run with any revoke row in its session (:5257-5258), then `thread-revoked` from the file (:5259). `bindLaunch` keeps refusing `thread-revoked` for a new run of a lost thread (:1371). The limit stop's `not-holder` (:4952) is unchanged. ### D2: the runner maps thread-revoked from a call to stopped - runner.mjs gets `THREAD_REVOKED = "thread-revoked"` next to `LAUNCHER_RELEASED` (:58), with a comment naming row 306. - In `ended()` (:491-500), `thread-revoked` takes the launcher-released branch: - it logs its own line, `runner: thread revoked by the access file`; - it sets `released = true` and returns true; - `code` stays `EXIT.stopped`. `released` also skips the exit-time `role.release` (:596-602), which is right because the sweep already ended the claim. - The refusal of the run's first `role.claim` (:459-467) is unchanged. `thread-revoked` there stays `EXIT.claim` (21), because the brief maps it only "from a call" after the claim. Mostly `bindLaunch` refuses such a run before it starts. - `ended()` covers every call site in the loop: landing `state` (:521), `message.receive` (:543), the turn's `state` (:561) and the reply's `message.send`/`message.read` (:589). The harness's tool calls go through the tool server and return their error to the harness, as they do for `launcher-released` today. The run stops at its next own call. ### D3: docs - packages/bus/README.md:328: "the run's next bus call refuses `not-holder`" becomes `thread-revoked`, until the run ends (row 306). - packages/harness/README.md:296: exit 0 adds `thread-revoked` next to `launcher-released`. - The `#revokeLost` comment at broker.mjs:1008 changes the same way as the bus README. ## Tests (red first under mos-1272) ### New file packages/bus/tests/revoke-window.test.mjs It uses access-revoke.test.mjs's fixture shape, copied, because that file is an old test. - **T1.** A run the timer sweep ended answers `thread-revoked` on `message.receive`, `state`, `message.read` and `message.send`, and each answer is logged as `action.refused` with code `thread-revoked`. Its `role.claim` still refuses `run-revoked`. The same holds when the trigger is a user's request or `access.reload`, one assert each, because all three go through `#revokeLost`. - **T2, the control.** In the same broker, after the sweep ended the first run: - a second user thread's run whose claim the human's `assistant.stop` ended answers `not-holder`; - a third run with a pending limit stop answers `not-holder` on its first `message.receive`, and then `not-holder` again on its next call (r2, Dewey C2). The first answer comes from `#limitStop` (:4952) and never reaches `#agent`, so only the second call checks the Set. ### New file packages/cli/tests/revoke-window.test.mjs thread-revoke.test.mjs's `start()`, copied the way idle-stop-race.test.mjs copied thread-launch's, with the `threadsState` op replaced by the caller. - **T3, the forcing test.** - Setup: a user thread A owned by alice and a human-owned thread B. Both get a message and a RESULT, so both runs hold. - Trigger: a hand edit drops alice's grant. - The hold: the replaced `threadsState` op calls `runtime.threadsState`. That call's sweep ends A's claim. When the rows include A as `revoked`, and before returning them to the launcher, the op has the human `assistant.stop` the instance's human-owned threads (B), then waits. It waits until A's runner has written its verdict line and the launch log has A's `end`, then the same for B. The launcher doesn't tick while a pass is running, so nothing signals either run while the op is held. - Asserts for A: - the verdict is `thread revoked by the access file`; - the end is `["stopped", 0, null]`; - the launch log has no `thread-revoked` event for A's run (r2, Dewey C1). The launcher removes A from the registry before it records `end` (packages/cli/src/launcher.mjs:643-646), so when the held op returns, `registry.running` at :705 finds nothing and :711-717 record and signal nothing. The runner stopped by itself before any SIGTERM; - the runner log has no `session stopped working` and no `role.release:`, and ends with `exiting 0`; - the store's `action.refused` codes for A include `thread-revoked` and not `not-holder`. - Asserts for B, the control: - the verdict is `session stopped working: not-holder`; - the end is `["session-invalid", 22, null]`; - B's codes include `not-holder`. - Every wait returns what it last saw, so a mutant fails on an assertion, not a timeout (sage-2707). ## Mutants | # | Edit | Must turn red | |---|------|---------------| | M1 | `#agent` drops the `#accessRevoked` check: the failure answers `launcher-released` or `not-holder` | T1, T3 (A gets 22) | | M2 | runner `ended()` maps `thread-revoked` to `EXIT.session` (it moves into `SESSION_GONE`) | T3 (A exits 22) | | M3 | every ended thread claim marks its run revoked: `#claimEnd` adds `s.business:h.holder_run` to `#accessRevoked` when `h.thread` (anchor `if (h.thread)`, one hit at :6495) | T2 (both halves, the limit half on its second call), T3 (B gets thread-revoked) | Each anchor must hit exactly once. The kit prints the count, as row 295's M8 receipt did. ## Expected first-run reds - **New tests (mos-1272).** On a red-first run against fe4ee8c7b, T1 and T3 are red: A gets `not-holder` and exits 22. T2 is green on both sides. Any other first-run red in T1-T3 I place myself and report after. - **Old test that changes: Mos GO (mos-1463, via sage-3483).** In packages/bus/tests/access-revoke.test.mjs (row 295), `not-holder` becomes `thread-revoked` in the six asserts at :103, :120, :140, :171, :201 and :262, and the :95 title's "refuses not-holder" becomes "refuses thread-revoked". Nothing else in the file changes; `git diff --numstat` for it reads `7 7`. Dewey checks that in the manifest round. At fe4ee8c7b those are the only seven `not-holder` lines in the file. - **Checked and not expected to change.** - thread-revoke.test.mjs:180 asserts `["stopped", 0, null]` for a run that only the SIGTERM ends. If its runner's poll happened to land in the window, it would get exit 0 now, where today it would get 22, so the assert holds either way. - idle-stop-race.test.mjs's control is a human `assistant.stop`, not a revoke, so it stays `not-holder`/22. M3 would turn it red too. - A `git grep` of `packages/*/tests/*` finds `not-holder` after a revoke only in access-revoke.test.mjs. ## Gate (per the brief) Dewey approves this design, then the manifest. On Sage's gate (mos-1455 D4, sage-3483/3484): - the new files and access-revoke.test.mjs whole; - the bus, cli, plugins, webui, business and harness suites; - every `scripts/test-*.sh` under `timeout -k 10` at three times its longest run (mos-1275); - M1-M3. Every suite shows 0 failures. Any red follows mos-1272. ## N items Both stay out of this row (sage-3483); Sage adds them to DEFERRED at landing. Dewey agrees on N1 (dewey-0918). - N1. The Set is lost on a broker restart. recover() rebinds an earlier host's run (:1366-1373), and if that run makes a call before the launcher's SIGTERM, it gets `not-holder` and exits 22, as every revoked run does today. Seeding the Set from the store at a rebind would need a mark only `#revokeLost` writes (D1's alternative shows `reason` and `by` aren't one). I'd leave it unless Dewey wants that mark. - N2. A revoked run that also has a pending limit stop gets `#limitStop`'s `not-holder` (:4572-4573, :4952) on `message.receive` and `state`, because that check runs before `#agent`. It's rare; I'd leave it and record it. ## Round 2 (dewey-0918 CHANGES C1-C3, sage-3483) - C1: T3 asserts A has no `thread-revoked` launch-log event, in place of "end before the event". - C2: T2's limit-stopped run makes a second call, which carries M3. - C3: the gate lists the six suites and every scripts/test-*.sh with 0 failures; mos-1463's bounds for access-revoke.test.mjs are in "Expected first-run reds". - Cosmetic: threadIdle's `#claimEnd` is :1133. ## Build (dewey-0920 APPROVE r2, sage-3487) - Red-first commit d38c5213a: packages/bus/tests/revoke-window.test.mjs (T1 two tests, T2) and packages/cli/tests/revoke-window.test.mjs (T3), no product code. - Build commit (this one): D1 in broker.mjs, D2 in runner.mjs, D3 in the two READMEs and the #revokeLost comment, and the mos-1463 edit to access-revoke.test.mjs (numstat 7 7). - Nothing has run yet; every run is slot work. Expected first-run reds, on d38c5213a (new tests, fe4ee8c7b product): - bus revoke-window: both T1 tests red, A answers `not-holder` where `thread-revoked` is expected. T2 green. - cli revoke-window T3 red: A's verdict is `session stopped working: not-holder`, its end `session-invalid`/22. - access-revoke.test.mjs is unchanged at d38c5213a, so it stays green there. On the build commit every file above, and every gate suite, is expected green.