# Row 206 build notes (Launchpad): 193b2, thread shares (#1704) Brief: docs/plans/2026-10-20_human-users-build.md, "Brief: 193b2, thread shares (schema 3l)" (blob de3a6310b). Design: agents/morgana/work/queue-193/DESIGN.md at e2d161e2e (sha256 dd7fcb41…), sections 4.3, 5.2-5.6 and 8. Reviewer: Duckworth. Assignment: sage-2789. ## Base and chain - Started on aca16404c (sage-2789) on branch launchpad/row206; 631a256ba pushed there (sage-2854). - Round 2: rebased onto Morgana's 275 r3, 957a9bfe0 (morgana-0143, which carries duckworth-0166 B1), on the new branch launchpad/row206-r275. No force push of the old branch. - Round 3 (C3): 253 landed at 48e450ce8. Morgana rebased 275 onto it as morgana/row275-48e4, tip 3410df7c3 (morgana-0144, range-diff `=` for all three). 206 is rebased onto 3410df7c3 as launchpad/row206-48e4. Range-diff against the round 2 commits is `=` for all five, and the 22 manifest blobs equal round 2's (e4dba2a84). C3 sweep: outside agents/ and docs/, 253 changed BUILD-LOG.md, packages/cli/tests/{case-sudo-fixture.mjs, case1-accept.test.mjs, case2-unit.test.mjs}, packages/webui/README.md and scripts/{case1-accept.sh, case2-unit.sh}. None is a 275 or 206 path, and no bus test, stack-release.test or stack-release.sh reads them. - Round 4 (slot rebase, sage-2959): 275 landed, then 269 (efcc9ea8d) and 282 (647424442), and refactor is 17f415792. 206 is rebased onto it as launchpad/row206-17f4. 275's three commits drop out as already applied, and the seven 206 commits apply clean. Sweep 3410df7c3..17f415792 over packages/ and scripts/: broker.mjs (269: the launcher's `#threadRows` reads one row per session, and `idle`; no share, read or grant code), sessions.test.mjs (269: `refusedOnce` checks actor_run; 206's hunks are the three '3l'→'3m' pins at T7 and engine3k, which still apply), end-launch.test.mjs, cli launcher and session-launch tests, seat session and sandbox, and tasks requirements. None reads a share table or the schema version. schema.sql, store.mjs, the migration tests, schema.test, stack-release.sh and stack-release.test are unchanged since 48e450ce8, so SCHEMA_VERSION on refactor is still '3l', the label 3l→3m stands, the table and trigger pins and COUNTED/LISTED lists stand, and fixtures/schema-3l.sql still equals refactor's schema.sql byte for byte. engine3k's strip regex starts at 264a's step comment and ends at the list's close, so it cuts 206's step too, as before. - 275 gives `VIEWER_VERBS`, `userLevel(verb)` and verb-aware `#granted`/`#grantedThread`, and `#userGrants` keeps a live grant of any level, so a viewer binds. On the rebase: - USER_VERBS keeps 275's block and gains the three share verbs. They are not in VIEWER_VERBS, so `userLevel` asks collaborator and a viewer is refused each `access-refused` (design 4.3). - `#threadRead`'s owner path passes `thread.messages` to `#grantedThread`, so a viewer owner reads its own thread at the viewer level (duckworth-0169 C1). Share and unshare pass `verb`; shares passes `thread.shares`. - Two conflicts, both in broker.mjs: the USER_VERBS block and the `thread.messages` line (275's `#grantedThread(s, 'thread.messages', #thread(...))` gives way to `#threadRead`, which now holds that check). user-principal.test.mjs merged clean. - C2 sweep on 957a9bfe0: 275 touches agents/morgana/work/queue-275/BUILD.md, broker.mjs, user-principal.test.mjs and viewer.test.mjs. Overlap with 206 is broker.mjs and user-principal.test.mjs, both handled above. viewer.test.mjs has no schema-version or hash pins, and its "every verb off the user list" test reads USER_VERBS, so the share verbs drop out of it on their own. - The live `--migrate` (3l→3m on the running bus) is not part of this build. It is Mos's go under Q173, with a backup first and a STATE report after (mos-1310 item 4). ## What changed - Schema 3l→3m. `thread_shares` as design 5.2, its index and three append-only triggers, in schema.sql and in the new step `src/migrations/3l-3m.sql` with the same SQL text. SCHEMA_SHA256 d5ca9bb9268994b175ac79d38939be4bb7f2e80bc0d9ea273eedaae50c29b8a9, SCHEMA_VERSION 3m. `tests/fixtures/schema-3l.sql` is the 3l schema.sql (sha256 cd68512212ce…). - `thread_shares` joins `timedTables` (store.mjs), the broker's clock list, `COUNTED` in scripts/stack-release.sh and `LISTED` in stack-release.test (T46). It has `at`, so it goes in the counted list, not the untimed one. - broker.mjs: - `#readable(s, t)` holds rules 1-4 of design 5.4. `#shared` reads the latest row per thread and target. `#covered` reads the access document once per call and covers nothing on a read failure, as `#userGrants` does. - `#threadRead` admits the owner (with `#grantedThread`) or `#readable`, and serves only `thread.messages`. `#thread` keeps the owner check for every write verb. - `thread.share`, `thread.unshare`, `thread.shares` (design 5.3). `thread.list {shared:true}` adds `owner`; the plain list keeps its shape (5.5). - `replies` leaves out user thread messages except those in threads `#readable` admits. `trail` needed no change: it already filters through `#hiddenMessage`, which calls `#readable`. - USER_VERBS gains the three share verbs. `thread.shares` joins `#processingGate`'s read list. - packages/bus/README.md: the verb rows, a "Thread shares" section, and the schema line (v3m, the new sha, 23 tables, 90 triggers). schema.test counts 89 triggers because it drops one first. ## Choices and departures (for Sage and Duckworth) 1. Label 3l→3m, not 3i-3j (design) or 3l (brief title). 264a took 3k-3l, and labels go by landing order (sage-2789). 2. A share covers every session of the thread, and `session.list` stays owner-only (sage-2818 Q1, from Sage's Q7 ruling, agents/sage/work/queue-264/RULINGS.md:72-77). broker.mjs keeps its "no shares (Q7)" refusal: a sharee reads any session it can name but can't list them. A test holds the refusal (human `thread-not-owned`, reader `read-only`, user `access-refused`). **Follow-up**, bound to whichever of row 193 or the share wiring lands second: wire the sharee's `session.list`. The bar is T1 sessions line 7 and REQ-USER-4 (candidate PRD 1509c7cc5, docs/prd/mosaic-stack.md:1027-1052): read-only, and it needs current grants, the thread owner's included. 3. A reader session matches shares as the business human (sage-2818 Q2: accepted, for the human principal only). Design 4.3 says reader sessions "stay owner-only", and 5.5 says "messages in other users' unshared threads hidden" for human, reader and tester. The reader sees what the owner sees, shares to the owner included. Q2 also found a gap in the first tip (16b9c6df6): an agent session has no owner, but it matched a `*` share, so a shared user message showed in an agent's trail. `#readable` now returns false unless the session is the business human, a reader or a user. A test binds an assistant run to the shared thread and a chief run with no thread, and checks that neither (nor the tester) sees the message after a `*` share and a share to the owner. 4. Access is read per request (sage-2818 Q3). Design 5.6's "30 s timer" and `access.reload` have no path at this base: runtime.mjs:263 passes `access: () => loadAccess(file, context)`, read on every request. The tests cover the per-request read (removed, expired, out of scope and unreadable), which is the stricter case. 5. Rule 3 asks the owner's grant for any level, like rule 2 (design: "still has a live grant covering t"). A user owner lowered to viewer keeps the thread readable to its sharees. 6. An unshare checks no coverage, so an owner can end a share whose target lost its grant. 7. `thread.shares` for a user goes through `#grantedThread` at the collaborator level, like share and unshare, so a viewer is refused it. Design 4.3 lists shares as "refused" for viewers. ## Tests New: tests/thread-shares.test.mjs, 16 tests: the read flow and the write refusals, `*`, rule 2, rule 3 (with trail for human and reader), latest-row state, share-not-covered, target checks, lost owner coverage, tester and reader refusals (18 `action.refused` events, no rows), trail and replies, sessions, the sharee's `session.list` refusal (Q1), agents and the tester matching no share (Q2), the viewer column (C1: a viewer reads a share, its share verbs refuse, and a viewer target binds and reads), the 3l→3m migration and the 3l engine's refusal of a 3m store. Pins moved 3l→3m: account-, chief-of-staff, comment-, gitea-, grant-, name-, spec-, usage- and thread-migration, sessions, schema (23 tables, 89 triggers) and user-principal (USER_VERBS). The engine copies in account-, gitea-, grant- and comment-migration gain 3l-3m.sql; comment-migration's future step becomes 3m→3n. `later3l` in account-, gitea- and grant-migration also leaves out `thread_shares` objects, and the gitea and grant `rows()` lists leave out the table, since each compares a store migrated through 3m against an older one. ## Mutants (design section 8 numbers) Each runs on a copy of the candidate tree with one edit, under `timeout -k 10 300`. The named test must fail on an assertion; a thrown error or a refusal before the assertion doesn't count. - M2. In `#readable`, delete the rule 2 line (`if (s.user && !this.#covered(s.user, ...)) return false;`). Killed by "mutant 2: a share never widens a grant" (bob reads after his grant moves to p2) and by the `*` test (carol reads). - M3. In `#readable`, delete the rule 3 line. Killed by "mutant 3" (the human reads A after alice's grant is removed). - M4. In `#shared`, replace the latest-row lookup with any share row: `SELECT op FROM thread_shares WHERE thread=? AND target=? AND op='share' LIMIT 1`. Killed by "mutant 4" (bob still reads after the unshare). - M7. In `thread.send`, replace `this.#thread(s, a.thread)` with `this.#threadRead(s, a.thread)`. Killed by the first test (bob's send to A must refuse `thread-not-owned`). - M8. In `thread.share`, delete the `share-not-covered` line. Killed by "mutant 8" (the share to user:carol must refuse). - M9. In `thread.share`/`thread.unshare`, replace `this.#thread(s, a.thread)` with `this.#threadRow(s, a.thread)`. Killed by the first test (bob's share of A to `*` must refuse). - Q2 probe (not a design mutant). In `#readable`, delete the human-principal line (`if (!s.human && !s.reader && !s.user) return false;`). Killed by "only a human principal matches a share" (the agent's trail shows the message after the `*` share). ## Run limits From 264's table (agents/gizmoduck/work/queue-264/BUILD.md, Run limits), each at least 3x the longest recorded run. A timeout with no failed test goes to Sage first; a timeout in the shell phase goes to Mos unplaced (mos-1275). | Run | LIMIT | |---|---| | stack-release.test alone | 1800 s | | each other touched file alone | 300 s | | bus suite | 900 s | | cli suite | 1800 s | | each scripts/test-*.sh | 900 s | | each mutant probe | 300 s | ## Review - Manifest aca16404c..631a256ba: APPROVE, no blockers (Duckworth, duckworth-0169, 16:57Z, note agents/duckworth/work/row206/REVIEW-manifest-631a256ba.md on duckworth/reviews 84458606d). Static only. Departures 1-7 accepted. Departure 1: whichever of 206 and row 271 lands second relabels its step. Departure 4: the 30 s timer and `access.reload` case binds the row that adds `access.reload` (193c). - Conditions for the 275 rebase round: - C1, design 4.3's viewer column: share, unshare and shares each refuse a viewer; a viewer reads a thread shared with it; `#threadRead`'s owner path passes `thread.messages` to 275's verb-aware `#grantedThread`. - C2: rerun the drift and pin sweeps on the rebased base (253 and 275 touch broker.mjs; 275 also touches the user-principal USER_VERBS pin). - Notes, no change asked: N1 `#readableThreads` binds a reader's owner as NULL (harmless); N2 replies reads the access file once per shared thread; N3 a share to a user absent from `users` is stopped only by coverage; N4 the gate evidence goes in "Runs" below before in-review. - Round 2 on d4fbce501 (rebased on 275 r3 957a9bfe0): C1 and C2 met, round 1 APPROVE carries (Duckworth, duckworth-0175, 17:32Z, note agents/duckworth/work/row206/R2-C1C2-d4fbce501.md on duckworth/reviews cd0fb90e2). 4ae94c5c9 is patch-identical to 631a256ba; manifest 22 paths. - C3, for the post-253 rebase: rerun the drift and pin sweep against 253's tip, and push the branch so the manifest is read from the pushed tip. N4 still applies. - Round 3 on 670658dd1 (rebased on 275 at 3410df7c3, on 253's 48e450ce8): C3 met, approve carries (Duckworth, duckworth-0179, note agents/duckworth/work/row206/R3-C3-670658dd1.md, 01f29379b). Pushed as launchpad/row206-48e4 at 670658dd1 in sage-2886's gap, after Morgana's morgana/row275-48e4; Duckworth's ls-remote matched the tip (duckworth-0180). ## Runs Slot from sage-2959 (CLEAR 19:13Z 10-22). Claimed rev 3317. Everything ran on launchpad/row206-17f4 on refactor 17f415792, after `npm ci --ignore-scripts` from the lockfile (oauth4webapi 3.8.8, no symlinked node_modules). Each run had TMPDIR from `mktemp -d /mnt/storage/scratch/tmp/gXXXXXX` and `timeout -k 10` at the limits above. Logs are in /mnt/storage/scratch/launchpad-r206/runs/. - First-run red, placed under mos-1272 (Sage accepted it, sage-2960). On 1a228f395, thread-shares.test.mjs "the owner's trail and replies..." failed with ERR_SQLITE_ERROR "a threaded message's session is in its thread". The test inserted the reply's message_threads row with session = the thread id, but a fresh thread's default session has its own id (264a; only a migrated thread's reuses the thread's). The fix (9538e0fc8) reads the session from session_defaults. It changes one fixture line in a new test, with no product change and no assertion changed. - Each touched file, whole and alone. The first 13 ran on 1a228f395 (the code under test is the same at 9538e0fc8); thread-shares reran on the fix. | File | Pass/fail | |---|---| | thread-shares | 16/0 on 9538e0fc8 (15/1 before the fix) | | schema | 1/0 | | sessions | 7/0 | | user-principal | 22/0 | | account-migration | 3/0 | | chief-of-staff | 42/0 | | comment-migration | 5/0 | | gitea-migration | 5/0 | | grant-migration | 6/0 | | name-migration | 1/0 | | spec-migration | 1/0 | | thread-migration | 3/0 | | usage-migration | 1/0 | | cli stack-release.test | 218/0, 580 s | - Mutants on 9538e0fc8 (19:28Z). Each is one exact edit on a copy of the tree (the edit must match once), running thread-shares.test.mjs. All are killed on ERR_ASSERTION in the named test: | Mutant | Result | Failing tests | Assertion message | |---|---|---|---| | M2 | 14/2 | "mutant 2" and the `*` test | | | M3 | 15/1 | "mutant 3" | "removed" | | M4 | 14/2 | "mutant 4" and the trail test | "human" | | M7 | 15/1 | the first test | "thread.send" | | M8 | 15/1 | "mutant 8" | "user:carol" | | M9 | 14/2 | the first test and "a share target is..." | "thread.share" | | Q2 probe | 15/1 | "only a human principal" | "agent after" | - Suites on 9538e0fc8, 19:28:23Z to 19:40:25Z, all rc=0: | Suite | Pass/fail | |---|---| | bus | 850/0 | | cli | 869/0 | | test-auth | 15/0 | | test-conductor | 17/0 | | test-config | 31/0 | | test-discord | 81/0 | | test-extension-package | 18/0 | | test-foundation | 44/0 | | test-land-work | 86/0 | | test-queue | 156/0 | | test-release | 14/0 | | test-task | 91/0 | The skip lines are by design. test-queue skips "verify and render --check" because the worktree isn't the canonical root. test-task skips the "live provider cases" twice (row 244: no provider call in the gate). No Docker group skipped. - Result: GREEN.