# Row 302 (#1809, Vikunja 588): the idle-stop race Owner Gizmoduck. Reviewer Dewey. Gate owner Sage. Brief: docs/plans/2026-10-23_idle-stop-race-row.md, "Brief: the idle-stop race (Vikunja 588)". Base 0e30a85fa. This is the design, written static only. Nothing gets built until Dewey approves it (sage-3200). ## The race, as the code has it at 0e30a85fa - `packages/cli/src/launcher.mjs:703-717`. The idle tick calls the `threadIdle` op. On `released` it adds the run to `stopping`, records `thread-idle`, and only then sends SIGTERM (:716). - `packages/bus/src/broker.mjs:1044-1057`. `threadIdle` releases the claim in the same synchronous call (`#claimEnd(..., 'release', null)`, :1055). The comment at :1040-1043 says the order is deliberate: row 121 r2, Dewey's F3 in 28591. - `broker.mjs:1002-1008`. `#agent` is the only place a thread run's own call gets `not-holder`, because its session's latest claim row isn't a `claim` of its generation. `message.receive` (:5586-5591) and `state` (:5805-5807) both go through it, and so do `message.send` and `message.read`. - `packages/harness/src/runner.mjs:55`. `SESSION_GONE` holds `unauthenticated`, `not-holder` and `run-ended`. Four sites set `code = EXIT.session` (22) on any of them, without checking `stopping`: the landing `state` (:500-506), `message.receive` (:526-531), the turn's `state` (:545-552) and the reply (:575-582). - `packages/seat/src/session.mjs:340` maps 22 to `session-invalid`. So if a runner call reaches the broker after the release and before SIGTERM sets `stopping`, the run exits 22 by itself and ends `session-invalid`. A call already in flight when the release lands counts too. Row 292's diagnostic saw this once in about 100 idle stops. The clear test's run r846b8d083502 logged `not-holder` and then `exiting 22`. ## Design: a distinct answer for a run the idle stop released (Mos's suggestion, adopted) ### Broker (`packages/bus/src/broker.mjs`) 1. Add a new in-memory set, `#idled`, keyed `business:run`, next to `#ended`, `#received` and `#stops`. `threadIdle` adds the run's key in the same synchronous call that releases it, and only when it returns `{released:true}`. Release and mark happen together, so no call can be answered between them. 2. In `#agent`, the refusal becomes `fail(this.#idled.has(s.business + ':' + s.run) ? 'idle-released' : 'not-holder')`. Nothing else in `#agent` changes. A run the idle stop released now gets `idle-released` at every call after the release, whether it's a receive, `state`, a send or a read, and whether or not another run has claimed the session since. Every other path that takes a claim away still answers `not-holder`: - `assistant.stop` (:5975), `instance.end` (:6009), the chief lifecycle revoke (:4682) and `role.revoke` (:5230, :5235) - the session clear (:1146) - the limit stop (`#limitStop`, :4842-4855). It releases a thread claim and then refuses `not-holder` on purpose, so that the run exits 22. - another run's newer generation, where the run wasn't idle-released 3. `endLaunch` (:1300) drops the key along with `#ended.add`. After the end, the run's caps are gone and its calls answer `unauthenticated`, as they do today. The set lives only in memory, like `#runs`. A broker that restarts loses `#runs` too, so an old run's calls answer `unauthenticated` there either way. 4. `threadIdle`'s own refusals don't change. A second `threadIdle` on a released run is still `not-holder` (threads.test:392). The answer comes from the `#agent` path only, for the run's own calls. Why a new code and not a flag on `not-holder`: the wire carries only `error: ` (server.mjs:96-160), and the client accepts only `^[a-z-]{1,64}$` (client.mjs:42-43). A new code changes neither of them. Tools and docs that switch on `not-holder` keep their meaning, because `not-holder` still means "your claim was taken away". ### Runner (`packages/harness/src/runner.mjs`) 5. A new constant, `IDLE_RELEASED = "idle-released"`. It is not added to `SESSION_GONE`. At each of the four sites, `idle-released` is checked before `SESSION_GONE`. The runner logs `runner: session released by the launcher's idle stop`, leaves `code` at `EXIT.stopped` (0), sets a local `released`, and leaves the loop the same way the site does for `SESSION_GONE` today. 6. The `role.release` at exit (:589-595) is skipped when `released`. The claim is already released, so the call would only be refused and logged. 7. If the runner didn't handle the code, the receive loop would count `idle-released` as an unreachable broker and retry (:532-537). The run would then end at SIGTERM (0), or after 30 polls (23). Every site handles it so that neither of those decides how the run ends. How this meets mos-1411 condition 1: `unauthenticated` (the caps went at the end), `run-ended`, and every `not-holder` that isn't an idle release still exit 22 and end `session-invalid`. Only the broker's own record that `threadIdle` released this run maps to `stopped/0`. The launcher's `stopping` set and SIGTERM play no part in the mapping. That also covers a poll that lands before the signal, and a call that was in flight when the release landed. ### Launcher and session.mjs 8. No change. The runner exits 0 by itself, and SIGTERM's start-time check (:716) then finds no process, which the code already handles. `endReason(0)` is `stopped`. ## Tests ### The forcing test (mos-1411 condition 2): new file `packages/cli/tests/idle-stop-race.test.mjs` This is a thread launch on the same fixture shape as thread-launch.test.mjs (`start(t, prepared, { ops })` replaces launch ops, :78-101), with `sessionDefaults.pollInterval` set to 100. The test replaces the `threadIdle` op: ```js threadIdle: async (m) => { const r = runtime.threadIdle(m.record); if (r.released) window.set(m.record.run, await held(m.record.run)); // returns only after the runner's own exit, or 20 s return r; } ``` `held(run)` waits for two things, in order. First, an `action.refused` event whose actor is `run` and whose seq is after the release's `thread_claims` row. That proves a runner call reached the broker inside the window. Second, the runner's own exit (the run's `runner: exiting N` line in runner.log, or its pid gone), with no SIGTERM sent. The launcher sends SIGTERM only after the op returns, so the window stays open until the runner has decided how it ends. The test then asserts all of the following: - `held` saw a refusal from the run after the release, and the runner exited before the op returned. - The refusal's code is `idle-released`. - The run's launch `end` event is `['stopped', 0, null]`. - runner.log has the idle-released line and no `session stopped working:` line. Against the code today, the refusal is `not-holder`, the runner exits 22 inside the window, and the end is `['session-invalid', 22, null]`. The test fails, and fails every time, not at some rate: the window is held open until a call has landed in it. If the runner fix is dropped, the runner retries and doesn't exit inside the window, so `held` times out and the "exited before the op returned" assertion fails. If the broker fix is dropped, the end is 22. A control test in the same file: after an idle release, a `role.revoke` the human approved on another thread's run (or `assistant.stop`) still ends that run `['session-invalid', 22, null]`. That shows a real not-holder still exits 22. ### Bus: new tests in `packages/bus/tests/idle-release.test.mjs` (a new file, so no existing assertion moves) - After `threadIdle` releases a-run, a-run's `message.receive`, `state`, `message.send` and `message.read` each refuse `idle-released`. Each writes an `action.refused` event with code `idle-released`, and none of them delivers a message or writes a receipt. - `not-holder` still comes back for: a run released by `assistant.stop`, a run released by a session clear, a limit stop (stop then receive), a run whose session another run claimed without any idle release, and a second `threadIdle` on a released run. After `endLaunch`, the idle-released run's calls get `unauthenticated`, as today (its caps go at :1310-1311). - A role run's `role.release` followed by its own receive still gets `not-holder` (no idle). ### Harness: no new runner.test case runner.test.mjs's `setup` (:105) binds role runs only, and its claims sit in `role_claims` (:876), so it can't reach `threadIdle`. The runner's own exit at receive is covered by the cli forcing test above, which runs the real runner. The forcing test reaches only the site that lands in the window, which in practice is the receive poll. The landing `state`, the turn's `state` and the reply sites get the same four lines, but no test forces each one. Q4 asks whether Dewey wants a per-site case. The existing 22 tests (:539-560, :630-647, :772) stay unchanged and pass unchanged. ## The one existing assertion that changes (goes to Mos through Sage, per the brief) `packages/bus/tests/threads.test.mjs:394`, `assert.throws(() => call('a', 'message.receive'), { code: 'not-holder' })`. This is a receive by the run that `threadIdle` released at :388, so under this design it answers `idle-released`. The assertion is changed, not loosened: the new pin is `{ code: 'idle-released' }`, and the property right below it (:395, the message stays `pending` for the next holder) stays. I've found no other existing assertion that pins `not-holder` for an idle-released run's own call. The build reruns `git grep -n "not-holder" 'packages/*/tests/*'` against every idle path before in-review, and names any other hit for Mos first. ## Docs - packages/bus/README.md:320-326 (threadIdle). Add: after a release, the run's own calls answer `idle-released`, not `not-holder`. Also say why. - packages/harness/README.md:295-302, the exit table. Row 0 becomes "stopped, by a signal, or because the launcher's idle stop released the session (`idle-released`)". Row 22 is unchanged. - packages/bus/README.md:207 (`assistant.stop`): no change. It still gives `not-holder`. ## Open questions for Dewey - Q1. `landRelease` (broker.mjs:4144-4165, launcher.mjs:876-889) has the same shape for a role run's land-relaunch: it releases, then the launcher sends SIGTERM, so a poll in between ends the run 22. I recommend folding it in. That means one line in `landRelease` (add to `#idled` on release), one bus assertion, and one cli forcing case like the one above. It falls outside this brief's words, so it needs Sage's yes. If the answer is no, I'll write it up as a follow-up row with the same evidence. - Q2. The code name `idle-released`. It names the cause, and if Q1 is yes it covers land-relaunch too, since that is also a release made by the launcher. A more general name like `launcher-released` would fit both. I prefer `released-by-launcher` only if Q1 is yes. Dewey picks. - Q3. A limit stop on a run that was already idle-released. `#limitStop` (:4842-4855) finds no claim to end and refuses `not-holder`, so the run ends 22. I'd leave that alone: a limit hit is a real stop and ends session-invalid, as it does today. It's possible only if the limit checker answers inside the window. - Q4. Per-site coverage in the runner. Only the receive site is forced. I can add a cli case per site (hold the window until the run's landing `state`, or a turn's `state`, lands in it), at about one fixture each. I'd add them only if Dewey asks, since all four sites share one helper in the build. ## Gate (brief) Dewey approves this design, then the manifest. Runs: the new forcing test, then compactions-launch.test.mjs whole, then the bus, cli and harness suites whole, then every `scripts/test-*.sh`. The mos-1411 point 4 rerun rule doesn't apply to this row's own gate. Mutants. Each one must die on the forcing test, with the bus test also named where it applies: - M1. `threadIdle` doesn't add to `#idled`. Dies on the forcing test (end 22) and the bus test. - M2. `#agent` ignores `#idled`. Same deaths as M1. - M3. The runner drops the `idle-released` branch at receive. Dies on the forcing test (no exit inside the window). - M4. The runner maps `idle-released` to `EXIT.session`. Dies on the forcing test (end 22). - M5. `#claimEnd` marks every release, not just threadIdle's. Dies on the bus not-holder controls and the cli control. - M6. `endLaunch` doesn't drop the key. This is not a correctness mutant: caps are gone, so the answer is `unauthenticated` either way. I'll drop it unless Dewey wants an assertion on it, and record it as an equivalent mutant. Limits will be set in the run plan after the design round (mos-1275: `timeout -k 10` at three times or more the longest run). ## After Dewey's d1 review (dewey-0875, 05:32Z): APPROVE on D1, folded here before any build D1 (required). role.claim's thread branch (broker.mjs:5154-5173) doesn't go through `#agent` and refuses only `run-revoked`, the session fence and `thread-already-held`. So an idle-released run could claim its free session again. Its key would still be in `#idled`, and a later real not-holder on that claim would answer `idle-released` and exit 0. Fix: the thread branch of role.claim refuses `idle-released` for a run in `#idled`, before the insert. The key stays, because the run's claim is over. Bus assertion: after threadIdle releases a-run, a-run's role.claim refuses `idle-released`, and no thread_claims row is added. Mutant M7: drop that check. It dies on that assertion. Correction to "Harness: no new runner.test case". That's true for threadIdle only. runner.test reaches landRelease, which is a role-run path: :742-772 releases coder-run with `ctx.broker.landRelease` and no launcher, and pins `out.code === EXIT.session` (:771) and `session stopped working: not-holder` (:772). If Q1 is yes, those two lines change (to stopped/0 and the released line). They go to Mos through Sage with threads.test:394, and runner.test:742-772 then serves as the land-relaunch forcing case. No launcher means no SIGTERM, so the receive always lands after the release. The cli case for land-relaunch is dropped. If Q1 is no, the follow-up row cites runner.test:771-772 as its evidence. Q2. The code is `launcher-released` if Q1 is yes, and `idle-released` if Q1 is no. Every name in this file reads that way. Q3. Agreed. A limit stop on an idled run stays not-holder and exits 22. Q4. No per-site cases. Both ops return `released:false` while a delivered message is unread (threadIdle via `t.unread`, landRelease via its count). The runner reads only after the reply (runner.mjs:575-580), so a run can't be released mid-turn. landRelease keeps a run that hasn't received yet (`landing:true`), and thread runs have no landing. So only the receive site can see the code. All four sites keep the shared helper, and the three unreachable ones get a one-line comment saying why. N1. The cli control must reach the runner before any launcher signal. It uses a role.revoke on the run (no launcher stop), or holds the signal the same way the forcing test does. assistant.stop gets checked first in case it goes through a launcher stop. It also asserts the run's runner.log has `session stopped working: not-holder` and `exiting 22`. N2. The control keeps "after an idle release", so `#idled` isn't empty when M5 runs. N3. M6 is equivalent. The key drop in endLaunch stays, so the set doesn't grow. N4. The other-pin grep also covers runner.test `session stopped working`. :558 (unauthenticated) and :647 (revoke) stay, and :772 changes only under Q1. Docs: if Q1 is yes, landRelease joins the bus README line and the harness exit-table row 0. ## Round d2 (sage-3204, 05:32Z) Line numbers. Sage's check against 0e30a85fa is right, and the references above are now corrected. The second threadIdle not-holder pin is threads.test:392 and stays. The receive pin that changes is :394, and the pending check is :395 and stays. The idle release is at :388. Sage asked for a grep of the land-relaunch tests for a not-holder pin on the relaunched run's own call. `git grep -l "landRelease\|land-relaunch" 'packages/*/tests/*'` lists 8 files. In those files, `not-holder|session stopped working|EXIT.session|session-invalid` hits only land.test.mjs and runner.test.mjs: - land.test.mjs:464, `assert.throws(() => w.call('merge-gate', 'message.receive'), /not-holder/)`. This is the released run's own receive after `landRelease` released it (:458). Under Q1 yes, it answers `launcher-released`, and the regex fails. That's a third old-test change for Mos through Sage. The new pin is `/launcher-released/`. - land.test.mjs:457 (a non-holder run's landRelease) and :463 (a second landRelease) are landRelease's own refusals. They stay not-holder, the same as threads.test:392. - runner.test.mjs:771-772 change under Q1, as in the section above. runner.test.mjs:548, :557-558 and :646-647 are unauthenticated, unauthenticated and a limit stop. They stay. - The six cli files have no hit. So under Q1 yes, the Mos package is threads.test:394, land.test:464 and runner.test:771-772. Under Q1 no, it's threads.test:394 alone. Nothing gets built until Mos rules and Sage gives the go. ## Round d3: as built (after mos-1414/1415/1416 and sage-3220) Q1 is yes, so the code is `launcher-released` everywhere. The broker set is `#launcherReleased`, not `#idled`. Every `idle-released` above reads `launcher-released`. ### Correction to d1: message.send doesn't go through `#agent` d1 said `message.send` and `message.read` both go through `#agent`. `message.read` does. `message.send` doesn't. So a released run's send isn't refused `launcher-released`. Only `message.receive`, `state`, `message.read` and `role.claim` answer it, and the bus test asserts exactly those four. That narrows what Dewey approved in d1, so I'm sending it to Dewey directly (sage-3220). ### Broker (packages/bus/src/broker.mjs) - `#launcherReleased = new Set()`, keyed `business:run`, next to `#stops`, with a comment. - threadIdle and landRelease add the key after their release transaction. - `#agent` refuses `launcher-released` for a run in the set, and `not-holder` otherwise. - role.claim (D1) refuses `launcher-released` right after the `agent-required` check, before the thread and role branches. So it covers landRelease's role runs as well as thread runs. - endLaunch deletes the key after `#ended.add(key)`. - Q3 needs no code. A limit stop runs `#limitStop` (broker.mjs:4482) before any `#agent` check, and it fails `not-holder` itself (:4862), so a limit stop after a launcher release still exits 22. ### Runner (packages/harness/src/runner.mjs) - `const LAUNCHER_RELEASED = "launcher-released"`. - The shared helper `ended(err, say = true)`. For `launcher-released`, it logs `runner: session released by the launcher`, sets `released` and leaves the exit code at `EXIT.stopped`. For the SESSION_GONE codes it keeps the old behaviour (`EXIT.session`, and the log line unless `say` is false). - It's used at the landing state check, receive, the turn state check and the reply. The reply site passes `say = false`, so its logging is the same as before. The three sites that can't see the code (Q4) carry a comment saying why. - At exit, the final `role.release` is skipped when `released`, because the launcher already released. ### Tests as built - `packages/cli/tests/idle-stop-race.test.mjs`. This is the threadIdle forcing case. Inside the first released run's held threadIdle op, the human's `assistant.stop` revokes the other thread's holder (N1: no launcher signal can come while the op is held, launcher.mjs:662-663). The op waits for both runners' verdicts and end events. The idle run logs `session released by the launcher`, ends `["stopped", 0, null]` before its thread-idle line, and has `exiting 0` and no `session stopped working`. The control logs `session stopped working: not-holder`, ends `["session-invalid", 22, null]` and has `exiting 22`. The Store shows `launcher-released` and no `not-holder` refusals for the idle run, and `not-holder` for the control. - `packages/bus/tests/launcher-released.test.mjs` (renamed from idle-release.test.mjs). It has three tests. (1) threadIdle: receive, state, read and role.claim refuse `launcher-released`, role.claim adds no thread_claims row, the four refusals are logged, nothing is delivered, threadIdle's own second refusal stays not-holder, and after endLaunch the answer is unauthenticated. (2) The control (N2: after an idle release): assistant.stop's run answers not-holder and run-revoked, pm's own role.release answers not-holder, and a new holder for the released thread doesn't change the released run's answer. (3) landRelease: receive and state refuse `launcher-released`, role.claim refuses and adds no role_claims row, landRelease's own second refusal stays not-holder, and coder's own role.release answers not-holder. - runner.test.mjs:742-772 is the land-relaunch forcing case (mos-1415). There's no cli land-relaunch case. - The four old lines Mos ruled are in ASSERTION-PRESERVATION.md. The diff on old test files is those four lines and nothing else. ### Docs The bus README's threadIdle paragraph names `launcher-released`, the four calls that answer it, landRelease, and that every other lost claim stays not-holder. The cli README's land-relaunch paragraph says calls between the release and the signal refuse `launcher-released` and the runner exits 0. The harness README's exit table row 0 names the launcher release. ### Red first (mos-1415) There are two commits. The first holds only the tests and the four ruled pin edits. The second holds the fix (broker.mjs, runner.mjs) and the READMEs. The gate's first stage runs on the tests-only commit, and these two must fail there: - idle-stop-race.test.mjs: the idle run ends `["session-invalid", 22, null]`, or its verdict is `session stopped working: not-holder`, whichever lands first. - runner.test.mjs, the case at :742: out.code is 22, not 0. The bus test files and threads.test:394 and land.test:464 fail there too, and the packet records them. The red run doesn't count as the gate. ### Mutants (each run against the fix commit, restored and checked by sha256 after) - M1. threadIdle doesn't add the key. Dies: idle-stop-race (end 22), launcher-released.test test 1, threads.test:394. - M2. `#agent` ignores the set. Dies: the same as M1, plus launcher-released.test test 3, land.test:464 and runner.test:771. - M3. The runner's helper drops the `launcher-released` branch. Dies: idle-stop-race (the run doesn't exit in the window, so `window.idle` is null) and runner.test:771-772. - M4. The runner maps `launcher-released` to `EXIT.session`. Dies: idle-stop-race (end 22) and runner.test:771. - M5. `#agent` answers `launcher-released` for every lost claim. Dies: the idle-stop-race control (verdict and end 22), launcher-released.test test 2, and test 3's coder line. - M6. endLaunch doesn't drop the key. Equivalent (N3), not run. - M7. role.claim drops the D1 check. Dies: launcher-released.test tests 1 and 3 (the claim succeeds). - M8. landRelease doesn't add the key. Dies: runner.test:771-772, land.test:464 and launcher-released.test test 3. ### Run plan (mos-1275 limits, `timeout -k 10`) 1. idle-stop-race.test.mjs alone, 600 s. Then runner.test.mjs whole, 900 s. 2. compactions-launch.test.mjs whole, 600 s. 3. The bus, harness and cli suites whole: bus 1200 s, harness 1200 s, cli 2100 s. 292's cli suite took 630 s. 4. Every scripts/test-*.sh, 900 s each. 5. M1-M5, M7 and M8, each on its named files at the limits above. TMPDIR comes from `mktemp -d /mnt/storage/scratch/tmp/gXXX`. Every run is slot work and starts only on a CLEAR from Sage. ## Round d4: Dewey's early static read (dewey-0878, 05:47Z) C1. The d3 "correction" about message.send was wrong, and d1 was right. `message.send` calls `#consumeAuthority` (broker.mjs:5506), which calls `#checkAuthority`, which calls `#agent` (:3749). So a released run's send refuses `launcher-released`, and so does every verb that reaches `#agent`. There are 29 call sites, including role.release and every authority check. role.claim refuses it before the claim (D1). I misread the send path in d3, and no code depended on that reading. The runner's reply site already handles the code. The bus README now says "every call that needs the claim", not a list of four. Bus test 1 adds a message.send refusal, so the refused list is five `launcher-released`. N1. Nothing killed a mutant that drops `&& !released` at runner exit (runner.mjs:596). That run calls role.release, logs `runner: role.release: launcher-released` and still exits 0. idle-stop-race now asserts the idle run's runner.log has no `runner: role.release:` line. - M9. Drop `&& !released` at runner.mjs:596. It dies on idle-stop-race (the new doesNotMatch). N2. idle-stop-race waits for the whole held window (`"revoked" in window`), then asserts `window.control` is non-null with a message. If the other thread's run isn't holding when A's first release lands, the test now fails with that message instead of a bare "timed out waiting". The mutants to run are M1-M5, M7, M8 and M9. These test edits are in a third commit, after the fix. ad9fa9f16 stays the red-first commit for the two forcing cases mos-1415 names. ## Round d5: red-first reordered (sage-3225, 05:49Z) Sage pointed out that 8cf2c6d54 changed idle-stop-race.test.mjs and launcher-released.test.mjs after ad9fa9f16. So a red run on ad9fa9f16 would test older copies than the ones that land. I took the first of Sage's two options and reordered the commits locally, before any push. The order is now: 1. Tests: the final test files and this packet, over unfixed product files. 2. The fix: broker.mjs, runner.mjs and the three READMEs. ad9fa9f16, 6e5f782ae and 8cf2c6d54 are superseded and were never pushed. The old tip is kept locally as gizmoduck/row302-pre-reorder. The fix commit's tree matches 8cf2c6d54's except for this BUILD.md note. The "third commit" in d4 now lives in these two commits. The red run uses the tests commit as it stands, with these test blobs (identical in the fix commit): | Blob | File | | --- | --- | | 52343932b231691ba2b4fc117547ea7f6f504704 | packages/bus/tests/land.test.mjs | | 6c83c9ede41247fd50d40eeac3e8a16a75efacbf | packages/bus/tests/launcher-released.test.mjs | | 27193a745c4ed8d4936de96c1768ab010a572856 | packages/bus/tests/threads.test.mjs | | 9dfacfea15a487c4e7056ebae3e311cb514b0467 | packages/cli/tests/idle-stop-race.test.mjs | | 0792431b60314a17ed74b3df0c5b3748dc3c1286 | packages/harness/tests/runner.test.mjs | The red run checks these blobs with `git rev-parse HEAD:` before it starts. ## Round d6: the first gate run's red in launcher-released.test.mjs:89 (gizmoduck-0388, sage-3294) The slot opened at 08:15Z (sage-3289). The gate tree was 649ec67ab, a local merge of 64f6558ee onto origin/refactor 1ac655005. The red stage on f799e8b15 passed: seven expected reds, with the pins at threads.test.mjs:394, land.test.mjs:464 and runner.test.mjs:771 (:772 sits behind :771 in one test). On 649ec67ab, idle-stop-race 1/1, runner.test 40/40 and compactions-launch 5/5 went green. The bus suite then had 875 of 876 pass. The one red was this row's own test, at launcher-released.test.mjs:89, 1 !== 0. The gate stopped there, and the red went to Sage before any fix or rerun. The test was wrong. `#route` (broker.mjs:6482) writes one routed delivery row for every message. After threadIdle no run holds thread A, so `#recipient` (:832-834) returns null and `#delivery` (:6612) writes holder_run null. The line counted every row for the message. Nothing was delivered to a-run. ASSERTION-PRESERVATION.md entry 5 has the old and new lines. The fix narrows the count to rows for a-run and adds the line Sage made mandatory: the message has exactly one row, routed, holder_run null. bus rows come from node:sqlite with a null prototype, and the tests use assert/strict, so the second line spreads each row before deepEqual. That is the idiom in sessions.test.mjs:385 and session-clear.test.mjs:564. Sage's draft compared store.all directly, which would fail on the prototype alone. Packaging (sage-3294): this is a third commit on gizmoduck/row302 after the pushed f799e8b15 and 64f6558ee. It touches only that test line, this note and ASSERTION-PRESERVATION.md. It goes out as a fast-forward push in a gap Sage names, after Mos rules (sage-3293). Then: 1. The red run uses the final test files over f799e8b15's product: f799e8b15 checked out detached, with the five test files from the new tip checked out on top. The script checks the new blobs first. 2. The whole gate runs again on a fresh merge of the new tip onto origin/refactor. Dewey reviews the delta. The first run's logs stay in gizmo-302-out/gate-run1/.