# Row 264 build notes (Gizmoduck) T1 item 7: independent sessions, compaction counts and clear context. Gitea #1769, Vikunja 463 (design) and 466 (Claude compaction format, T3's dependency, linked and not built here). Brief: docs/plans/2026-10-21_t1-sessions.md, "T1 item 7: independent sessions, compaction counts and clear context" (blob ff0be0c79). Reviewer Duckworth, gate owner Sage. Branch gizmoduck/row264 from refactor da9555ca2. MVP due 10-27. The brief pins its code citations at cca206cdc. `git diff --stat cca206cdc da9555ca2` over every cited source file is empty, so F1-F11 still hold line for line. Only docs/prd/mosaic-stack.md moved. ## Phase A: what the code has today - Bus. `threads` binds one `instance` per thread (schema.sql:240-250). `thread_claims` keys on `thread` alone (:251-261), and `message_threads` maps a message to a thread (:262-265). Schema is 3j (store.mjs:17). The thread tables first appear in migrations/3d-3e.sql. - Launcher. `check()` refuses a second run for the same instance and thread (launcher.mjs:233-240, `registry.running(b.id, instance, thread)`). - Seat. `threadStore()` makes one `session/` and one `claude-projects/` per thread (session.mjs:51-74). The registry's running key is business, instance and thread (:215-218). - Runner. `stop()` sends TERM to the child group, then KILL after 5 s (runner.mjs:402-414). It has no drain, no idle acknowledgement and no dequeue fence. D7 step 2 needs all three. ## Consumer audit (D9), first pass `git grep -F` over packages/*/src/* and adapters/* at da9555ca2: | Name | Files and hits | | --- | --- | | `thread_claims` | broker.mjs 12, store.mjs 1 | | `message_threads` | broker.mjs 9 | | `thread_opens` | broker.mjs 4, store.mjs 1 | | `threadStore` | launcher.mjs 4, sandbox.mjs 7, session.mjs 3 | | `running(` | launcher.mjs 5, session.mjs 1 | Still to read: the broker holder, agents, end, usage, reply correlation and pending-approval paths named in D9, the two adapters (F5), both harness plugins, the conversation reader (F8) and the Console. Each gets a file:line row here before the first code commit. ## Proposed slices One seat can't build D1-D9 in six days and keep every slice reviewable. I propose five candidates under row 264, each with its own manifest and Duckworth round: 1. 264a bus. Session tables, claim key `(organization, thread, instance, session)` with a generation, the D3 default session, the D9 bus mapping, `session.list` and `session.create`, and an optional session selector on the thread verbs. Tests: T1 bus half, T2 claims, T7 legacy clients and the rollback refusal. 2. 264b seat and launcher. Per-session native store and workspace, the single-writer fence, the registry key with session, sandbox mounts of the selected store only, and pinned native ids in both adapters. Tests: T1 run half, T2 workspace and path mutants. 3. 264c compaction count. Host collector, exact for Pi. Claude shows `unknown` with the 466 reason until that format is pinned. Tests: T3 Pi half. 4. 264d clear. The D7 journal, the runner drain fence and idle acknowledgement, stop proof, then retirement and successor in one transaction. Tests: T4, T6. 5. 264e surface. `mosaic session` CLI and the Console chooser, count and clear confirmation. Tests: T5 for the legacy single human, T7 Console half. ## Phase A rulings (sage-2235) Q1-Q7 went to Sage in gizmoduck-0154. Sage's answers are in agents/sage/work/queue-264/RULINGS.md at 5f5b51dd6. In short: - Q1. Five candidates in the order a, b, c, d, e, each with its own manifest, Duckworth round and landing. Row 264 carries 264a. Sage adds rows for 264b-264e. All five are MVP. - Q2. 264a builds as 3j-3k.sql. If 236 or 206 lands first, I rebase onto a new branch name, take the next label, and rerun the whole bus suite, the schema.test.mjs counts and the T46 lists. - Q3. PRD 1.13 has landed, and REQ-ROLE-2 (mosaic-stack.md:323-329) already carries the session text. Build to it. - Q4. Row 194's lifecycle path owns the stop. honker/row194-b5 dfabbbe8a is read-only reference. 264d rebases onto 194's landing. The runner's dequeue fence and idle acknowledgement are new code and a named review item. No 194 code gets copied. - Q5. One shared admission hook for every session start, using today's limits and revocation. Of 191 and 264b, whichever lands second wires 191's checks in. No setting turns it off, and a check that's configured but unavailable fails closed. - Q6. 264c lands with Pi exact and Claude `unknown` with the 466 reason. The Claude half of T3 stays open on row 264 and Vikunja 466. - Q7. Legacy single human only. Collaborator and share paths refuse. A4 and the full matrix go to whichever of 264 and 193 lands second. ## 264a design: the bus Line numbers are at da9555ca2 unless they say otherwise. ### Schema 3j-3k - `sessions`, new and immutable: `id`, `at`, `business`, `thread`, `instance`, `owner`, `name`, `op` (the creation operation key) and `predecessor` (null until 264d's clear). UNIQUE `(thread, name)` and `(thread, op)`, so a repeated create with the same key finds the first row. Guard triggers check that the business, instance and owner match the thread's, and that a predecessor sits in the same thread. - `session_defaults`, new and append-only: `thread`, `session`, `at`, `by`. The latest row per thread is that thread's default (D3). A trigger checks that the session is in the thread. - `thread_claims`, `message_threads` and `thread_opens` are rebuilt, each with a `session` column, and `thread_claims` also gets `generation`. Rows are copied with their seq values and every old column as it was, and the sequence carries over, as 3h-3i.sql did for `usage_records`. That is a named review item: the copy adds two derived columns and changes nothing else. - Each existing thread gets one legacy default session with **id equal to the thread id**, the name `default`, op `legacy-3k`, and the thread's own `at` and owner. Every copied row names that session. This is D9's immutable mapping. 264b binds it to the existing native store at `threads///session/`. A new thread's default session gets a random id. - `generation` is the claim's number within its session, counting from 1. A release or revoke carries its claim's number. On copy, it's the count of claim rows in the thread up to that row. - Triggers keyed on the thread now key on the session: one holder, release only while held, only the holder releases, and a generation check. These two keep their thread meaning: one held session per run per business, and the business check. New checks: the claim's, message's and open's session is in the named thread. `delivery_same_thread` becomes `delivery_same_session`, which sends a threaded message only to its own session's holder. - A 3j binary refuses a 3k store with `schema-mismatch` (store.mjs:181-186), which covers D9's rollback refusal. T7 tests it. ### Broker - Run binding. `bindLaunch` takes an optional `session`. When it's missing, the run gets the thread's default session, which is the D3 path for today's launcher. The binding refuses a session that isn't in its thread. The run's `s.session` then drives `role.claim`, `#own`, `message.receive` and the reply routing. - Lookups. `#threadHolder(thread)` becomes `#sessionHolder(session)`, which `#recipient`, `#routeThread`, `#threadHolders`, `#claimEnd`, `threadIdle` and run end all use. `#threadFor` returns a thread and its session: a thread run's own, or the session of the nearest threaded message up the reply chain. `#specThread` uses the opened thread's default session. - Launcher view. `threadsState` and `threadIdle` keep their row shape, one row per thread computed on its default session. Until 264b, the launcher starts only default sessions. Messages to another session wait, and nothing reaches the default session's run. - Verbs: - The thread verbs take an optional `session`, and leaving it out uses the default (D3). `thread.create` also makes the default session. `thread.messages` without a session reads the default session. - `session.list {thread}` and `session.create {thread, name, op}` (D8: metadata only). The build dropped `session.get`, `session.open` and `session.send`. `thread.open`, `thread.send` and `thread.messages` take an optional `session`, which does the same job with one owner check and one code path. - All of them are human or tester only, through `#human`/`#thread`, the same owner check as the thread verbs (Q7: single human, no shares). They go into `TESTER_VERBS`. - Other readers. `#grantNotice`, `#raisedThread` and the decision-close path (:736, :1500, :2941) read the holder run's claim row. The rebuilt `thread_claims` keeps that query working, and the session comes along from the same row. ### Tests and lists - New file `packages/bus/tests/sessions.test.mjs`: - T1 bus half: two sessions in one thread, distinct histories, receive and reply isolation, and a non-thread role refused. - T2: one holder per session, generation fencing, sibling delivery refused by the trigger. - T7: legacy clients, the 3j-3k migration from a fixture (default session id = thread id, rows copied with seq), and the rollback refusal. - A new `fixtures/schema-3j.sql`. - Update the schema.test.mjs pins (:519-525), the `'3j'` version pins in the migration tests, `COUNTED` in scripts/stack-release.sh:404, and the T46 lists in packages/cli/tests/stack-release.test.mjs:860-934. Also `timedTables` (store.mjs:65-79) and the broker clock-seed list (:325-336). ### Duckworth's design checks (duckworth-0106) Duckworth had no objection to the in-place rebuild and asked for seven checks. Where each one is: 1. The cross-table trigger. 3j-3k.sql drops `delivery_same_thread` before any rename and creates `delivery_same_session` after the new tables. 2. Index names. The step drops `thread_claims_thread` and `message_threads_thread` before the renames, so the new tables can reuse the names. 3. Per-table triggers. The step drops each guard and append-only trigger and creates it again with text that matches schema.sql. The digest check at store.mjs:231 enforces that, and the T7 `sqlite_master` comparison shows it. 4. Generation counts claim rows only. T7's fixture is Duckworth's: claim, release, claim, revoke, giving 1, 1, 2, 2, with t2's claim between them. 5. Sequences. Rows keep their seq values, `sqlite_sequence` carries over, and T7 checks that the next insert is 41 after 40. 6. Id collisions. A legacy session id is its thread's id. `sessions_id_not_thread` refuses a session id that names another thread, and `threads_id_not_session` refuses a thread id that names a session. New ids come from randomUUID. 7. `thread_one_per_run`. The inner `max(seq)` is per session. ### Duckworth's early look (duckworth-0107) and Sage's ruling (sage-2257) Duckworth read 312cf33a1 with git only and found nothing blocking. Round 2 (gizmoduck/row264-r2, rebased on dcd8d4dda with no shared paths) answers his notes: 1. Retired-generation replay. Sage ruled it into 264a (sage-2257). `#agent` now states the thread run's fence in generations: the session's latest claim must have the generation of the run's own latest claim (`#runGeneration`). This refuses nothing new. The trigger `thread_claim_generation` gives each claim in a session its own number, so with `h.op` 'claim' the generations match exactly when `h.holder_run` is the run, which is the check it replaced (duckworth-0108). 312cf33a1 would most likely refuse the same calls. A new sessions.test case has a released run and a revoked run each try `decision.withdraw`, `message.receive` and `message.read` after another run claims the next generation. All refuse `not-holder` and every table's row count stays the same. The workspace and path halves of T2 stay in 264b. 2. `assistant.stop` keeps returning `{thread, run}`. Adding `session` would change the pins in tester-threads.test and threads.test for a field no caller reads yet. The Console stop in 264e is the first reader, so the field goes there. 3. `#route` refuses an unknown session with `session-not-found` instead of a TypeError. 4. The README's session section says the `default` retry answers on new threads only, because a migrated default has op `legacy-3k` (`legacy-3l` from round 5). 5. The Row 155 comment at the role.revoke end now says "held thread session". ### Mutants Each runs on a copy of the candidate tree with one edit, and the named test must fail. - M1 (sage-2257). In `#agent`, replace `h.generation !== this.#runGeneration(s)` with `false`. Killed by sessions.test "a released or revoked run's retired generation". M1 removes the thread run's fence entirely, so the kill shows the refusal is tested, not that generations add a check. A mutant that puts back `h.holder_run !== s.run` survives by equivalence. That isn't a coverage gap. - M2 (mos-1279, sage-2688, sage-2690). Four edits, and the restore sha check covers all four files: 1. broker.mjs:5251, in `message.receive`: drop the `mt.session=?` filter (the session clause and its argument). 2. 3k-3l.sql: drop the `delivery_same_session` trigger. 3. schema.sql: drop the same trigger. 4. store.mjs:16: set `SCHEMA_SHA256` to the sha256 of the mutated schema.sql, with the replace sessions.test:432 uses. store.mjs:94 checks schema.sql against it at import (duckworth-0144). `LEGACY_SHA256` stays. The chain check at :117 holds because both SQL files drop the trigger. Without edits 2-4, the trigger refuses #delivery's INSERT first. request() then returns storage-refused before any assertion runs. That happened at r13 (duckworth-0143). A kill counts only as an assertion in a test that exercises receive: sessions.test T1 :89, T7 :422, or another receive assertion the log names. A failure only at the trigger's own pin (:164-170) doesn't count, and neither does a thrown error. The killing test is expected to be T1, where a1 would take the second session's message. - M3. In 3k-3l.sql, count every claim row for `generation` instead of `op='claim'` rows only. Killed by sessions.test T7, generations 1, 1, 1, 2, 2. Phase 4 rerun on r13 (mos-1279, sage-2688). The r13 gate's phases 1-3 carry. M1-M4 rerun whole on 2cc34aef8 plus this note, each under `timeout -k 10 300` (Run limits). Any survivor, throw or non-receive kill goes to Mos unplaced. ### Dry run Before writing any node test, I ran 3j-3k.sql in an in-memory Python sqlite3 on a 3j fixture (gizmo-264-out/dry-3k.py, no repo code). The claims were kept with generations 1, 1, 1, 2, 2 in seq order for the mixed t1/t2 fixture. Seq 40 carried over and the next insert was 41. The migrated `sqlite_master` matched a fresh schema.sql, 119 rows each. Every guard refused its bad write. ### Hand-made threads in tests A thread that a test writes by hand has no default session, because only `thread.create` and the migration write one. The broker refuses such a thread with `session-not-found`, and the launcher view leaves it out. So every test that inserts a `threads` row now also writes the legacy-shape default session (`DEFAULT_SESSION` in threads, end-launch, thread-cli, thread-launch and chat tests). That mirrors the migration's rows rather than inventing a third shape. ### Round 5: on row 236, the step is 3k-3l Row 236 (8f51900b5, which round 5 is built on) adds `migrations/3j-3k.sql` with the `gitea_users` and `gitea_grants` tables, and lands first. By the landing-order label (Q2, sage-2412) 264a's step moves one version. The design above still says 3j-3k; read it as 3k-3l. - `migrations/3k-3l.sql` is round 4's `3j-3k.sql` with three renames and nothing else: the copy tables are `*_3k`, and the op values are `legacy-3l` and `migration-3l`. The op values name the step's target version, as round 4's `legacy-3k` did on 3j-3k (sage-2415). No earlier migration set a precedent either way. - store.mjs runs 236's 3j to 3k step, then 3k to 3l. `SCHEMA_VERSION` is `3l`, and `SCHEMA_SHA256` is the merged schema.sql's, cd68512212ce8bf8f5631138b2e63a340fa7b940088430649bb7784af7df162a. schema.sql merged with no conflict: 264a's edits are above 236's two tables at the end. - The T7 fixture is `fixtures/schema-3k.sql`, 236's schema.sql at 8f51900b5 (SHA-256 aba92408...). 236's `fixtures/schema-3j.sql` is the same blob round 4 added, so that path no longer differs. - Pins: schema.test 22 tables and 86 triggers; README 22 tables and 87 triggers. Every `'3k'` version pin is `'3l'`, the comment-migration future step is 3l to 3m, and each copied engine also copies `3k-3l.sql`. - account-migration and grant-migration filter both 236's objects and the 3k-3l objects. gitea-migration.test (236's) now reads its store after both steps: version `3l`, `rows` skips `sessions` and `session_defaults`, `master` skips the 3k-3l objects, and its failing engine copies `3k-3l.sql`. Its 3j engine already stripped every step after 3i-3j. - sessions.test's engine is a 3k engine: 236's schema and steps through 3j-3k, no 3k-3l. - The restore guard lists merged with no conflict: 236 adds `gitea_users` and `gitea_grants` to T46's `untimed`, and 264a adds `sessions` and `session_defaults` to COUNTED and LISTED. Dry run (gizmo-264-out/dry-3l.py, Python sqlite3 in memory, no repo node): 3k-3l.sql on 236's schema kept every claim with generations 1, 1, 1, 2, 2, kept seq 40 with 41 next, and the migrated `sqlite_master` matched a fresh schema.sql, 121 rows each. Against round 4's run from 86f95bb27 (dry-3k-r4.py), the output differs only in the op values and the 119 rows that 236's two tables make 121. ### Round 5 on landed 236 (47a35bf43) Row 236 landed at bd5f11ded and refactor was 47a35bf43 (sage-2579). The prep commits moved from 8f51900b5 to 47a35bf43 by cherry-pick. schema.sql, the migrations, store.mjs and the fixtures are the same as the prep tip 4b369ddba, so the SHA-256 and the dry run above still hold. - Two conflicts, both with row 194. In broker.mjs `TESTER_VERBS` keeps 194's `instance.launch`, `instance.end` and `instance.request` and then 264a's `session.list` and `session.create`, and the private fields keep 194's block before 264a's claim comment. tester-writes.test pins the same list in the same order. - Row 205's message-filter.test (new since 8f51900b5) makes threads and `message_threads` rows by hand. Its threads now get the default session, as the other hand-made threads do, and its `message_threads` rows name that session. That makes 19 touched test files, not 18. - A defect from round 1: edit-264a-6.py wrote `DEFAULT_SESSION` with bare `'` quotes inside single-quoted strings in threads.test, end-launch.test and chat.test. Those three files could not parse in any earlier round. The quotes are now escaped. The two cli tests used double quotes and were fine. Runs below records no run, so no reported result rests on those files. - Every broker insert into `thread_claims`, `thread_opens` and `message_threads` names a session, 194's new callers included. 194's lifecycle-verbs.test reads `thread_claims` by thread only. - Pins on this base: schema.test 22 tables and 86 triggers, README 22 tables and 87 triggers, COUNTED and LISTED carry `sessions` and `session_defaults`, T46's untimed list carries 236's `gitea_users` and `gitea_grants`. ### Correction (sage-2581) Rounds 1 to 4 never parsed bus threads.test, bus end-launch.test or webui chat.test. Each had the bare quotes in `DEFAULT_SESSION` described above, from edit-264a-6.py in round 1. No run claimed those files: this packet's Runs section was empty through round 4 and the r4 gate was voided before it started (sage-2412). Round 5 escapes the quotes. The round 5 gate is wider than the plan above (sage-2581, mos-1200: a rebase carries no result). Each of the 19 touched test files runs whole on its own, then the bus, cli and webui suites whole, then every `scripts/test-*.sh`, then the mutants, with every skip named. ### Round 6 on landed 274 (0165be266) Row 274 (FED R3) landed at 4c485c63b and refactor was 0165be266 (sage-2588). The round 5 commits moved from 47a35bf43 by cherry-pick. broker.mjs is the only path both rows change. - Four conflicts in broker.mjs, all from 274's user principal. `USER_VERBS` and `USER_LEVEL` follow `TESTER_VERBS`, which ends with the session verbs as before. In thread.open, thread.send and thread.messages each verb keeps 274's `!s.tester && !s.user` check and `#grantedThread`, and adds 264a's optional `session` key, `#threadSession` and the session on `thread_opens` and `#route`. thread.send keeps 274's return, where a user's message writes no human.input. - Users stay at the seven ruled verbs (sage-2576): `session.list` and `session.create` are not in `USER_VERBS`. user-principal.test:133 already checks every verb outside the list refuses a user, and that set now includes the two session verbs. - 274's B6 check (a run answers a user only in reply to that user's message in its thread) joins `message_threads` by thread, not by session. No code change: a user can't call `session.create`, and `#thread` refuses anyone but the owner, so a user's thread has only its default session and the two scopes are the same rows. A slice that lets a user add sessions (264e) must make it session-scoped and test it. - 274's other writes and tests make threads through the broker, so each gets its default session. Its tests read `thread_opens` and `message_threads` by thread, and those columns stay. - schema.sql, the migrations, store.mjs and the fixtures are unchanged from round 5, and so are the touched test files: 19. ### Round 7 on landed 277 (980cec88c) Row 277 (thread.claims, #1782) landed at 62f53b1e0, after row 256 (f12441d4f), and refactor was 980cec88c (sage-2608). The round 6 commits moved from 0165be266 by cherry-pick with no conflict, and the range-diff is `=` for all eleven. - 256 changes `packages/bus/src/runtime.mjs`, `packages/business/src/instance-settings.mjs` and `packages/webui/src/public/agents.js`. None is a manifest path and none of their changed lines names a thread, a session, `message_threads` or `thread_claims`. Three touched tests load these modules (bus chief-of-staff and tester-writes, cli thread-launch), so the whole-file runs cover 256's code. - 277's `thread.claims` took the latest `thread_claims` row per thread. With sessions that is the last claim of any session, so a second held session would hide the first (duckworth-0123 N3, sage-2591). It now joins `sessions` and takes each session's latest row, as `#threadHolders` does, ordered by thread then session. The result keeps 277's five keys: two rows of one thread differ by `holder_run`, and `mosaic agents` prints each as its own thread line. Adding the session id to the row is left to the slice that shows sessions to the owner. - thread-claims.test (277's) makes its threads by hand, so each now gets the default session the migration gives an old thread (C5, :47), and its T5 message names that session (:111). T6 holds two sessions of one thread, sees both rows, releases one and sees the other (C4). M4 swaps the query back to 277's per-thread latest row and must fail T6. - 274's thread-scoped user reply check binds row 272 (264e) in `docs/plans/DEFERRED.md`, written here (sage-2608): a slice that lets a user add sessions makes it session-scoped. - 277's other tests: message-filter.test's claim pin reads thread.claims for a thread made with its default session, and the cli format and tester tests use stub rows or no threads. - Touched test files: 20 (thread-claims.test joins). Mutants M1-M4. ### Correction: the r7 red (sage-2621, duckworth-0129) The r7 gate on Sage's CLEAR (sage-2619) stopped on its first file. account-migration.test failed 2 of 3: "3h copy migrates to 3i" at :87 and "a 3h→3i step that fails partway" at :136. Each compares a whole snapshot of the store after the steps to 3l, `sqlite_sequence` included, with the snapshot before them, and found two more rows: `session_defaults` and `sessions`, both at seq 0. The cause is mine. 3k-3l.sql:50-52 fills `sessions` and `session_defaults` with INSERT ... SELECT, and SQLite writes a `sqlite_sequence` row for an AUTOINCREMENT table on that insert even when it copies no row. My round 1 schema commit (67fded6af then, 31fcecf7b in r8) changed the file's version pin, `master` filter and engine copy, but no round ran the file before r7 (Correction above: no run was claimed). The same gap held in every migration test that dumps `sqlite_sequence`. What I read in r8, before any run: every touched test (and every bus, cli and webui test that calls `migrateSchema`) that compares a whole snapshot or whole table across a step to 3l. `git grep migrateSchema` and `git grep sqlite_sequence` over `packages/*/tests/*` and `scripts/*` gave the list. A Python sqlite3 dry run of the real steps on each fixture's shape (gizmo-264-out/dry-seq-r8.py, no repo node) gave the rows after the step. - account-migration :87 and :136: red as run. Fixed. :132 compares before the 3l step (the failing 3h→3i engine rolls back) and is unchanged. - gitea-migration :90 (3j to 3l) and grant-migration :95 (3i to 3l): `rows()` leaves out the new tables by name but keeps `sqlite_sequence`. Same two rows at seq 0. Fixed. gitea :113 and grant :117 compare after a rolled-back step and are unchanged. - usage-migration :67 (3g to 3l), name-migration :65 (3f to 3l), spec-migration :61 (3e to 3l): whole snapshots with `sqlite_sequence`. Their fixtures have one thread, so the two rows are at seq 1. Fixed. In usage-migration the step also moves the `thread_claims` and `thread_opens` rows after `instance_names`' (3k-3l.sql:84-86 deletes and writes them again), so its snapshot now reads `sqlite_sequence` by name, as account-migration's has since row 126 (ca15d73e9). - Read and not affected: thread-migration :110 and :144 compare `messages` and `deliveries` only; sessions.test T7 and the D9 refusal compare named 3l tables, 3l against 3l, and :177 lists table names; each engine-refusal test (account :156, gitea and grant's third test) compares 3l with 3l; comment-migration and chief-of-staff count rows and compare no snapshot; stack-release.test T46 lists table names from schema.sql, and its `--migrate` tests run stub copies at 3g to 3h. The fix, in all six files: the compare after the step leaves out the two new rows and asserts them exactly, names and seq, so an extra row of any other name still fails. account, gitea and grant leave out the rows `SESSIONS_3L` names (sage-2621); their stores have no thread rows, so nothing else in that list has a sequence row. usage, name and spec leave out only `sessions` and `session_defaults`, because their `thread_claims` and `thread_opens` rows must stay in the compare: the step carries those seqs. Nothing else in the files changes. ### Round 9 on landed 278 (385a7194c) Row 278 (thread claims in the Console) landed, and refactor moved from 6af85df4b to 385a7194c, fast-forward (sage-2657). r8's 15 commits moved by rebase with no conflict, and the range-diff is `=` for all fifteen. - 278 changes packages/webui (`src/cli.mjs`, `src/serve.mjs`, `src/public/bus.js`, README, and the tests `shell`, `thread-claims` and `thread-claims-browser`), BUILD-LOG.md and its own packet. None is a manifest path. - Webui reads by 264a's tests: stack-release.test reads `packages/webui/src/cli.mjs` at :718 (a `--tester-port` match, still present) and its copy tests at :2475-2573 replace or append the whole file, so 278's added line doesn't reach them. No 264a test pins a hash of a webui file; the 64-hex pins in the migration tests and sessions.test are fixture DDL. - 278's `thread.claims` reader in bus.js renders one table row per result row and checks only that `thread`, `holder_run`, `harness` and `at` are strings, so 264a's per-session rows (a thread with two held sessions lists twice, five keys kept) show as two rows. 278's three webui tests use fake bus objects and never reach the broker's query. ### Round 10 on landed 281 (e479521d3) Row 281 (a fire or delete revokes the instance's grants) landed, and refactor moved from 385a7194c to e479521d3, fast-forward (sage-2668). r9's 16 commits moved by rebase with no conflict, and the range-diff gives all 16 `=`. The r9 run never started (sage-2665), so no result carries over. - 281's paths over 385a7194c: packages/bus/src/broker.mjs, five tests (bus fire-revokes-grants, gitea-grant, grant-executor, lifecycle-revoke; cli lifecycle) and its own packet. The one shared path is broker.mjs, and the hunks don't touch: 264a's at :128, :455-:1081 and :3276-:5537, 281's at :396-:400 and :1273-:3831 (base lines). - 264a removes #routeThread, #threadFor and #threadHolder. No line 281 adds calls them, and every private name the merged broker calls is defined. No line 281 adds names thread_claims, thread_opens, sessions or session_defaults; its section 3a "claim" is the host-key claim. - 281's tests name no schema version, migration, sqlite_sequence, thread_claims or 64-hex pin. cli lifecycle.test's `sessions` are launcher.registry.sessions, which 264a doesn't change. - No 264a test reads a 281 path as text or pins its hash. The touched tests that import broker.mjs run against the merged file in this round's gate, which is the test of the pair. - 281's gate (its BUILD.md, "Whole packages") recorded the cli suite at 591 s, longer than the 535 s the table used. 3x is 1773 s, so the cli limit goes from 1650 s to 1800 s. Its other times (bus 23 s, each scripts/test-*.sh at most 36 s) are under the table's, and those limits stand. ### Correction: the r11 red, T2 (mos-1277, sage-2676) The r11 gate (75f049ed2, ffc49615, CLEAR sage-2674) went red in its first phase at sessions.test T2 "a released or revoked run's retired generation", :204: the events table had 10 rows before the three refused calls and 13 after. Every other table's count was equal. request()'s catch (broker.mjs:4242) sends each refusal through `#refused` (:4081-:4096), which writes one `action.refused` event with body `{code}`. r2's base dcd8d4dda has the same catch, so the assertion that every table's row count stays the same (round 2, item 1 above) could never pass. Sage reproduced it at 72fc5a330, ed6773f72 and 75f049ed2, and the three rows are `action.refused {"code":"not-holder"}`. Mos ruled it a test defect, not a product fault. Mos's reading of sage-2257: "writes nothing" means a retired generation's approval, delivery and read write no approval, delivery, read or claim state. The refusal trail stays. Commit 19 (test only) makes `refused()` say that, for both calls (released and revoked): - every table's row count stays equal except events; - each refused call adds exactly one event, kind `action.refused` with body `{"code":"not-holder"}`, checked after each call in call order (withdraw, receive, read), so events grows by three. Before the next gate, one survey pass runs the touched files the r10 and r11 runs never reached (sage-2676 step 3), with M1 against the fixed T2. ### Run limits (sage-2634, sage-2637, sage-2647, mos-1275) Every run in run-264 goes through `timeout -k 10 LIMIT`, so a hang stops the run instead of holding the slot. Each LIMIT is at least 3x the longest recorded run of that file, suite or script. The records are my own gate logs (`ℹ duration_ms` for node runs, the lap lines for scripts), the times in committed BUILD.md files, and row 278's committed receipts: `SHELL-LIMITS.json` (row 196's longest per script) and `run3/whole/results.json` in `agents/gander/work/queue-278`. A timeout with no failed test is a hang, not a pass or a red, and goes to Sage first (sage-2637). A timeout in the shell phase goes to Mos unplaced (mos-1275). | Run | Longest recorded | Where | LIMIT | |---|---|---|---| | stack-release.test alone | 575 s | the r12 survey, 2026-10-22 (row 234 r3 singles with mutants: 505 s; plain r3: 482 s); 3 x 575 = 1725 (mos-1275 item 3) | 1800 s | | each other touched file alone | 15 s (thread-launch.test); end-launch, gitea-migration, message-filter, sessions, thread-claims, thread-cli and chat have none | rows 234, 235, 210 logs | 300 s | | bus suite | 23 s | row 235 run 1b; row 278 run 3: 22.5 s | 900 s | | cli suite | 591 s | row 281's gate, 2026-10-22 (row 235 run 1: 535 s; Megavolt row 199: 472 s) | 1800 s | | webui suite | 57 s | row 278 run 3 (row 66 r2: about 60 s) | 600 s | | each scripts/test-*.sh | 42 s (test-foundation); task 36.8, queue 23.4, land-work 12.6, release 12.2, discord 8.8, conductor 8.1, config 3.2, extension-package 3.0, auth 1.9 | the longest of row 268's gate (2026-10-22 04:40Z), row 196's receipts and row 278 run 3 | 900 s | | mutant probes M1-M4 (sessions.test, thread-claims.test) | 1 s wall each (longest `ℹ duration_ms` 512, M3) | the r13p4 rerun, 2026-10-22 14:08:43Z to 14:08:45Z | 300 s | ## Runs - r7 a23afbf95 on 980cec88c, Sage's CLEAR sage-2619, 2026-10-22 11:29:47Z. HEAD and the manifest digest eb7c1ab3 printed and matched. RED on the first file, account-migration.test 1 pass 2 fail (Correction above); the run stopped there as ruled, with no retry. The sweep found 0 node processes in the worktree. Slot returned. - r10 54f65f9ab on e479521d3, Sage's CLEAR sage-2669, 2026-10-22 13:11Z. RED on the first file, account-migration.test :106: later3l missed the 3l trigger threads_id_not_session (3k-3l.sql:104). Mos ruled option A (mos-1276): commit 18 names it in account-, gitea- and grant-migration. On r11 all 8 *-migration files ran whole, 25/25, 0 skipped (2026-10-22 13:14:25Z). - r11 75f049ed2 on e479521d3, manifest ffc49615, Sage's CLEAR sage-2674, 2026-10-22 13:16:37Z. Phase 1 was green for account-migration 3/3, chief-of-staff 42/42, comment-migration 5/5, end-launch 7/7, gitea-migration 5/5, grant-migration 6/6, message-filter 6/6, name-migration 1/1 and schema 1/1. Then RED at sessions.test T2 :204 (Correction: the r11 red, above), and the run stopped. 0 node processes left. Mos ruled a test defect (mos-1277): commit 19. - r12 93f4af12b on e479521d3, manifest 16603087, survey pass (sage-2676 step 3), Sage's CLEAR sage-2678, 2026-10-22 13:24:09Z to 13:34:07Z, 598 s. Each file whole under `timeout -k 10`, with no stop on red and no edits, all 0 skipped and 0 todo: sessions 7/7, spec-migration 1/1, tester-writes 8/8, thread-claims 6/6, thread-migration 3/3, threads 16/17 RED, usage-migration 1/1, thread-cli 11/11, thread-launch 11/11, chat 23/23, stack-release 218/218 in 575 s. M1 against the fixed T2: rc=1, killed on an assertion, file restored, sessions 6/7 with T2 sage-2257 the only failure ("Missing expected exception: approval" at :204). Log gizmo-264-out/r12-survey/probes/M1.log. Mos accepted M1 killed by T2 alone at r12 (mos-1278 item 4). The threads.test red: :330 expected `/only to its thread's holder/` (:341, from row 121, 2c16d6b93) and got the 3l trigger's "only to its session's holder". Mos ruled option A (mos-1278): delivery_same_thread in the base (schema.sql:300-306 at e479521d3) and delivery_same_session (3k-3l.sql:147-153) share the WHEN, the ops and the holder check, and differ only in taking the latest claim per session and in the text, so :330 keeps its strength. Commit 20 changes :341 to `/only to its session's holder/` and nothing else in that file. - r13 gate, 2cc34aef8 on e479521d3, manifest 65e04d27, Sage's CLEAR sage-2684. Start 2026-10-22 13:39:53Z, busy count 0, HEAD and digest printed and matched, blobs checked with `sha256sum -c`. Every count below has 0 fail, 0 cancelled and 0 todo. - Phase 1, the 20 touched files whole, one at a time, all 0 skipped, wall 600 s: bus account-migration 3/3, chief-of-staff 42/42, comment-migration 5/5, end-launch 7/7, gitea-migration 5/5, grant-migration 6/6, message-filter 6/6, name-migration 1/1, schema 1/1, sessions 7/7, spec-migration 1/1, tester-writes 8/8, thread-claims 6/6, thread-migration 3/3, threads 17/17, usage-migration 1/1; cli stack-release 218/218, thread-cli 11/11, thread-launch 11/11; webui chat 23/23. - Phase 2, suites whole: bus 827/827 (23 s), cli 837/837 (593 s), webui 345 pass of 346 with 1 skipped (55 s). The skip is the esbuild 0.28.1 editor rebuild (Duckworth named it in duckworth-0146; DEFERRED row 264a N2). - Phase 3, the 10 scripts/test-*.sh, all rc=0: auth 15, conductor 17, config 31, discord 81, extension-package 18, foundation 44 (36 s), land-work 86, queue 156 node tests and 28 suite checks, release 14, task 91. The worktree was clean after. - Phase 4: M1 killed by T2 at sessions.test:204 (6/7). M2 then failed with no assertion: T1 (:89) and T7 (:422) threw `storage-refused` from #refused (broker.mjs:4082) through request()'s catch (:4242), because the delivery_same_session trigger refused #delivery's INSERT. The run stopped there at 14:02:47Z, total wall 1374 s, and M3 and M4 did not run. 0 node processes left. Mos ruled option B (mos-1279): phases 1-3 carry and phase 4 reruns with M2's four edits. - Logs: gizmo-264-out/r13-run.out, with the per-file, suite and probe logs under gizmo-264-out/r13/single, r13/suite and r13/probes. - r13p4, phase 4 rerun under mos-1279 at HEAD 84f53540d (2cc34aef8 plus commit 21, the M2 note in this file), Sage's CLEAR sage-2688 with the fourth edit confirmed in sage-2690. Start 2026-10-22 14:08:43Z, busy count 0. `git diff --name-only 2cc34aef8..HEAD` was this file only, and the non-BUILD.md blobs matched manifest 65e04d27. Kit gizmo-264-out/probes-264-r13p4.py (`--check ok`), each probe under `timeout -k 10 300`, each file restored and its sha checked. End 14:08:45Z, failures 0, 0 node processes left. - M1, the #agent generation fence set to `false`: killed, sessions 6/7, T2 sage-2257 at sessions.test:204 (`assert.throws(fn, { code: 'not-holder' }, label)`). - M2, four edits: the `mt.session=?` clause and its argument dropped from message.receive (broker.mjs:5251), delivery_same_session dropped from 3k-3l.sql and from schema.sql, and store.mjs:16 SCHEMA_SHA256 set to the sha256 of the mutated schema.sql. Killed, sessions 4/7, by T1 at sessions.test:89 (a1's receive got the second session's message). T7 also failed on its receive assertion at :422. T2 failed at :168, the trigger's own pin, which doesn't count as a kill under mos-1279. The kill rule: only an assertion in a test that exercises receive counts, never a thrown error. - M3, generation counted over every claim row in 3k-3l.sql: killed, sessions 6/7, T7 at sessions.test:390 (the thread_claims generations). - M4, thread.claims back to the latest row per thread: killed, thread-claims 5/6, T6 at thread-claims.test:133 (two held sessions list as two rows). - Logs: gizmo-264-out/r13p4-run.out and gizmo-264-out/r13p4/probes/M1.log to M4.log.