Compare commits

...
Author SHA1 Message Date
jason.woltje d5428c1a87 row 203 FED R2: cases for missing self, peer id and label (webby-0191 B3)
The pattern test coerces an absent value to the string 'undefined', which
matches, so the typeof halves are the only refusal for a missing value. Adds
a case for each, and M15-M17 in BUILD.md; keeps the config-console-key check
name on purpose. Not run yet.
2026-10-22 15:40:56 -05:00
jason.woltje 8d34a9e948 row 203 FED R2: stack-release probe reads federation; a refusal case per federation check
stack-release config_checks refuses a target whose loader refuses a key the
config holds; it looked only for console, so a federation-only config passed
to a pre-203 copy. The probe now reads console or federation. The fixture's
B loader replace follows the new rejectUnknownKeys list, which the federation
key changed.

Adds a test for each federation refusal in test-config.sh (six uncovered,
plus trailing slash and duplicate peer id), and one stack-release test for
the target-loader refusal. Mutants M7-M14 in BUILD.md. Not run yet.
2026-10-22 15:38:15 -05:00
jason.woltje e704a18aec row 203 FED R2: federation key in the system config (#1701)
The loader validates an optional federation block: self and peers[].id
labels, peers[].origin as an https origin with no path, query or userinfo,
peers[].label non-empty and at most 80 characters, peer ids unique and
distinct from self. Unknown sub-keys refuse. Labels and URLs only; the key
grants no trust. Absent key: config loads with no federation output.

Tests in scripts/test-config.sh cover each refusal and the no-key case.
Notes and mutants M1-M6 in agents/quackerjack/work/queue-203/BUILD.md.
Not run yet: the gate runs on CLEAR.
2026-10-22 15:31:35 -05:00
jason.woltje f2735bb6a1 queue: revs 3317-3324. Row 206: Launchpad claim and in-review (3317-3319), Sage review request (3320-3321), Duckworth approve record (3322), Sage landing note and done (3323-3324). 2026-10-22 15:14:46 -05:00
jason.woltje 0cbe2e9b35 row 206 (#1704): BUILD.md Runs, gate GREEN on 9538e0fc8 (bus 850, cli 869, shell suites, M2-M9 and Q2 killed) 2026-10-22 14:40:53 -05:00
jason.woltje 9538e0fc86 row 206 (#1704): thread-shares trail test threads the reply into A's default session, not the thread id (first-run red, mos-1272) 2026-10-22 14:19:00 -05:00
jason.woltje 1a228f395f row 206 r4 (#1704): BUILD.md records the slot rebase onto 17f415792 (269, 282 landed) and the drift sweep 2026-10-22 14:16:21 -05:00
jason.woltje 8295622600 row 206 (#1704): BUILD.md records duckworth-0179/0180, C3 met on the pushed tip 670658dd1 2026-10-22 14:15:48 -05:00
jason.woltje ac0e4dc9bc row 206 r3 (#1704): BUILD.md records the C3 rebase onto 275 at 3410df7c3 on 253's 48e450ce8, and the sweep 2026-10-22 14:15:47 -05:00
jason.woltje c49fdb2692 row 206 (#1704): BUILD.md records duckworth-0175 (round 2 meets C1/C2, APPROVE carries) and C3 2026-10-22 14:15:47 -05:00
jason.woltje cfb86f1bd7 row 206 r2 (#1704): rebased on 275 r3 957a9bfe0; share verbs need collaborator, viewer reads a share (duckworth-0169 C1, C2) 2026-10-22 14:15:47 -05:00
jason.woltje 178eefd5c4 row 206 (#1704): BUILD.md records Duckworth's manifest APPROVE (duckworth-0169), C1/C2 for the 275 round, N1-N4 2026-10-22 14:15:47 -05:00
jason.woltje 8a29f94771 row 206 (193b2, #1704): sage-2818 rulings, only a human principal matches a share
#readable returns false for an agent session, so a `*` share no longer shows a user's message
in an agent's trail. New tests: a sharee's session.list refuses (Q1), and an agent bound to the
shared thread, a chief run and the tester match no share (Q2). BUILD.md records the Q1
follow-up, Q2-Q4 and the live --migrate as Mos's go under Q173. README names the agent rule.
2026-10-22 14:15:47 -05:00
jason.woltje d78a9cc83c row 206 (193b2, #1704): thread shares, schema 3l→3m
thread_shares (row 193 design 5.2) with its index and append-only triggers; the
3l-3m step only adds them. thread.share, thread.unshare and thread.shares for a
thread's owner; #readable holds design 5.4's four rules, #threadRead serves
thread.messages only, and thread.list {shared:true} adds owner. replies and
trail show shared user threads to the owner and readers. COUNTED, LISTED,
timedTables and the clock list take the table. Tests: thread-shares.test.mjs
and the 3l→3m pins. Packet agents/launchpad/work/queue-206/BUILD.md. Not run
yet: node waits for Sage's CLEAR (sage-2789).
2026-10-22 14:15:47 -05:00
jason.woltje 17f4157920 queue: revs 3217-3316. Q226 gap (rows 216-229, 265-267 re-pins; 217 unblocked; 223 blocked; rows 282-290 added), rows 253, 275, 269 and 282 reviews, notes and done moves, row 291 (PRD 1.16, #1796) added, claims and in-review moves for 173, 207, 211, 214, 224, 239, 252, 258, 276 (sage, darkwing, della, dewey, gander, herb, honker, megavolt, scrooge, ludwig, gizmoduck, gosalyn, webby, duckworth, morgana); review requests posted as jarvis by Sage 2026-10-22 14:13:04 -05:00
jason.woltje 647424442f gosalyn: PRD 1.15 round 2 for Webby webby-0158 B1: requireCitation makes every reply carry a citation except the runner's four fixed notices, as the design keeps them passing, and the REQ-LIB-1 rewrap joins organization file to its sentence (N1) 2026-10-22 14:06:25 -05:00
jason.woltje 71fed6281c gosalyn: PRD 1.15 rewrites REQ-LIB-6 to Q223 and Q226 (mos-1320 item 8): agents never write story and write .md files only in supporting folders a human marks, each as a commit by their own Gitea account with a notice she can revert, chat is free text, any citation is checked, and requireCitation is a human-only setting off by default. REQ-LIB-1 and REQ-LIB-2 gain supporting folders and the canon-cite rule. The 1.12 note's residual becomes the supporting-doc residual, and the 1.15 note records the old texts and lists REQ-LIB-3, 4, 5 and 7 as unchanged. requirements.json version 1.15 with three titles changed, and the test's version pin. The id count stays 85 2026-10-22 14:06:25 -05:00
jason.woltje 86a665bdf0 docs: PRD 1.16 brief, library.write routes under REQ-LIB-6 (#1796, mos-1328) 2026-10-22 13:51:55 -05:00
jason.woltje efcc9ea8dd row 269 (264b, #1774): BUILD.md M11 kill line is the survivor log assertion at session-launch.test.mjs:309, not the outcome assertion (mutant run 17:49Z, duckworth-0181) 2026-10-22 13:37:34 -05:00
jason.woltje f39508c24f row 269 (264b, #1774): the M6 halt test halts after mayLaunch answers
A halt inside the op reaches the bus's own fence first (runtime.mjs:564-566,
processingWork at :352-356), so the launcher refused limit-check-failed
(launcher.mjs:389-391) before its recheck admission() at :397. First-run red of
a new test on 6d9ab3503 early signal, placed under mos-1272.
2026-10-22 13:37:34 -05:00
jason.woltje d20ef317fc row 269 (264b, #1774): BUILD.md names the sessioned-survivor residual in Q2 and records N1's mos-1292 allowance (duckworth-0159 N3, N4) 2026-10-22 13:37:34 -05:00
jason.woltje 6e50bd6304 row 269 (264b, #1774): BUILD.md Q2 notes that a survivor's released claim doesn't prove its run has exited, and the per-leftover 5 s wait (duckworth-0158 N1, N2) 2026-10-22 13:37:34 -05:00
jason.woltje 6c80b12904 row 269 (264b, #1774): BUILD.md answers sage-2750 on the recover() change; the survivor log names its pids
The section "recover() change, for a design read" covers three things, with
file:line against aca16404c. Every leftover is still bound and ended as
host-lost, and a survivor only refuses legacy-run-live while it lives. The
5 s wait applies only in the bwrap kill branch. No old test's expected value
changes. The fallback is to revert to the base recover() and leave the
refusal untested. The survivor log line now names the outer and runner pids
so an operator can clear it, and the new test's log assertion matches.
2026-10-22 13:37:34 -05:00
jason.woltje 42fd3edd45 row 269 (264b, #1774): legacy-run-live gets its test (duckworth-0156)
recover() kept a survivor only when a bwrap outer pid outlived SIGKILL, so
the Q2 refusal (BUILD.md:177-179, t1-sessions.md:109) had no test. A leftover
whose runner still ran after its outer pid was gone was ended and dropped.
Now recover() waits on the outer pid and the runner, keeps any leftover where
either still runs, and logs that it still runs. check() reads only the
survivors that still run.

session-launch.test.mjs adds a test where a sessionless leftover's runner is
a sleep child and its outer pid is gone. It asserts the legacy-run-live
refusal with no launch and no session workspace, then one launch once the
child exits. BUILD.md adds mutants M10-M12 and replaces the "no test" note.
2026-10-22 13:37:34 -05:00
jason.woltje dc5836c7df row 269 (264b, #1774): refusedOnce in sessions.test.mjs pins actor_run, closing Duckworth N1 (DEFERRED.md:474-480)
Allowed by Mos in mos-1292 (copy mos-1294) on mos-1285's terms. The SELECT
reads actor_run, the expected row gets the run-name literal, and the three
callers pass `${name}-run`. Nothing else in the test changes. The old
expected row was:

  [['action.refused', { code: 'not-holder' }]]

DEFERRED.md gets a closing line on the entry naming row 269.
2026-10-22 13:37:34 -05:00
jason.woltje 2133c5a0c6 row 269 (264b, #1774): per-session store, workspace and single-writer lock
threadsState gives one row per session with `session` and `legacy`; threadIdle
idles a run against its bound session. Each session gets its own store
(threads/<b>/<t>/sessions/<s>/, or the thread's old store for a legacy-3l
session) and its own workspace (session-workspaces/<b>/<i>/<s>/), held by an
O_EXCL <ws>.lock from before the clones to the run's proven exit. A legacy
session's workspace binds in-sandbox at the old instance path. sessions.json
is v2 and reads v1. New tests: cli session-launch.test.mjs, seat
session-workspace.test.mjs. Not yet run: tests wait for Sage's CLEAR.

Two old assertions change as exact updates:
- mos-1285: end-launch.test.mjs threadsState deepEqual. The old row shape was
  { thread: 'th-1', instance: 'coder', pending: 0, unread: 0, lastMessage: null,
  lastOpen: '2026-10-15T00:00:01.000Z', lastClaim: null, holder: null }; the
  row keeps every key and value and adds session: 'th-1', legacy: true.
- mos-1289: thread-launch.test.mjs:182, old line
  `const store = join(f.dataRoot, "threads", "acme", A);`
  now reads A's default session id from threadsState's `session` key; :183-185
  unchanged; one added assert that threads/acme/<A>/session is absent.
2026-10-22 13:37:34 -05:00
jason.woltje 194dec83d6 row 269 (264b, #1774): BUILD.md lists thread-launch.test.mjs:182 as a second old assertion that changes. A thread.create thread's default session isn't legacy-3l, so its store takes D2's layout (t1-sessions.md:100, :294). Proposed as an exact path update, for Mos through Sage 2026-10-22 13:37:34 -05:00
jason.woltje 84b32e3903 row 269 (264b, #1774): BUILD.md drops the planned bindLaunch session-running refusal. Two old tests bind a second unended run to one session and expect the claim refusal (threads.test.mjs:88-89, sessions.test.mjs:119-120), so the broker half of the fence is the existing per-session claim, thread-already-held (broker.mjs:4815) 2026-10-22 13:37:34 -05:00
jason.woltje 9d241fdcc2 row 269 (264b, #1774): design notes for Duckworth's early look (duckworth-0150). C1 adds the Console and R-guard sessions.json readers, the 264a refusals a v2 file causes, the /proc live check and the Console restart; C2 pins M6 to a generation change after check() with all eight start() admission calls named; C3 gives the realpath compare its own code and a symlinked workspaces root as M4's input; C4 lists the old threadsState assertions, end-launch.test:193 the only change, under mos-1285 2026-10-22 13:37:34 -05:00
jason.woltje 76a6524cfe row 269 (264b, #1774): design notes for sage-2717. Rollback names the four steps past the 264a exit-3 refusal and the re-pin plan section that carries them; new M8 binds a non-default session at the legacy path, old M8 becomes M9; Duckworth's legacy-bind check 2026-10-22 13:37:34 -05:00
jason.woltje 693f7e745e row 269 (264b, #1774): design notes. D9 audit of every sessions.json reader at 84f53540d and of threadsState's consumers; sessions.json goes to v2 because recover() rebinds without session; pi 0.85.1 resumes by cwd, so the legacy session keeps its in-sandbox path; mutants M1-M8 and run limits (sage-2707) 2026-10-22 13:37:34 -05:00
jason.woltje 94c33a43c0 docs: row 275 landing line; Researcher session line 2026-10-22 13:13:47 -05:00
jason.woltje a870fd85bb docs: seat log lines for rows 281, 224, 219, 214, 211 and 258, the row 253 landing line, Sage's SESSIONS line, and DEFERRED for the 224 residual and the 206 sharee list 2026-10-22 13:10:27 -05:00
jason.woltje 5b1f1dc885 docs: DEFERRED gains row 253 N1 (Vikunja 532, mos-1297), row 198 N2 (/api/seen 400, sage-2867) and the stale row 203 text in access.mjs (Quackerjack) 2026-10-22 13:10:27 -05:00
jason.woltje 8c638b6f66 agents: register Della, Scrooge, Huey, Louie and Ludwig as seats so the queue can name them as row owners (Q220, mos-1301) 2026-10-22 13:10:27 -05:00
jason.woltje a700992cd4 docs: 193b2 brief, the live --migrate window needs Mos's go under Q173, not Jason (mos-1310 item 4) 2026-10-22 13:10:27 -05:00
jason.woltje d30f35e292 docs: SESSIONS line for Gizmoduck, rows 264a and 268 after-landing docs and the row 269 plan 2026-10-22 13:10:27 -05:00
jason.woltje e5b7d1d083 row 268 (#1773): BUILD.md Runs records the r2 gate on ecd9b35eb and the r3 and r4 carry reruns with every count and skip; doc only (sage-2455) 2026-10-22 13:10:27 -05:00
jason.woltje 836b2f25ca docs: preserve email hard-wait approval and tune parser limits 2026-10-22 13:10:27 -05:00
jason.woltje 73516da853 docs: apply Q217 and Q222 to email source settings 2026-10-22 13:10:27 -05:00
jason.woltje 95e3f71661 docs: address Q195 editor design review findings 2026-10-22 13:10:26 -05:00
jason.woltje cde0cd0832 docs: Q195 writing editor design and Q196 options 2026-10-22 13:10:26 -05:00
jason.woltje 100f018af3 gosalyn: row 229 design round 2 for Webby webby-0148: the settings.json allowlist is three keys a fresh /login writes, written in Mosaic code with httpProxy and the path and command keys refused by name and a sha256 bump test on pi's setter names, models-store.json and keybindings.json now refuse, auth.json.lock is the only allowed dir, pi-login runs --offline and --no-session in an empty temporary cwd with a 229b allowlist test, the contents check runs before preflight, and creativeHumanOnly covers the fallback (R1, R2, N1-N5) 2026-10-22 13:10:26 -05:00
jason.woltje 4a8215e3a1 gosalyn: row 229 design draft 1 after mos-1306: Chet's route has no fallback by default with a human-only fallback still possible through routes, and the dates note records that the live check gates every build row, not the design 2026-10-22 13:10:26 -05:00
jason.woltje 669e680368 gosalyn: row 229 design draft 1, a per-instance pi agent dir for Chet's Codex login: the host-made 0700 store per instance, a same-path rw bind with PI_CODING_AGENT_DIR through the pi module env, a contents allowlist since pi reads models.json and settings.json there, a refusal for roles with bash, a no-refresh auth check preflight, no fallback for Chet, a fresh-grant mosaic pi-login, the live check that gates the build, and rows 229a and 229b (#428, #1730) 2026-10-22 13:10:26 -05:00
jason.woltje c06ccc9ba4 gosalyn: Case 2 C2-03b round 3 for Webby webby-0156 R4: the fetch fallback reads the append-only events table for any GRANT_EVIDENCE event with method gitea and agent the chief, never gitea_grants, whose row a revoke and a grant-lost delete. It names no project, since grant.applied and grant.revoked carry none, so a chief grant on any project ends the fallback and fails safe. The revoked and fired tests go through grants.revoke and instance.fire and assert the row is gone before the fetch 2026-10-22 13:10:26 -05:00
jason.woltje 136e5c1ca3 gosalyn: Case 2 Q226 row briefs with Webby's round webby-0151, Mos's mos-1320 and Sage's sage-2833 folded in. C2-06 rewritten and taking in C2-07: an unreadable business file resolves requireCitation to true (R2), a send over 64 KiB gets the one retry and then a fourth fixed text (R1). C2-03b falls back to slot library-gitea only while no chief grant record has ever existed and refuses library-fetch-no-grant after (R3), with library-human-missing for her save and revert. C2-02 after C2-05c, revert and save by her editor (N1). C2-21 cites mos-1320 item 2 and keeps the host-creative-role refusals, and records two Gitea facts that don't gate it (N4, item 5). C2-08b names what the chief's credential needs (N5). C2-05b restored to the brief row 224 built, which lands as built, and new row C2-05c carries the supporting scope and blob. A PRD row brief for REQ-LIB-6 (mos-1320 item 8). New briefs C2-01d, 05c, 08b, 03b, 21, 02, 11b and 12b 2026-10-22 13:10:26 -05:00
jason.woltje cf17ab387b gosalyn: C2-06 brief Q222 round 2 for Webby webby-0144: M11 compares against the on-disk human and replyLimits on document and written and refuses agent actor shapes, an unreadable business file checks at the defaults, keys read by Object.hasOwn and Number.isInteger, the verb refuses unknown keys, the new tests, the residual line, cites at aca16404c, and the hold moves to Q30 (mos-1305, design f164dcef8) 2026-10-22 13:10:26 -05:00
jason.woltje 09948da187 gosalyn: C2-06 brief makes every M5 limit a replyLimits setting with Q219's numbers as defaults, the human's library.limits.set and mosaic limits replies, the M11 writer check, read at check time with per-key fallback to the default, the setup sheet's settings line, the settings and M11 tests, After C2-04, and the build held for Q29 (Q222, mos-1302, design df29550c9) 2026-10-22 13:10:26 -05:00
jason.woltje 35615b01b7 gosalyn: C2-06 brief cites the setup sheet's reply limits paragraph at :115-118 (webby-0140 N4) 2026-10-22 13:10:26 -05:00
jason.woltje 3f1f4e44fa gosalyn: C2-06 brief owns the setup sheet's reply limits paragraph, which says 3 ideas of up to 120 characters and drops the lower-limits offer, and the tests paragraph is rewrapped (webby-0139 N1, N2, Q219) 2026-10-22 13:10:26 -05:00
jason.woltje 65b8c8a9bd gosalyn: C2-06 brief takes Jason's idea caps, 3 items of 120 characters per reply, tested one past each boundary, and the idea-text residual line reads 360 characters closed in Q219 (mos-1299, design 2852348d2) 2026-10-22 13:10:26 -05:00
jason.woltje 271cef205c gosalyn: Case 2 design round 3 for Webby webby-0156 R4: the fetch fallback reads GRANT_EVIDENCE events with method gitea and agent the chief in the append-only events table, never gitea_grants, and names no project, so it survives a revoke, a grant-lost and a fire and fails safe. The ruling paragraph, 7.2 and section 10 steps 4 and 4b say so, cited at aca16404c 2026-10-22 13:10:26 -05:00
jason.woltje 3e5db651b7 gosalyn: Case 2 design ruling after r11, agents never write story (Q223, Q226, mos-1309), with Webby's round webby-0151 and Mos's mos-1320 folded in. Supporting prefix lists agents may write .md files under through library.doc.write and library.doc.delete, fenced by M12 supportingPath, plumbing commits pushed fast-forward only with a Mosaic-Agent trailer, a notice and a human revert authored by her editor on slot library-human. Chat is free text with optional citations, replyLimits keys requireCitation and citationsPerReply, and an unreadable business file resolves requireCitation to true (R2). A send over 64 KiB gets the one retry and then a fixed text (R1). The fetch falls back to slot library-gitea only while no chief grant record has ever existed, and Jason deletes the slot once status shows the chief's grant (R3). A missing editor or slot refuses before any git call (N1), runner and tools cites fixed at aca16404c (N2, N3), two record-only Gitea facts at C2-21 (N4, mos-1320 item 5), mos-1163 narrowed to gitea library projects (mos-1320 item 2), M3 at eight or ten (mos-1320 item 4). C2-05b is row 224 as built and C2-05c carries the supporting scope and blob (sage-2833). New rows C2-01d, C2-05c, C2-08b, C2-12b and C2-21 2026-10-22 13:10:26 -05:00
jason.woltje e1c9c09935 gosalyn: Case 2 Q222 round 2 for Webby webby-0144: the M11 fence compares against the on-disk human and refuses agent actor shapes, checks document and written as M9 does, a failed business file read checks at the defaults and never skips, the fallback reads keys by Object.hasOwn and Number.isInteger with booleans, arrays and objects named, the verb refuses unknown keys, the ceilings residual line, and cites at aca16404c (R1-R3, N1-N3, N5, N6) 2026-10-22 13:10:26 -05:00
jason.woltje aaaf45576a gosalyn: Case 2 design ruling after r11, reply limits are settings: every M5 limit is a replyLimits key in the business file with Q219's numbers as defaults, changed only by the human's library.limits.set and fenced in organizationWriter.write as M11, read at check time, with a missing or invalid value using its default. C2-06 carries it and waits on Q29 (Q222, mos-1302) 2026-10-22 13:10:26 -05:00
jason.woltje cf6abca9c0 gosalyn: Case 2 design ruling after r11, idea items capped at 3 of 120 characters: M5, the B5 note, the 5.1 residual (now 360 characters per reply) and 10 step 8 closed by Jason; the rest of M5 unchanged, C2-06 builds and tests the new boundaries (Q219, mos-1299, Vikunja 465, ludwig-0014 Q4) 2026-10-22 13:10:26 -05:00
57 changed files with 9779 additions and 619 deletions

No files matched your search

+20
View File
@@ -5322,3 +5322,23 @@ Gander: after Duckworth-0100 design approval with C1-C5, prepared the two host-s
2026-10-22 14:45 UTC | Gander row253 focused27 on finalaca16404c base | Fresh0604d91fc / manifest573c51cf:27 whole files604/0/0, no timeout/retry, case134/0 in99.281s, case243/0 in66.418s, stack-release includingT46221/0 in583.453s. N9 sandbox bind ran and passed, no focused skips. Seven source blobs unchanged; sweep0 run-created leftovers, pre-existing infrastructure receipts retained. Whole gate and M1-M40 await separate Sage CLEAR after Duckworth rebase check. No live action.
2026-10-22 14:46 UTC | Gander correction to the row253 focused27 entry immediately above | stack-release whole was218/0/0, not221/0. The27-file sum604/0/0 and duration583.453s are unchanged; raw files/results.json and SUMMARY.json give the exact per-file totals.
2026-10-21T16:04:23Z | Q195/Q196 writing editor design, #1752 / Vikunja #448 | Before: row 154 edits Markdown in a separate Docs view, with no selected-word thesaurus or document pins. After: design draft on gyro/editor-design compares three editor approaches, records WordNet source/license/size, folds the pane/pin model and specifies guarded context/proposal APIs, DesignCraft handoff and seven proposed briefs. Q196 changes the basis to a Word-like interface. Verification so far: editor/provenance checks 10 pass, 1 explicit rebuild skip (pinned esbuild absent); brief fields, unique headings and 11-line checklist checked. Product, queue and live state unchanged. WordCraft scratch evaluation is being incorporated before publication. Review and Jason choices remain open.
2026-10-21T16:22:36Z | Q195/Q196 design verification, #1752 / Vikunja #448 | Folded Fenton 7caf8008b and Louie 880ef19e0. Recommend bounded O3: Word-like DOM editor plus isolated headless DOCX export, with explicit feature cut and semantic loss checks. WordCraft web 44,179,239 bytes, identical twice; measured comment/footnote conversion limits recorded. Seven briefs and 12-line checklist validated. Fresh editor/provenance: 10 pass, 0 fail, 1 explicit rebuild skip (esbuild 0.28.2 absent). CI queue guard identity gyro/purpose push returned documented degraded-pass (exit 0), CANNOT_ASSERT credential-unresolvable, scratch audit gyro-q195-ci-queue-audit.jsonl; no fallback or CI-green claim. Documentation-only candidate for Mos/Fenton review, no source, queue or live changes.
2026-10-22T16:06:34Z | Q195/Q196 design review fixes, #1752 / Vikunja #448, mos-1300 / fenton-0004 | Before: draft 105811400 had owner-only thread wording, stale source positions, two mismatched seat/model assignments, an assumed library save boundary and 12 owner questions. After: grant-aware pin validation and stale/revoked access redaction; references refreshed against aca16404c, including public/bus.js and README offsets; Sage assigns model-only seats; unimplemented library.file.save/C2-11b brief blocks book editing; tabs explicitly cover 62-76rem; J7 stricter context choice recorded; pronouns removed; six owner questions. Static docs/source-anchor checks only, no builds, product suites, MVP gate slot or live action. Fenton re-check pending; O3 and both companion folds retained.
2026-10-22T16:09:56Z | Della mos-1303 / Vikunja 460 Q217/Q222 revision | Before: Q1/Q2 open and Q3-Q7 phrased as Jason questions. After: Jason-created InMotion/woltje.com ingress recorded; per-source mailboxScope defaults none with human-only CLI then Console writer and named default/change/agent-refusal tests. Q3-Q7 settings/defaults/owners documented; no new external decision required for default development, conditional provider/spend/exposure list retained. PRD/grant/data.delete/live gates preserved. Docs only, no product suite or live operation. Earlier uncommitted append-only PRD review session receipts are preserved in this docs commit. Ludwig reviews before Sage landing.
2026-10-22T16:12:26Z | Della ludwig-0035 / mos-1303 / Vikunja 460 focused corrections | Verified REQ-WAIT-2/3 at db1e7732a: Jason approval is required to route mail.access out of hard waits. Restored explicit Jason-approved PRD gate; no Q211 waiver inferred. Named tunable parser/attachment settings, defaults and schema/test review ownership of resource bounds; E04 checks changed values and bound refusals. Clarified Q8 extraction stays Jason choice. Docs-only checks, no product/live action; focused review before Sage handoff.
2026-10-22 13:06 UTC | Megavolt | Row 281 (#1786) build. Before: a fire or delete left host_grants/gitea_grants rows, the host line and the Gitea token live. After: step G in broker.mjs revokes them in a detached job per agent (the host line first, then Gitea local-first with an admin preflight); a refusal is grant.revoke.refused plus a human message; open grant decisions are in the set; a raise for a fired agent refuses instance-fired. Tests T1-T14 (bus fire-revokes-grants, lifecycle-revoke, grant-executor, gitea-grant; cli lifecycle). Gate on 7979fb41e: base 121/121 + 24/24, 29 anchors, M1 exit check rc1/17, M1-M22 22/22 killed, dewey-0840 files 76+14+23, bus 819, business 231, cli 837, ten shells rc 0 under the mos-1275 limits; skips: test-task row 244 live-provider pair, test-queue canonical-root check. Corrections: the first base run on c174f37ac went red on five first-run test defects (b3, test only); the first mutant run on 06ee5ed6b hung on M1 because T6c's held loop never ended (b4a fixed the teardown, and the runner now has timeout -k 10 180 and an exit check without --test-force-exit).
2026-10-22T16:36:30Z | Della row224 C2-05b #1725 preparation | Before: C2-05a queries exist at 4f3fbc25a but no broker lookup verbs or typed tools. Scope: broker/tools plus Sage-authorized runtime/bundle integration, tests and docs. Existing human-only library reads retained; five agent queries require a live holder and trusted configured source. Code/docs only under row253 gate; no Node/test/push/claim. Runtime ownership coordinated with Rocko219/Huey272.
2026-10-22T16:52:55Z | Rocko | Row219 prepared under sage2793/2807; explicit creative thread/default-session selection, narrow metadata route/picker U1–U5,22 static mutant sites; unrun while253 holds.
2026-10-22T17:42:16Z | Sage | Row 253 (#1758, Gander, Duckworth) LANDED 48e450ce8. Before: refactor at aca16404c; case-sudo calls had no supervised session check and the M21 survivor hid behind a fast-exit fixture (mos-1297). After: a fast-forward of 22 commits; manifest 591020a1 rechecked with 0 mismatches; run 5 whole gate green with M1-M40 killed, the M38 PID equals SID assertion added under mos-1298; Duckworth APPROVE rev 3252, note rev 3253, done rev 3254; #1758 comment 30701 and closed; P19 416 comment 719. N1 (fast child reported as exit 85) is DEFERRED as Vikunja 532.
2026-10-22T18:11:57Z | Sage | Row 275 (#1780, Morgana, Duckworth) LANDED 3410df7c3. Before: refactor at 48e450ce8; viewers had no verb list of their own and board reads were not refused to users. After: a fast-forward of 3 commits; manifest 39847b15 (4 paths) blobs match; gate 17:52:12Z-18:05:00Z green (whole files 7/22/6, bus 834, cli 861, harness 151, business 231, seat 167, 10 scripts with 3 named skips), M6 and M10 killed; Duckworth APPROVE rev 3260 (comment 30717); note and done by sage-2897; #1780 comment 30728 and closed. N1 (board refusal test as a loop over BOARD_VERBS) is non-blocking.
+5
View File
@@ -27,6 +27,11 @@ not fleet seats. Keep changes in `/mnt/storage/src/mosaic-stack`; do not modify
| Gizmoduck | Federation design (#414) and builder of FED R1 | T3 session (Claude, Opus 5.5, thread 9312a896) | none, T3 only |
| Webby | Reviewer: Case 2 designs, rows 192 and 194 | T3 session (Claude, Opus 5.5, thread 03086006), started by Mos (mos-0981) | none, T3 only |
| Duckworth | Reviewer: federation designs, row 193 | T3 session (Claude, Opus 5.5, thread ffceafec), started by Mos (mos-0977) | none, T3 only |
| Della | Builder, email plan (Vikunja 460) and row 224 (C2-05b) | T3 session (Codex, gpt-6.1-sol, thread 66735a7f), started by Mos | none, T3 only |
| Scrooge | Builder of row 252 and reviewer of row 272 | T3 session (Claude, Opus 5.5, thread 629b6cd3), started by Mos | none, T3 only |
| Huey | Builder, row 272 (T1 sessions) | T3 session (Codex, gpt-6.1-sol, thread cabd45ca), started by Mos | none, T3 only |
| Louie | Builder and research, editor evaluation and row 174 | T3 session (Codex, gpt-6-luna, thread bfd13d49), started by Mos | none, T3 only |
| Ludwig | Builder and reviewer, post-v1 email PRD and row 258 (E01) | T3 session (Claude, Opus 5.5, thread 8615b2a6), started by Mos | none, T3 only |
| Sage | Project lead: coordination, review, integration; earlier DYOR strategy records retained | T3 session (Claude Code); Pi launcher `zai/glm-5.3:high` retained | `agents/sage/launch.sh` |
Each script supports `--check` and `--fresh`. Normal launches resume the agent's
+8
View File
@@ -0,0 +1,8 @@
# Della
Builder seat, Codex gpt-6.1-sol, started by Mos for the email plan (Vikunja 460). Owns row 224 (C2-05b) under Q220.
No launcher in this directory. The seat runs as a T3 thread (thread 66735a7f) and works in its own worktree. It has no
credentials of its own; it pushes only its own `della/*` branches through the repository credential helper (mos-0981),
never `refactor`, `main` or another seat's branch. Registered by Sage on 2026-10-22 so the queue can name the seat as
row owner under Q220 (mos-1301).
+16 -1
View File
@@ -77,4 +77,19 @@ from overlapping, so the first pass finds at most one.
## Runs
None yet. Every run waits for Sage's CLEAR.
- r2 ecd9b35eb on dcd8d4dda, Sage's CLEAR sage-2449, one pass with the first red stopping it
(run-268-c.sh), 2026-10-22 04:40:22Z to 04:42:17Z, 115 s. M1, M2 and M3 all killed (scope.test
2 of 3 failed, mapping.test 2 of 11, mapping.test 1 of 11), and each file's sha256 matched after the restore.
Brief files 14/14, library 83/83, business 209/209. test-auth 15, test-conductor 17, test-config
31, test-discord 78, test-extension-package 18, test-foundation 44, test-land-work 86, test-queue
28 plus 156 node, test-release 14, test-task 91, with 0 failures. Node skipped 0. The scripts
skipped three cases: test-queue's verify and render check (not the canonical root) and
test-task's live provider cases twice (row 244). Neither test-task skip is a dead-Docker skip.
- r3 aca53c4f0 on 0fa7bf421, after row 191 landed. The cherry-pick of r2 has range-diff `=` and
stable patch-id 2942acc8e6e2b0171b96ebbf033ab5c08c0f0eac. Rerun on CLEAR sage-2461, 04:50:29Z to
04:50:38Z: brief files 14/14, library 83/83 (survey.test on 191's scripts/mosaic), business
209/209, 0 skipped. Webby approved r3 as a carry (webby-0112).
- r4 c27207f6e on ce6a96ad6, after row 205 landed (sage-2466: 205's gate went first). Cherry-pick
of r3 with range-diff `=` and the same patch-id. Rerun on CLEAR sage-2480, 05:04:50Z to
05:04:58Z: brief files 14/14, library 83/83, business 209/209, 0 skipped.
- Every sweep found the worktree clean and no node process in it or in its temp dir.
+449
View File
@@ -0,0 +1,449 @@
# Row 269 build notes (Gizmoduck)
264b, T1 item 7: per-session native store, workspace and single-writer fence. Gitea #1774. Brief:
docs/plans/2026-10-21_t1-sessions.md (blob ff0be0c79), slices in agents/sage/work/queue-264/RULINGS.md
(5f5b51dd6). Reviewer Duckworth, gate owner Sage. Plan gizmoduck-0228, accepted with rulings in
sage-2707. Branch gizmoduck/row269 from refactor aca16404c. Row 253 lands first, and this branch is
rebased onto its tip, under a new name, before the gate.
Claim: rev 3217, op gizmoduck-0230, 2026-10-22 14:29:42Z. That was 21 s after Gander's slot opened
at 14:29:21Z (sage-2710). Reported in gizmoduck-0231 and not reverted, since a revert would be a
second queue write.
## D9 consumer audit, sessions.json (sage-2707 Q3)
Q3 allows `session` as an optional field with no version bump, if every sessions.json reader at
84f53540d selects by run and not by business, instance or thread. Every reader at 84f53540d:
| Reader | file:line at 84f53540d | Selects by |
| --- | --- | --- |
| `readSessions` (the only parser) | packages/seat/src/session.mjs:232-243 | nothing. Refuses any `sessionsVersion` but 1 (:241) and returns every entry with `live` |
| `stopSession` (`mosaic stop <run>`) | session.mjs:249-250 | run (`s.run === run`) |
| `launches list` | packages/cli/src/cli.mjs:526-530 | nothing. Prints every entry, and `--json` dumps them whole |
| host start, `leftover` | packages/cli/src/launcher.mjs:235 | business, then `recover()` works one entry (one run) at a time (:955-972) |
| runtime launch settings | packages/bus/src/runtime.mjs:149 -> packages/business/src/launch-settings.mjs:166 -> packages/business/src/launch.mjs:116-135 (`usageCounts`) | business. Counts entries per provider and model |
| Console settings (C1, duckworth-0150) | packages/webui/src/reads.mjs:234 -> the same launchSettings `read()` -> readSessions | business, through usageCounts. Any throw becomes `launch: {refused: 'not-configured'}` |
| stack-release R guard (C1) | scripts/stack-release.sh:2091-2096, its own JSON.parse, no version check | nothing. Takes `pid` and `runnerPid` of every entry in `sessions` and fails `sessions-live` if one is alive in /proc |
Not a reader: stack-release.sh:2199-2204 checks only that no sessions.json exists. packages/ledger
readSessions (ledger.mjs:146) is a different function that reads seat directories.
launch-settings `current()` (:129) doesn't call `sessions()`, so the limits the bus enforces don't
read the file; only the `limits` verb's `read()` (:166, runtime.mjs:292) does.
Tests that read the file: packages/cli/tests/launcher.test.mjs (`readSessions` at :164 through
:1097, each by run, by instance or whole) and packages/cli/tests/host.test.mjs:830 (file absent).
Not a file reader: `Registry` (session.mjs:192-230). Its list starts empty (:193), and the constructor
writes it over the file (:198). `running(business, instance, thread)` at :217 is the one key on the
thread, but it reads only the in-memory list this host added (launcher.mjs:529, removed at :553).
Its callers are launcher.mjs:289 (check) and the thread pass. This row changes that key (see Design).
Result: no file reader keys on the thread. Q3's condition holds as worded.
One more finding sends me to v2 anyway. `recover()` rebinds a leftover run with
`{business, role, run, harness, pid, startTime, thread}` and no `session` (launcher.mjs:964).
The broker compares that record's body with the recorded `session.launched` body, and the body
carries `session` whenever the launch record had one (broker.mjs:945). A mismatch refuses
`launch-record-mismatch` (broker.mjs:952). Each new thread run binds with its session (Design), so
after a rollback to 264a, a 264a host that finds a 264b entry logs "not ended, bindLaunch refused:
launch-record-mismatch" (launcher.mjs:968), and the run's claim stays held. The host has
already killed the process (:956-962), but it can't end the run. With v1 and an optional field,
that failure is quiet.
Decision: sessions.json goes to v2 (`SESSIONS_VERSION` 2). New code reads v1 and v2 and writes v2.
A v2 file keeps the `sessions` array and every v1 field of each entry, `pid` and `runnerPid`
included, so the R guard (stack-release.sh:2094-2095) reads it unchanged (C1).
A v1 entry has no `session`, and it is treated as the thread's legacy default session (D9). After a
rollback, a 264a host refuses to start with exit 3, "sessions file is malformed"
(session.mjs:241 via launcher.mjs:237), and the README's remedy applies: check which sessions still
run, then move the file aside. Tradeoff: a rollback while runs are live needs a hand step, but it
fails loudly at host start instead of leaving held claims. The rollback note names this refusal.
## D9 consumer audit, threadsState and threadIdle (sage-2707 audit addition)
These are trusted launcher APIs, not socket verbs (packages/bus/README.md:288, :294), and they're
reached only through the host op table (packages/bus/src/process.mjs:23-24, :54-55;
runtime.mjs:561-562). At aca16404c:
| Consumer | file:line | What it reads |
| --- | --- | --- |
| launcher thread pass | packages/cli/src/launcher.mjs:596, :611 | every row's `thread`, `holder`, `pending`, `unread`; threadIdle `{business, thread, run}` |
| bus end-launch.test | packages/bus/tests/end-launch.test.mjs:193, :198 | whole rows (deepEqual), and `not-holder` |
| bus thread-migration.test | packages/bus/tests/thread-migration.test.mjs:139 | `thread, holder, pending, unread` |
| bus threads.test | packages/bus/tests/threads.test.mjs:352, :375-376, :407, :416 | rows keyed by `thread` |
| cli launcher.test stub | packages/cli/tests/launcher.test.mjs:989 | wraps `host.op`, counts the op name |
| cli lifecycle.test stub | packages/cli/tests/lifecycle.test.mjs:90-91, :749-752 | passes through, wraps `runtime.threadsState` |
| cli thread-launch.test stub | packages/cli/tests/thread-launch.test.mjs:90-91, :163, :264-265, :304, :348, :443 | `.find(x => x.thread === ...)` then `holder` |
Console and webui: no reader. `git grep threadsState` over packages/webui, packages/control-board and
scripts has no hit. The webui reads `thread.claims` (packages/webui/src/serve.mjs, cli.mjs,
public/bus.js, tests shell.test, thread-claims.test, thread-claims-browser.test). This row doesn't change
`thread.claims`. The webui's `session.launched` line reads only `harness` and `address`
(public/bus.js:151), so a `session` field in the body changes nothing it shows.
## D9 consumer audit, workspace and native store
| Consumer | file:line at aca16404c | Change in this row |
| --- | --- | --- |
| `workspaceDir` | packages/seat/src/session.mjs:49 | unchanged, still the non-thread path and the legacy path |
| launcher start, workspace | packages/cli/src/launcher.mjs:397-403 | a thread run uses its session workspace (Q1) |
| launcher instance delete (V5) | launcher.mjs:841-866 | also retires the instance's session workspaces |
| `threadStore` | session.mjs:59-74, launcher.mjs:388-393 | becomes the legacy branch of `sessionStore` |
| sandbox binds | packages/seat/src/sandbox.mjs:102-125 | the workspace gets a separate in-sandbox path |
| harness `transcript()` | packages/plugins/harness/pi/index.mjs:75, claude-code/index.mjs:130 | none: `{session, projects}` keeps its shape |
| in-sandbox readers of `session.workspace` | packages/harness/src/runner.mjs:332, :386, :475-476; bundle.mjs:258, :327; gate.mjs:152 | none: they get the in-sandbox path |
| task runner workspaces | scripts/mosaic-task.mjs:351 | none: `<dataRoot>/workspaces/<task>` is a legacy root the overlap check covers |
| git socket | packages/cli/src/git-socket.mjs:7 | none: it takes bundles and reads no workspace |
## Pi and Claude resume, read offline (sage-2707: no pi run while P19 383 holds)
Pinned `@earendil-works/pi-coding-agent` 0.85.1 (package.json:8), read in node_modules/.../dist:
- adapters/pi/adapter.sh:46-52 passes `--session-dir "$MOSAIC_SESSION_DIR"`, plus `-c` when that
directory isn't empty.
- With `-c` and an explicit session dir, `continueRecent` filters by cwd
(core/session-manager.js:1244-1250). It opens the newest `*.jsonl` whose header cwd resolves to
the process cwd (:397-410, `sessionCwdMatches` :393-395). With no match, it makes a new session in
the same directory (:1251), and nothing fails.
- Both adapters `cd "$MOSAIC_WORKSPACE"` (pi adapter.sh:34-36, claude adapter.sh:46-47), the runner
sets `MOSAIC_WORKSPACE` from `session.workspace` (runner.mjs:386), and the sandbox binds the
workspace at its host path (sandbox.mjs:112, `argsOf` :128-131). So a legacy transcript's header
cwd is `<dataRoot>/workspaces/<business>/<instance>`.
- Opening a file whose header cwd doesn't exist exits 1 in a headless run (main.js:536-548,
core/session-cwd.js:2-15). So `--session <file>` is no way around it either.
Result: a legacy default session run from `session-workspaces/.../<session>` as its cwd would
quietly start a new pi conversation, and its old history wouldn't resume. Claude Code keys its
projects directory on the cwd, and the adapter resumes by id (claude adapter.sh:53-59). I haven't
read the Claude binary, so whether `--resume` finds an id saved under another cwd is unverified. I
treat it as the same break.
## Design
### Legacy mapping (D9, M7)
A session is legacy when its row has `op='legacy-3l'`, which the migration gave every pre-3l thread
with `id` = the thread id (3k-3l.sql:50-51). The rule reads only an immutable row, never the disk.
- A legacy session keeps the thread's existing store, `threads/<b>/<t>/{session,claude-projects}`
(`threadStore`, unchanged).
- Its workspace is new (Q1), `session-workspaces/<b>/<instance>/<session>/`, but inside the sandbox
it's bound at the legacy path `workspaces/<b>/<instance>`. The cwd its transcripts recorded is
unchanged, so pi `-c` and Claude `--resume` find them. The host's old instance workspace isn't
mounted, so it stays as history (Q1, D9). Every in-sandbox reader gets the sandbox path (table
above), and host git (`ensureCheckouts`) gets the host path.
- Every other session uses D2's layout for both, and its sandbox path is its host path.
- A thread made by 264a's `thread.create` has a random default id (broker.mjs:5993), so this rule
doesn't call it legacy. A 264a launcher ran that thread from `threads/<b>/<t>/session`. A start
for a session whose thread has no legacy-3l session, while `threads/<b>/<t>/session` exists,
refuses `legacy-store-unmapped`, so it fails closed and doesn't start a fresh conversation. The
re-pin plan lists such threads. If 264a and 264b go live together, there are none.
### Broker
- `threadsState(business)`: one row per session of every thread, not just the default, ordered by
thread seq, then session seq. Each row adds `session` and `legacy` to the fields it has now.
`#threadRows` joins `sessions` in place of `session_defaults` (broker.mjs:868-896).
- `threadIdle(record)`: same keys. The row is the one for the session the run was bound to
(`#runs` entry, broker.mjs:927), so a run is idled against its own session.
- `bindLaunch`: no new refusal. It already takes `session` and puts it in the body (broker.mjs:945).
The broker half of the single-writer fence is the per-session claim that exists now:
a second claim on a held session refuses `thread-already-held` (broker.mjs:4815). A
`session-running` refusal at bind would break two old tests that bind a second unended run to one
session and then expect the claim refusal (threads.test.mjs:88-89, sessions.test.mjs:119-120).
The launcher's registry check and the workspace lock are the host halves.
### Seat
- `sessionStore(dataRoot, business, thread, session, { legacy })`: the legacy branch is
`threadStore`. Otherwise it's `threads/<b>/<t>/sessions/<s>/{session,claude-projects}`, with
`threadStore`'s checks on every segment and directory (ID_PATTERN, no `..`, owner, 0700, no
symlink), giving the same `{dir, session, projects}` shape.
- `sessionWorkspace(dataRoot, business, instance, session, { live })`: makes
`session-workspaces/<b>/<i>/<s>` 0700 with the same checks, then compares realpaths. It refuses
`session-workspace-overlap` when the path is inside, or contains, `<dataRoot>/workspaces` (legacy,
`.retired` and task workspaces) or any workspace in `live`. A segment that fails the lstat checks
refuses `session-workspace-unsafe` first (C3).
- Lock: `lockSessionWorkspace(path, owner)` makes `<path>.lock` with O_EXCL, next to the workspace
and outside every mount. It holds the host's pid and start time, and the run's outer pid and
start time once spawned. A lock whose host and run are both gone is stale and is taken over.
Anything else refuses `session-workspace-busy`. start() takes it before `ensureCheckouts` and
releases it only after the run's `close` (proven exit) or a failed start.
- `Registry`: entries gain `session` and `workspace`, and the key becomes `running(business,
instance, thread, session)`. `SESSIONS_VERSION` is 2, and `readSessions` reads 1 and 2.
### Launcher
- check(instance, { thread, session }): `instance-running` is per session. Q2 adds
`legacy-run-live`: a start for a thread instance refuses while any registry entry or unrecovered
leftover for that instance has no `session`.
A survivor is still bound and ended host-lost (launcher.mjs:1030-1040), so the broker sees its
claim free while it runs. A released claim doesn't prove the run has exited. Only this launcher
check holds the instance then, for sessionless survivors and thread starts only (duckworth-0158
N1). recover() waits up to 5 s per leftover for the outer pid and the runner (N2, see "recover()
change, for a design read").
Residual (duckworth-0159 N3): a survivor with a `session` counts nowhere, because :309 counts
only sessionless entries. The same session could start again while the old runner lives. The
lock doesn't catch it either: it records the outer pid as runPid (launcher.mjs:574), so with the
outer pid gone it reads stale and is taken over. The base has the same exposure. In practice
the runner dies with its pid namespace when the outer bwrap is killed.
- start(): the store and workspace come from the session. The lock covers checkouts and spawn.
`bindLaunch` gets `session`, and the entry records it.
- threadPass(): one pass per session row. `startedFor` and `refusedThreads` key on the session.
The `thread-idle`, `refused` and `end` records add `session`.
- recover(): passes the entry's `session` when present.
- remove(): moves `session-workspaces/<b>/<instance>` to `session-workspaces/<b>/.retired/<instance>-<transaction>`
after the legacy move, and records `workspace-retired` with that path.
- Admission (RULINGS Q5, D8): a named session starts only through threadPass, then check(), then
start(), so it passes through every `admission()` call start() makes now (launcher.mjs:382-524).
There is no second start path.
### Sandbox
`binds()` takes `workspaceDest` (default `workspace`), the in-sandbox path for the rw workspace
bind. The store binds keep their shape: only the selected session's two directories.
## Tests
T1 run half, with the fake harness adapter from thread-launch.test (no pi or Claude binary):
two sessions of one thread run at once, each with its own sentinel history, store and workspace;
stopping one and restarting it resumes only that one. The legacy session resumes from the old
store at the old in-sandbox cwd. A non-thread instance gets `thread-not-allowed`.
T2: a second start on a live session's workspace refuses `session-workspace-busy` before any
checkout; a hand-made live lock from another host refuses; a symlinked or nested session root
refuses `session-workspace-unsafe`; a workspace root symlinked into the session path refuses
`session-workspace-overlap`; a store segment symlink refuses `thread-store-refused`. Under a halt, a
named session's start refuses `processing-halted` and spawns nothing. A generation change after
check() refuses `processing-generation-changed` and spawns nothing (M6).
N1 (DEFERRED.md:474-480): `refusedOnce` in packages/bus/tests/sessions.test.mjs pins `actor_run`.
## Old assertions that change (C4, duckworth-0150, mos-1285)
Every pre-existing test that reads threadsState at aca16404c, checked against one row per session:
- packages/bus/tests/end-launch.test.mjs:193-195 deepEquals the whole row. This one changes. Mos
allowed it in mos-1285 as an exact update: still a whole deepEqual on threadsState('demo'), still
exactly one th-1 row, every old key and value the same, and two new keys with literal values,
`session: 'th-1'` (the fixture's legacy default, :14-15) and `legacy: true` (op `legacy-3l`). The
commit message names mos-1285 and the old row shape.
- packages/bus/tests/threads.test.mjs:352, :375-376: fromEntries keyed by thread. Neither test, and
neither fixture (`twoThreads`), makes a second session (no `session.create` in the file), so each
thread still has one row. Unchanged.
- packages/bus/tests/thread-migration.test.mjs:139: maps four fields of a one-session thread.
Unchanged.
- packages/cli/tests/thread-launch.test.mjs:163, :304, :348, :443: `find` by thread. One session
per thread in every fixture. Unchanged. The default session also sorts first, by session seq.
- packages/cli/tests/lifecycle.test.mjs:90-91, :749-752 and thread-launch.test.mjs:90-91, :264-265
pass threadsState through. Unchanged.
- packages/cli/tests/thread-launch.test.mjs:182 (found in the code round, after mos-1285). The test
makes threads with `thread.create` (:171-172), so A's default session has a random id and op
`default` (broker.mjs:5992-6009), not `legacy-3l`. The brief puts every new store in D2's layout
(t1-sessions.md:100, :294), so A's store moves to `threads/acme/<A>/sessions/<A's session>`. The
proposed exact update changes only the `store` path on :182, taking the session id from
`threadsState` (the test already reads it at :162). Lines :183-185 stay as they are. One added
line asserts that `threads/acme/<A>/session` doesn't exist. This goes to Mos through Sage, and no
code commit edits the file until Mos rules.
- The same file's :375 (`../evil` refuses `thread-store-refused`) and :381, :384 (no `threads`, no
`workspaces`) stay unchanged: start() checks the store before it makes the session workspace.
Any other old assertion the code changes goes to Mos through Sage before the commit. After the
first run I survey every touched file whole for one.
## Mutants
Each kill counts only as an assertion failure in a test that runs that path, never a throw or
another fence (sage-2707, C3 duckworth-0128).
| Id | Change | Killed by |
| --- | --- | --- |
| M1 | `sessionWorkspace` drops the session segment: a thread's sessions share one checkout | T2 same-thread fence: `session-workspace-busy`, no second spawn |
| M2 | the session segment becomes the session name: two threads' `default` sessions share one checkout | T2 cross-thread fence, same assertion |
| M3 | the lock ignores a live holder | T2 hand-made lock: `session-workspace-busy` |
| M4 | the realpath overlap check removed | T2 nesting: `session-workspace-unsafe` |
| M5 | the launcher passes the thread's default store for every session | T1: the second session's sentinel history is its own |
| M6 | start() calls a no-op in place of every `admission(admissionGeneration)` (launcher.mjs:382, :396, :407, :420, :495, :499, :520, :524) for a non-legacy session | the generation test (below): `assert.rejects` with code `processing-generation-changed`, then `assert.equal` spawn count 0 |
| M7 | the legacy session maps to `sessions/<s>/` | the D9 test: the legacy run resumes the sentinel in `threads/<b>/<t>/session` |
| M8 | a non-default session binds its workspace at the legacy `workspaces/<b>/<instance>` in-sandbox path | T1: `assert.equal` that a named session's in-sandbox workspace is its `session-workspaces/<b>/<instance>/<s>` path, read from the fake adapter's recorded cwd (sage-2717) |
| M9 | `#threadRows` back to the default session only | T1: the named session's message starts its run |
| M10 | the `legacy-run-live` refusal removed from check() | the legacy-run-live test: `outcome` equals `[[S, "legacy-run-live"]]` |
| M11 | recover() keeps a survivor only when the outer pid outlived the kill (the runner ignored) | the legacy-run-live test: the survivor log `assert.ok` (session-launch.test.mjs:309); the host doesn't see the live runner, so the survivor line is never logged (run 17:49Z 10-22, duckworth-0181) |
| M12 | check() reads every survivor, live or not | the legacy-run-live test: after the runner exits, `settle` gives one launch |
M6 and the halt fences (C2, duckworth-0150). A plain halt never reaches start(). tick() admits first
(:579) and runs no thread pass while halted (:583), and check() admits at :282 and :376. The kill
test lands the change between check() returning and the spawn:
- The fake host's `credentialStatus` op (start() calls it at :395, after check() returned and after
:382) records that it ran, then halts and resumes processing through `transition`. That leaves
`halted` false and a new generation (state.mjs:115-121).
- The tick and check fences see no halt, and start()'s second check() (:419) takes its own
generation, so it passes too. withProcessing at :406 has no parent context (the launcher never
opens one) and checks only `halted`, so it passes. The one fence left is start()'s own
`admission(admissionGeneration)`, which refuses `processing-generation-changed` at :396.
- The test asserts the hook ran (check passed), then the refusal code, then no spawn.
M6 removes all eight calls because removing only :382 leaves :396 to refuse. The T2 halt test stays
as written in Tests, but it doesn't kill M6 and isn't counted for it.
M4 and the segment checks (C3, duckworth-0150). The lstat check on each segment refuses a symlinked
session root before the realpath compare runs, so the compare gets its own code,
`session-workspace-overlap`, and the segment check keeps `session-workspace-unsafe`. M4's kill input
passes every segment check: `<dataRoot>/workspaces/<b>` is a symlink to
`session-workspaces/<b>/<i>/<s>`. Each segment of the session path is a real directory, and only the
realpath compare finds that the session path contains the legacy workspace root. The test asserts
`assert.rejects` with code `session-workspace-overlap` and no spawn. With M4 the start spawns, so
the rejection assertion fails. The segment test keeps asserting `session-workspace-unsafe` for a
symlinked session root, so it tells the two codes apart.
Duckworth's check for the early look (sage-2717): a non-default session never gets the legacy
`workspaces/<b>/<instance>` bind path. Only a session with op `legacy-3l` takes `workspaceDest`
set to the legacy path. M8 is that mutant.
### Code round corrections (local, not yet run)
Written before the first run, which waits for Sage's CLEAR.
- Tests live in two new files: packages/cli/tests/session-launch.test.mjs (T1, D9, T2) with a new
stub adapter, fixtures/session-adapter.mjs (thread-adapter.mjs's turn plus the run's cwd and its
entries; thread-adapter.mjs is unchanged), and packages/seat/tests/session-workspace.test.mjs
(sessionStore, sessionWorkspace, the lock, sessions.json v2). The new launch tests copy
thread-launch.test.mjs's host helpers rather than change them, so no other old test file changes.
- M1 and M2 kill in T1, not in a busy test. A test that expects `session-workspace-busy` still
passes when sessions share a path. T1 starts thread A's default and named sessions and thread B's
default at once and asserts no refusal, three live entries and three distinct cwds. With M1 (A's
two sessions) or M2 (the two `default` sessions) the second start refuses busy, and the
`deepEqual(refused, [])` assertion fails.
- Every wait a mutant could stall returns what it last saw (`settle`, `outcome`), so a kill is the
assertion after it, never a timeout. That covers M3, M4, M6 and M9, where the mutant's run starts
or never starts.
- The M6 test also carries the halt fence. start() admits right after credentialStatus, before it
makes the session workspace, so the first refusal leaves no workspace. On the retry the recheck's
mayLaunch op halts after the lock is taken. The recheck refuses `processing-halted`, and the test
asserts that the lock is gone (startLocked's throw releases it).
- The lock's release() runs once. A second call would unlink a lock another host has written since.
- `legacy-run-live` now has a test (Duckworth's early warning in duckworth-0156). Before, recover()
kept a survivor only when a bwrap pid outlived SIGKILL, which a test can't make. It also ended
a leftover whose runner still ran once the outer pid was gone, so the refusal never saw that run.
Now recover() waits up to 5 s on the outer pid and the runner. It keeps any leftover where
either still runs, and logs `run <run> (<instance>) from an earlier host still runs`. check()
reads only survivors that still run, so a start goes ahead once the run has exited. The test
writes a sessionless leftover whose runner is a sleep child and whose outer pid is gone. It
asserts the refusal, no launch, no session workspace, and then one launch after the child is
killed. M10-M12 cover it. The registry and pre-recover leftover terms stay. A launching host
can't reach them for a thread instance (check() refuses `session-required` first, and listen()
runs recover() first), but they cost nothing.
### recover() change, for a design read (sage-2750)
Sage asked for this because recover() predates the row. Lines are launcher.mjs at 3d29876df plus
the log-line edit after it, against aca16404c:947-976.
- What stays the same: every leftover is still bound and ended as host-lost (:1030-1040, base
:963-973). The survivor push (:1026-1029) comes before the bind, and the loop carries on past it,
so a kept leftover isn't left unended. Its bus run ends and its role is released, as in the base.
The kill is unchanged too: SIGKILL only to a live outer pid whose cmdline is bwrap (:1018-1021,
base :956-959).
- What changes:
1. The wait after the kill runs while the outer pid or the runner still runs (:1023). In the
base it waited on the outer pid only (base :961).
2. A leftover where either still runs after that goes on `survivors` and is logged (:1026-1029).
3. check() counts a survivor only while it still runs (:309, `liveRun` at :255).
- a. Nothing in this row kills a kept leftover's runner. The base didn't either: it ended the run
and let the runner go on, unseen. The run is still ended (:1038), so the runner holds no role.
Its capability died with the old broker (:1004-1008). The only thing a survivor does is refuse
`legacy-run-live` for sessions of its instance while it lives, and each refusal is a `refused`
launch record. For an operator, the log line names the run, the outer pid and the runner pid
(:1028). Killing that pid clears it. check() re-reads liveness on each pass (:309), so the next
tick starts the session with no host restart. If bindLaunch refuses (:1032-1036), the run stays
unended and logged, as in the base (base :965-969). The survivor entry doesn't change that.
- b. The 5 s wait only applies inside the kill branch (:1018-1024), so only to a leftover whose
outer bwrap was still alive. A leftover with nothing running doesn't wait, and nor does one
whose outer pid is gone: neither enters the branch. The wait ends as soon as both processes are
gone. The base already waited up to 5 s in the same branch. It can now last longer only if the
runner outlives its outer pid. With `--unshare-pid` and `--die-with-parent` the runner dies when
the namespace goes.
- c. Old tests that run recover() with a launching host and a leftover:
- launcher.test.mjs:885-943, the "host that died hard" pair. It asserts the outer pid and the
runner are gone right after setup (:917-918), the host-lost log (:919), one session.ended
(:920-922), one end record (:924), an empty sessions.json (:925), and then "already ended" for
a leftover whose processes are dead (:937-941). None of these expected values change.
Waiting on the runner makes :918 hold sooner, not later. The new log line can appear only if
a process outlives 5 s, which would already fail :917-918. The dead-process third host takes
no wait and logs no survivor.
- launcher.test.mjs:950-971 (`--no-launch`) returns before the loop (:1012-1015). It's
unchanged.
- stack-release.test.mjs:1928 writes `{pid: process.pid}` with no start times. `liveRun` is
false for it, and that test reads it through bus status, not recover().
- verbs.test.mjs:213 and the seat tests build a Registry with no launcher, so they aren't
affected.
No expected value in an old test changes, so nothing goes to Mos under mos-1272 for this. If a
run shows otherwise, I stop and send it before any change.
- Fallback, if Duckworth or Sage doesn't accept this: revert recover() and check() to the base
behaviour, keeping only the sessionless check over the registry, the pre-recover leftovers and
bwrap survivors. Drop the legacy-run-live test and M10-M12, and name the untestable path here.
- N1 (refusedOnce pins `actor_run`) changes a pre-existing test. Mos allowed it in mos-1292 (copy
mos-1294) on mos-1285's terms, and it's committed as a81b3446e, with a closing line on the
DEFERRED.md entry.
## Rollback
264b writes sessions.json v2. A 264a host given that file refuses to start, exit 3, "sessions file
is malformed" (session.mjs:241 via launcher.mjs:235-237). Session workspaces and per-session stores
stay on disk, and a 264a host never reads them.
What else refuses on a 264a stack while a v2 file is in place (C1, duckworth-0150). All of them go
through the 264a readSessions, which throws SeatError "sessions file is malformed" (session.mjs:241):
- `mosaic launches list` and `mosaic stop <run>`: exit 2 with that message (cli.mjs:526, :465).
- the bus `limits` verb: `settings.read()` throws (runtime.mjs:149, :292, launch-settings.mjs:166),
so the verb fails with that message. The limits the bus enforces still work, since `current()`
doesn't read the file. I haven't traced the code a bus caller sees for that throw.
- the Console settings view: launch shows `not-configured` (reads.mjs:234).
- the R guard keeps working (above).
All of these read the file on each call, so step 3 below clears them without a restart.
Console restart: the shape of what the Console reads doesn't change. usageCounts still counts
entries per provider and model, and reads.mjs keeps its fields. What changes is the file, and a
Console still on 264a code shows launch `not-configured` from the first v2 write until a human
restarts it on a 264b copy. The re-pin that carries this row needs its Console restart (Jason or
Mos), done right after the host's, as it does for a read-shape change. Step 3 covers the reverse
case after a rollback with no restart.
Seeing live sessions without `launches list` (C1): read the file directly the way the R guard does.
For each entry in `sessions`, a session is live if `/proc/<pid>/stat` or `/proc/<runnerPid>/stat`
exists and its state isn't `Z` (stack-release.sh:2080, :2095). For a stronger check, compare
`runnerStartTime` with field 22 of `/proc/<runnerPid>/stat`, which is what readSessions' `live`
does through startTimeOf.
How a rollback host gets past exit 3 (sage-2717), run on NEW before the rollback activate:
1. Stop the bus host on NEW. Its stop SIGKILLs what is left and ends those launches as `killed`
(packages/cli/README.md:470).
2. Read `sessions.json` with NEW's readSessions (`mosaic launches list`, cli.mjs:526-530). Go on only
if it shows no live entry: every listed runner is gone (pid and start time no longer match). If
NEW's CLI is already gone, use the /proc read above.
3. Move `sessions.json` aside under a dated name, never delete it (packages/cli/README.md:478).
4. Then roll back. The 264a host starts with no leftover file and nothing to recover.
A live entry at step 2 stops the rollback until that run ends or `mosaic stop <run>` ends it.
Which re-pin plan step carries it: no re-pin plan names 264b yet. The 84f53540d re-pin carries 264a
only. The first re-pin whose NEW includes this row puts steps 1-4 in its "Stop conditions and
rollback" section, before `rollback --plan`, the section the 10-24 plan uses
(agents/sage/work/window-plan/repin-1024-draft1.md:144-148). I'll name it to whoever drafts that
plan when the row lands.
## Run limits (sage-2707)
Each node --test file and suite runs under `timeout -k 10 LIMIT`, and each scripts/test-*.sh under at
least 3x its longest recorded run. Records are from agents/gizmoduck/work/queue-264/BUILD.md "Run
limits" unless named.
| Run | Longest recorded | LIMIT |
| --- | --- | --- |
| stack-release.test alone | 575 s (264a r12 survey) | 1800 s |
| each other touched file alone | 15 s (thread-launch.test); the rest have none | 300 s |
| bus suite | 23 s | 900 s |
| cli suite | 591 s (row 281 gate) | 1800 s |
| webui suite | 57 s | 600 s |
| seat suite | none recorded | 600 s |
| harness suite | none recorded | 600 s |
| each scripts/test-*.sh | 42 s (test-foundation) | 900 s |
| each mutant probe | 1 s wall (264a M1-M4) | 300 s |
+522 -145
View File
@@ -114,6 +114,291 @@ Webby's round d1 on this ruling (webby-0088, REVIEW-d1 at webby/reviews 35130c26
refusal for a prefix that holds a book folder (B1), and asked for the `unmapped` row, the `kind` values,
the readers and the section 8 line (N1-N4). All five are in the text above, in 1.2 and in 8.
## Ruling after r11: idea items capped at 3 of 120 characters (Q219)
Jason answered Q26 with "yes" (DECISIONS Q219, mos-1299, Vikunja 465, ludwig-0014 Q4). An `idea` item
is at most 120 characters, at most 3 per reply. The rest of M5 is unchanged: 10 items and 1,200
characters of item text per reply, 300 for `fact`, `finding`, `correction` and `question`, and the
correction edit distance. The residual in 5.1 is now 360 characters of free suggestion text per reply,
and 10 step 8 is closed by Jason. C2-06 hasn't landed, so it builds with the new numbers, and its tests
refuse one past each new boundary and accept at it.
## Ruling after r11: reply limits are settings (Q222)
Jason ruled Q222 (DECISIONS Q222, mos-1302): "Things should NOT be hardcoded." Q219 stands for now, and its
numbers become the defaults. Every M5 limit is a setting in a new top-level `replyLimits` object of the
business file:
| Key | Default | Valid value |
|---|---|---|
| `itemsPerReply` | 10 | an integer from 1 to 1,000 |
| `itemTextPerReply` | 1,200 | an integer from 1 to 100,000 |
| `ideasPerReply` | 3 | an integer from 0 to 1,000 |
| `ideaLength` | 120 | an integer from 1 to 100,000 |
| `itemLength` | 300, for `fact`, `finding`, `correction` and `question` | an integer from 1 to 100,000 |
| `correctionMinDistance` | 12 | an integer from 0 to 100,000 |
| `correctionPercent` | 25 | an integer from 0 to 100 |
A `correction`'s `to` is within edit distance max(`correctionMinDistance`, `correctionPercent`% of
`from`) of its `from`.
Where. The object sits beside `projects`, not inside the library entry, because M9 keeps `projects` to
hand edits and Mos asked for a CLI verb. `TOP_KEYS` (`packages/business/src/business.mjs:24`) gains `replyLimits`, and
`validateBusinessDocument` accepts any JSON value under it. A bad hand edit then never stops her business
from loading.
Who changes them (M11). Only the business file's `human`, through a new human verb `library.limits.set`
with `revision` and `replyLimits`. She runs it as `mosaic limits replies --file <json>`, a new verb in
`packages/cli/src/limits.mjs` beside `mosaic limits set`, and later from the Console. The verb replaces the whole object, and a key left out means its default. It sits outside
`ORGANIZATION_KEYS` and `TESTER_ORGANIZATION_VERBS`, as `instances.set` does (`broker.mjs:219`, `:139`,
sage-0918). An agent session gets `human-required` and the tester gets `tester-refused`. A value outside the
table refuses `invalid-request`, and so does a key not in the table. Nothing is written.
The fence that covers every other path sits in `organizationWriter.write`
(`packages/business/src/organization.mjs:180`), next to M9. It compares against the file on disk, `old`
in `write`, never against the incoming document. A write whose `replyLimits` differs from
`old.replyLimits`, compared in canonical JSON, refuses `organization-reply-limits-human-only` unless both
hold:
- `actor` equals `old.human`. A write that sets `human` to its own actor in the same document still
compares against the old `human`, so it refuses.
- `actor` isn't `tester` and isn't `<definition>.<role>` for any role in `old.roles`, the two shapes of
an agent actor (`broker.mjs:4201`, `:4206`, `:4306`). `ID_PATTERN` (`vocabulary.mjs:199`) allows a dotted
`human` id, and the broker refuses only `tester` (`broker.mjs:328`), so without this a human id such as
`assistant.bean` would let that agent through.
As M9 does (`organization.mjs:191-193`, `:199-200`), the fence checks `document` and then `written`, the
JSON round trip, so a value with a `toJSON` can't differ between the check and the disk. That stops the
chief of staff's `organization.set`, any actor's `limits.set`, `instances.set`, `routes.set` and
`plugins.set`, the tester, and a direct call to `write`. Every setter builds its document with
`structuredClone` of the file, so none of them changes `replyLimits` today and the fence refuses no
ordinary write.
Read at check time. The guard reads `replyLimits` from the business file on each check, through the
organization reader, not from the broker's copy loaded at start (`broker.mjs:419`, read by `#business`
at `:570`). A change applies to the next reply with no restart.
A failed read never skips the check. If the business file is missing, can't be read, isn't JSON or isn't
an object at check time, the guard checks the reply at the defaults, except `requireCitation`, which
resolves to `true` (webby-0151 R2, ruling after r11), and `library.status` shows
`reply-limits-unreadable`. The reply is neither refused for that alone nor let through unchecked.
Fallback. Each key resolves on its own. The guard reads a key only if `Object.hasOwn(replyLimits, key)`,
and uses it only if `Number.isInteger(value)` is true and the value is within the table's range. Anything
else resolves to its default: a missing key, `null`, a boolean, a string such as `"5"`, an array such as
`[7]`, an object, a fraction, `NaN`, a negative number, a value below the minimum or above the ceiling.
Every key resolves to its default when `replyLimits` is missing or isn't a plain object, an array
included. The guard never compares a value it hasn't checked with `Number.isInteger`, so JavaScript's
coercion of `true` or `[7]` to a number can't reach a range check. Unknown keys are ignored when read. The
guard has no path without a number, so no value reads as "no limit". `library.status` shows the limits in
force and names each key that fell back.
The ceilings are validity bounds, not policy: they keep a value from reading as unlimited. 100,000
characters is far past any reply, and Mos can change them. At the ceilings the human can make the guard
loose. The idea residual is `ideasPerReply` times `ideaLength`, up to 100,000,000 characters at both
ceilings, and `correctionPercent` 100 lets a correction replace its whole `from`. Cites stay required at
every value. `library.status` shows the residual, and the setup sheet says so in one line.
What agents see. The contracts don't state numbers. A refusal reason names the key and its value in
force, so the runner's retry (C2-07) tells the agent the limit it broke.
Hold, now lifted. Q29 is answered (Q223, Q226). The next ruling replaces this table's seven keys with
`requireCitation` and `citationsPerReply`, and C2-06 builds that.
## Ruling after r11: agents never write story (Q223, Q226)
Jason answered Q29, and Mos's reading of it, Q30, is confirmed (DECISIONS Q223 and Q226, mos-1305,
mos-1309). F40 to F43 come with it, and mos-1308 sets the tool set. This ruling follows my impact list,
gosalyn-0148, with Mos's eight points, Mos's rulings on my flags in mos-1320, and Webby's round,
webby-0151 R1 to R3 and N1 to N5. It replaces D3, D5, M5, most of 5.2, the Q222 hold and 7.3's
"after the slice". Sections 0, 1.1, 1.2, 2.1, 3, 4, 5, 7, 8, 9.2 and 10 say so.
Story and supporting. Her chapters and story files stay read-only to agents. A human marks folders as
supporting through a new `supporting` list in each universe entry, beside `playground` (1.1). An agent
may create, edit and delete `.md` files under a supporting prefix, and nothing else in her repo. A folder
she hasn't marked is story. A folder in both `playground` and `supporting` is writable. A playground
folder alone isn't (Mos point 4). The prefix rules are the playground's: a prefix inside a book folder is
book level, one outside every book folder is universe level, no prefix holds a book folder or another
supporting prefix, and one inside or holding another universe's book folder refuses the business file
with `library-supporting-other-universe`. M9 already keeps the list to hand edits, since it sits in
`projects`.
Chat. A reply is free text of any length, within the broker's message limits as they are today. The item
list goes, and with it the item kinds, `CREATIVE_KINDS`, the M5 caps, the three notices,
`cited-reply-required`, `reply-kind-not-allowed` and `correction-unbound`. A reply may carry citations
(5.2). Jason's "NEVER" now reads as story in her files, and M12 enforces it in code.
Citations are tunable (F43). `replyLimits` keeps the Q222 mechanism: the object, `library.limits.set`, M11
and the read at check time. The seven Q222 keys go, since their subject is gone. The keys now are:
| Key | Default | Valid value |
|---|---|---|
| `requireCitation` | `false` | `true` or `false` |
| `citationsPerReply` | 20 | an integer from 1 to 1,000 |
The fallback rule holds. A key is read only if `Object.hasOwn` finds it, `requireCitation` only when
`typeof` gives `boolean`, and `citationsPerReply` only when `Number.isInteger` is true and the value is in
range. Anything else resolves to the default, and `library.status` names the key. So a bad
`requireCitation` hand edit reads as off, which is Jason's default and not a weaker guard than he chose.
The verb refuses it with `invalid-request`. One case differs from the defaults. When the business file
can't be read at check time (the Q222 rule), `requireCitation` resolves to `true`, so a setting she turned
on doesn't fail open, and `citationsPerReply` takes its default. `library.status` shows
`reply-limits-unreadable`, and the runner's fixed texts still pass (webby-0151 R2). When
`requireCitation` is true, a reply with no citation refuses `citation-missing`. When a reply carries
citations, the four checks in 5.2 run whatever the setting.
Links. Wiki links already in her files stay. The hub never rewrites a file it wasn't asked to write, and
agents can't write a chapter at all, so they add no link inside one. Their cross-links go in supporting
docs, and Obsidian's graph shows those. The `[CH]` alternative, items 42 to 46 of my impact list, is
dropped. A Console brain map is a later option and has no row.
The write path. Two agent verbs, each with a typed tool:
- `library.doc.write {universe, path, base, content}`, tool `library_doc_write`. `base` is the blob id the
agent read, from `library_get`, or `null` to create.
- `library.doc.delete {universe, path, base}`, tool `library_doc_delete`.
Only a session of a `CREATIVE_DEFINITIONS` instance holding the new action `library.write` and a live
Gitea grant on the library project may call them (`library-write-refused`, `library-write-no-grant`).
The tester gets `tester-refused`. M12 checks the path before anything else. `content` is UTF-8 with no
NUL, at most 1 MiB, the reader's R1 limit. An agent's call passes the runner's tool socket and the bus
socket, and both cap a request at 64 KiB today (`runner.mjs:294`, `server.mjs:62` at aca16404c). So an
agent write over that refuses `request-too-large` before the broker sees it, and the 1 MiB limit binds
only her saves. `library.file.save` joins the `project.file.save` exception at `client.mjs:19` and
`server.mjs:9` and `:62`, with the same `PROJECT_SAVE_REQUEST_MAX`.
Each write is a plumbing commit on the bare mirror, with no working tree. Under the library lock the bus
fetches, reads the path at the remote tip, and compares it with `base`:
1. Equal, or both absent for a create: it writes the blob, builds the tree from the tip's tree with
`GIT_INDEX_FILE` pointing at a temporary index outside the mirror, and runs `commit-tree` with the tip
as the one parent. The author and committer are the seat's `identity`. The message is a fixed line,
`Mosaic: write <path>` or `Mosaic: delete <path>`, and a trailer `Mosaic-Agent: <business>/<instance>`.
The agent supplies no message text.
2. Not equal: it refuses `stale-base` and writes nothing.
3. It pushes `<commit>:refs/heads/<branch>` with no `+`, so the push is fast-forward only (D6). A
rejection for a moved tip fetches once more and goes back to step 1 one time. A blob still equal to
`base` re-applies on the new tip. Otherwise the write refuses `stale-base`.
4. Any other push failure refuses `library-push-failed`, and the commit is left unreferenced. Nothing is
kept to push later, so there is no `unpushed` state and C2-17 drops.
5. After the push, the bus moves `refs/remotes/library/<branch>` and the served ref to the new commit,
which is a fast-forward of the served commit, rebuilds the index, records `lastPush` in the sync state
and stores a notice.
The notice is a message from the writing instance to `human` in that instance's thread. The broker
builds its body, `{"notice": "library-write", "op", "path", "commit"}`, and no agent text reaches it.
The Console shows it as a fixed line with a Revert button (7.4).
`library.revert {commit}` is the human's. It refuses `library-revert-not-agent` unless `commit` carries
this business's `Mosaic-Agent` trailer and is reachable from the served commit. For the one path that
commit touched, it puts back the parent's blob, or deletes the path if the parent had none, as a new
commit by the same steps, with `base` the commit's own blob. A later change to that path makes it refuse
`stale-base`. A revert is her act, so its author and committer are her `editor`, and it pushes with slot
`library-human`, as her saves do. It still works after the agent's grant is revoked or the seat is fired.
A cite to a file whose last commit at the cited commit has a `Mosaic-Agent` trailer is labelled
agent-written (Mos point 3). The broker reads the trailer with `git log -1 --format=%(trailers:key=Mosaic-Agent,valueonly) <commit> -- <path>`,
and the label rides in the stored reply's check result, so no agent can set it.
M12 is the fence. A new pure function in `packages/library`, `supportingPath(mapping, universe, path,
tree)`, and the broker calls it on every agent write and delete. It refuses `library-write-story-path`
for a path that isn't a `.md` file under a supporting prefix of `universe`. That covers an unmapped path,
a `.canvas` or `.base` file, an attachment, any story or canon path, a playground path that isn't also
supporting, and a path under another universe's prefix. It also refuses an absolute path, a `..`, `.` or
empty segment, a backslash, a control character, a path whose any prefix in the tip's tree is a symlink
or a gitlink, a path that differs only in case from a tree entry it doesn't equal, and a path over 1,024
bytes. A delete also needs the path to exist at the tip as a `100644` blob.
Who writes, with what (F40). The shared bot account goes. Each writing seat writes with its own Gitea
account and token from the per-seat grant path (Q190 O1, #436b, row 236). The token stays in the bus's
credential store, out of every sandbox (#436b section 3c), and no agent session holds it. The askpass
helper from 7.2 reads the seat's store file for that one push, with the seat's Gitea user as the only
username it answers.
The chief of staff of her business is enrolled at install and grants the other seats through the
existing step: the chief raises a `credential.mint` decision with a gitea grant for `books`, and the
executor runs on its existing approve (436b section 4, step 0). Whether each such grant also needs
Jason's Approve is Q31, asked of him. This design adds no step for it, and a yes adds none here.
The executor today forks each project and refuses any other reach (436b steps 4 and 5). A library
project can't work that way, since the writes must land in her repo. C2-21 changes the two steps for a
project of `kind: "library"` only:
- The raise check. `#giteaRole` (`broker.mjs:2122-2135` at aca16404c) refuses `gitea-not-declared` for
any creative role, whatever its definition declares (mos-1163). F40 narrows that ruling. A creative
role passes only when every project the raise names is `kind: "library"`. A raise for a creative role
that names any other project still refuses `gitea-not-declared`, so a creative seat never gets a fork.
The other checks stay: the definition's gitea credential, `identity.gitea` and a non-empty `publish`,
which C2-08b supplies. Mos ruled the narrowing in mos-1320 item 2: mos-1163 still covers every gitea
project except `kind: "library"`, and covers host grants whole. The host grant writer, the executor's
step 1 recheck and `host.run` keep refusing a creative role with `host-creative-role`. The comment at
`broker.mjs:2120` cites mos-1320.
- Step 4 adds the seat's user as a collaborator on the project's own repository with the admin token,
`PUT /repos/{owner}/{repo}/collaborators/{user}`, in place of a fork. The permission is `write` for a
`CREATIVE_DEFINITIONS` instance and `read` for any other, the chief included. The definition decides
it, and the raise can't name one.
- Step 5 accepts that repository in the reach list when its `permissions` show exactly that level, with
`admin` false. Anything wider still refuses `gitea-reach-wider`.
- The rendered block names the repository and the level instead of a fork URL, and adds the line "The
token can write every path of this repository. Only the bus's path check limits it to supporting
folders."
- A revoke deletes the token as today, which ends the write. The collaborator entry stays, as forks do.
A fire's delete sets `prohibit_login` as today.
Two facts aren't proven yet, and C2-21's first test proves them on a scratch Gitea 1.27.1 before any code,
as F1 to F11 were: that the admin token's scopes (436b 3b) can add a collaborator to a repository the
admin account doesn't own, and that `/user/repos` with the seat token shows that repository with the
granted `permissions`. If either fails, C2-21 stops and goes to Mos.
The mirror's fetch uses the chief's token, whose grant is `read`, and the chief's user in the askpass
helper. That replaces the bot slot `library-gitea` from C2-03a. C2-03b makes the change. The fetch falls
back to slot `library-gitea` only while the bus has never recorded a gitea grant event for the chief.
The check reads the append-only events table (`packages/bus/schema.sql:131-132` at aca16404c) for any
`GRANT_EVIDENCE` event (`broker.mjs:246`) with `method` `gitea` and `agent` the chief. It never reads
`gitea_grants`, whose row a revoke and a grant-lost delete (`broker.mjs:2785`, `:2609`), and which has
no state column (`schema.sql:469-480`). It names no project, since `grant.applied` and `grant.revoked`
carry none (`:2469`, `:2787`), so a chief grant on any project ends the fallback, which fails safe
(webby-0156 R4). An install that runs before C2-21 still syncs. Once such an event exists, a revoked
or `held` grant, or a fired chief, refuses the fetch with `library-fetch-no-grant`, and `library.status`
shows it. A write, a revert and a save never fall back to the slot. When `library.status` shows the fetch
on the chief's grant, Jason deletes slot `library-gitea` (10 step 4b, webby-0151 R3).
Her edits (F41). She edits in the Console. Each save is one commit by the same steps, through the human
verb `library.file.save {path, base, content}`. The author is a new `editor` object in the library entry,
`{name, email}`, which the setup sheet asks her for, and the push uses her own Gitea token from a new slot
`library-human` (mos-1320 item 3). A save or a revert with no `editor` refuses `library-mapping-invalid`.
One with slot `library-human` missing or failing the helper's file checks refuses `library-human-missing`
before any git call. Her saves reach any mapped `.md` file. M12's other path rules hold for them, and only
the supporting rule doesn't, since the files are hers. They still refuse
`stale-base` when the file moved, because an agent write or an Obsidian push can race her save. She sees
an agent's write at once in the Console, and Obsidian sees it on its next pull. Agents see her save at
once too, since the bus moves the served ref after its own push. Her Obsidian pushes reach them through
the 60-second sync. The editor is a Q195 add-on, so C2-11a and C2-11b are slice rows that don't gate
10-27.
Rows (F42). C2-02, C2-03b and C2-11b join the slice, and C2-11a with them, since C2-11b needs it. Sage
queues them. C2-07 folds into C2-06. C2-17 drops. Five new rows carry what landed or built rows can't:
C2-01d, the `supporting` list and scope, C2-05c, the `supporting` scope and `blob` in the lookups,
C2-08b, the role changes, C2-12b, the setup sheet rewrite, and C2-21, the grant change. C2-05b is row
224, built to its earlier brief, and it lands as built (sage-2833). Its tools already match M3 below.
Section 8 lists them. Mos asked for a PRD row too: REQ-LIB-6 rewritten to Q223 and Q226, before C2-01d
and C2-02 (mos-1320 item 8).
M3 (mos-1308, Mos point 8). A creative session's typed tools are exactly the five lookups,
`send_message`, `library_doc_write` and `library_doc_delete`. `companion` and `research` also get
`web_search` and `web_fetch` through the existing web grant. So the per-role snapshot holds eight names,
or ten for those two. mos-1308 said six or eight, before Q226 added the two write tools, and Mos
accepted eight and ten in mos-1320 item 4. None of `read_spec`, `list_agents`, `inbox`, `trail`,
`write_record`, `raise_decision` or a tracker read reaches a creative session.
`packages/harness/src/tools.mjs` at aca16404c adds all but `raise_decision` to every session, and
already skips `raise_decision` for a creative definition (`:241`, webby-0151 N3).
Residual. A supporting doc can hold any text, a drafted scene included, and code can't tell a scene from
a note. Every agent write is visible as a notice and a trailer, and she can revert it. Nothing an agent
writes lands in a story path (M12). At Gitea the seat token can write any path of her repo, and the bus's
check is the only path limit. The token never leaves the bus, so the limit holds while the bus code does.
C2-21's scratch Gitea run also records two facts that don't gate it: whether a protected file pattern
refuses a seat user's push to a story path while her push still passes (mos-1320 item 5), and whether
branch protection refuses a seat user's force-push while her normal push passes (webby-0151 N4). If the
first works, Mos files a task for it as a second fence after the slice. The second would back D6 at the
remote and wouldn't narrow the paths.
## Changes from r10
Webby returned r10 with B1-B3 and N1-N5 (webby-0036, `agents/webby/work/case2-r10/REVIEW.md` at
@@ -407,7 +692,8 @@ the line says so.
- B4. On a library business the conversation reader leaves creative sessions out of `catalogue()` and
refuses to open one by id (5.1 M7, C2-10a in the slice).
- B5. At most 10 items and 1,200 characters of item text per reply. An `idea` item is at most 200
characters, at most 5 per reply. Other kinds are at most 300 (5.1).
characters, at most 5 per reply. Other kinds are at most 300 (5.1). Q219 later set ideas to at most 3
of 120 characters (ruling after r11), and Q222 made every limit a setting with those defaults.
- B6. `correction.from` must equal one cite's `quote`, inside that cite's lines (5.2).
- B7. The broker keeps fetched page text per session, bounded and in memory, not in a table. The bundle
refuses the `harness-builtin` web source for creative definitions (5.2, 5.3).
@@ -441,10 +727,12 @@ the line says so.
Melanie's writing already lives in a git repository, her books repo on git.mosaicstack.dev, and her
Obsidian syncs it with obsidian-git. The hub reads that repo. The bus keeps a bare mirror of it, fetched
with a read-only token, and the business file maps her folders onto books, types, canon and playground.
Agents can't write to it at all. They read it through typed lookup tools. Every message an agent sends
her must be a short list of items, each item must cite her repo or a page the agent fetched, and the
broker checks each citation before it stores the message. Each creative agent holds its own thread.
with the chief of staff's read token, and the business file maps her folders onto books, types, canon,
playground and supporting folders. Agents never write her story. They read it through typed lookup
tools, and they may create, edit and delete `.md` files only in folders she marked as supporting. Each
such write is a commit she sees as a notice and can revert. An agent's reply is free text. Any citation
it carries must be real, and the broker checks each one before it stores the message. Each creative
agent holds its own thread.
Bean and Chet, her two agents from Hermes, keep their names and personalities as Mosaic agents, and the
specialist roles sit beside them. She talks to them in the Console and sees only stored messages that
passed the guard. Hermes keeps running until she has moved over.
@@ -454,14 +742,16 @@ Decisions (D-codes are this document's own):
- D1. Her repo stays as it is. The design adapts to her layout through a mapping in the business file,
which agents can't write.
- D2. Scope comes from the path. No file content can change a file's scope.
- D3. Agents get no write path into her repo. That covers canon, playground and everything else.
- D3. Agents never write story. An agent writes only `.md` files under a supporting prefix, and M12
enforces that in code (ruling after r11, Q223, Q226). Canon, playground that isn't also supporting, and
everything else stay read-only to agents.
- D4. One chat is a project. Each creative agent holds its own thread in it. The `threads` table and its
triggers don't change (section 6).
- D5. In the slice the bus reads a bare mirror and never writes. Bus writes come later, through the
record repo commit path (`packages/project/src/index.mjs:195-234`).
- D5. The bus writes to her repo only as plumbing commits on the bare mirror, pushed fast-forward only:
an agent's supporting doc write, the human's revert and her own Console save (ruling after r11).
- D6. The Gitea remote is where the bus and Obsidian meet. The bus only fast-forwards and never merges.
- D7. Anything fetched from the remote is untrusted input. The bus reads it as blobs, never checks it out
in the slice, and never runs anything from it.
and never runs anything from it.
- D8. Every channel to Melanie shows only messages the broker stored after the guard. A channel that
shows a model's raw output is refused on a library business.
@@ -486,8 +776,10 @@ The mapping is a new form of business file project entry. Business files live in
"vaultPrefix": "<repo folder name under ~/src>",
"universes": {
"<u>": {"name": "<display name>", "books": ["<book folder>", "<book folder>"],
"playground": ["<book folder>/<playground folder>/"]}
"playground": ["<book folder>/<playground folder>/"],
"supporting": ["<book folder>/<notes folder>/"]}
},
"editor": {"name": "<her name>", "email": "<her email>"},
"types": {"<characters folder>": "character", "<locations folder>": "place",
"<research folder>": "research", "<tracker folder>": "tracker",
"<chapter summaries folder>": "summary"},
@@ -508,6 +800,12 @@ The mapping is a new form of business file project entry. Business files live in
cite check (5.2) has something to compare (ruling after r11, three levels). Her repo has no playground
today as far as I know. The setup sheet suggests one folder per book, for example
`<book>/playground/`. Until she makes one, everything mapped is canon.
- `supporting` is a second list of path prefixes in each universe entry (ruling after r11, Q223). An
agent may write `.md` files under a supporting prefix and nowhere else (M12). The prefix rules are the
playground's, and the refusal for another universe is `library-supporting-other-universe`. A prefix
may sit in both lists, and that folder is writable. A playground prefix alone isn't. Her repo has no
supporting folder until she names one, and until then agents write nothing.
- `editor` is the name and email her Console saves commit under (F41). The setup sheet asks her for it.
- `types` maps a type folder name to a type. A file's front matter `type`, if present, is shown next to
it but doesn't change scope.
@@ -527,14 +825,16 @@ Each path in her repo, relative to the repo root, gets one scope:
| Scope | Rule |
|---|---|
| `playground` | A `.md` file that starts with a playground prefix of any universe entry, drafts included |
| `supporting` | A `.md` file that starts with a supporting prefix of any universe entry, drafts included. This comes first, so a folder in both lists is supporting. Agents may write it (M12) |
| `playground` | Otherwise, a `.md` file that starts with a playground prefix of any universe entry, drafts included |
| `canon` | Otherwise, a `.md` file inside a configured book folder |
| `unmapped` | Everything else: top-level notes, `.base`, `.canvas` (under a playground prefix too), attachments, anything outside a book folder and outside every playground prefix |
| `unmapped` | Everything else: top-level notes, `.base`, `.canvas` (under a playground or supporting prefix too), attachments, anything outside a book folder and outside every playground and supporting prefix |
Lookups default to canon and return playground only on request. Unmapped files are never returned or
citable. The universe of a canon file is the universe whose `books` list holds its book folder. The
universe of a playground file is the entry whose prefix holds it, and its book is the book folder that
holds the prefix, or `null` at universe level and in a draft (ruling after r11, three levels).
Lookups default to canon and return playground or supporting files only on request. Unmapped files are
never returned or citable. The universe of a canon file is the universe whose `books` list holds its book
folder. The universe of a playground or supporting file is the entry whose prefix holds it, and its book
is the book folder that holds the prefix, or `null` at universe level and in a draft (ruling after r11,
three levels).
### 1.3 Reading and reporting
@@ -571,7 +871,7 @@ setup sheet says so (C2-12).
Why reporting is enough for separation. The guarantee Jason asked for is that agents keep playground and
canon apart. The code that does that is the scope rule, the lookups' default and the cite check in 5.2,
which refuses a playground cite on a `fact`, `finding` or `correction`. None of those needs her files to
which refuses a playground or supporting cite marked as canon. None of those needs her files to
pass a rule. Refusing her latest commit would only stop the agents seeing her newest work.
### 1.4 Hybrid crossover (after the slice)
@@ -590,9 +890,8 @@ C2-15 builds this.
### 1.5 Promotion (after the slice)
Promotion moves files from a playground prefix into a book folder. Only Melanie does it, in Obsidian
today. A Console verb `library.promote {from, to}` comes with the write rows (C2-19). It is human only,
moves every file in one commit through a multi-path form of `commitFile` (today that commits one path,
`index.mjs:218-219`), and pushes.
today. A Console verb `library.promote {from, to}` comes later (C2-19). It is human only and moves every
file in one plumbing commit, by the steps of the ruling after r11 with several paths, and pushes.
## 2. What exists and how Case 2 uses it
@@ -617,12 +916,11 @@ moves every file in one commit through a multi-path form of `commitFile` (today
sets `protocol.allow=never` (`git.mjs:8`), and `inspectGit` refuses any `remote.*` key (`git.mjs:44`).
The mirror keeps both rules. The URL goes on the command line from the business file, and fetch uses
its own command builder (7.2).
- Bus writes, later (C2-02): `commitFile` (`index.mjs:195-234`) handles the lock, `inspectGit`,
`requireClean`, the layout check, publish, commit and rollback. `writeHumanFile` (`index.mjs:248-261`)
is the model for `writeLibraryFile`. C2-02 refactors `commitFile` to take the check function (it calls
`checkProject` at `:209`) and a list of paths, and adds r1's S0 rule for files in the working tree.
- Bus writes (C2-02) don't use `commitFile` (`index.mjs:195-234`), which commits in a working tree. The
mirror has none (D7). C2-02 writes plumbing commits on the bare mirror by the steps of the ruling after
r11, under the library lock and after M12. `commitFile` and `writeHumanFile` stay as they are.
### 2.2 ProseMirror editor (`packages/webui/editor`), after the slice
### 2.2 ProseMirror editor (`packages/webui/editor`)
- It edits Markdown (`model.mjs:17-18`). Blocks it can't draw, front matter included, are kept byte for
byte (`model.mjs:3-11`, `:220-231`).
@@ -656,8 +954,9 @@ moves every file in one commit through a multi-path form of `commitFile` (today
- `typedTools` (`tools.mjs:91-261`) builds the typed tools per session from held actions. `write_record`
exists only for `record.write` holders (`:211-220`), and `send_message` for `message.send` holders
(`:95-102`). `raise_decision` goes to every agent (`:221`), with free-text `question` and option `text`.
- Case 2 adds the lookup tools (section 3) when the business has a library project, and drops
`raise_decision` for creative definitions (5.1 M6).
- Case 2 adds the lookup tools (section 3) when the business has a library project, adds the two write
tools for a creative session holding `library.write`, and drops `raise_decision` and the other
non-creative tools for creative definitions (5.1 M3, M6).
### 2.5 Threads, messages, conversations and Discord
@@ -670,9 +969,10 @@ moves every file in one commit through a multi-path form of `commitFile` (today
(`packages/bus/schema.sql:296-299`) and deliver only to the thread's holder (`:300-306`). `threads`
can't be updated (`:310`). Thread names are unique per project (`:249`).
- The runner sends the model's final text as a `message.send` RESULT to the sender
(`packages/harness/src/runner.mjs:446-450`). It sends `(the session gave no answer)` for an empty turn
and `The turn failed: <reason>` for a failed one (`:449`). A refusal is logged and the message is still
marked read (`:451-456`), so the answer is lost.
(`packages/harness/src/runner.mjs:571-574` at aca16404c). It sends `(the session gave no answer)` for
an empty turn and `The turn failed: <reason>` for a failed one (`:572`). A refused send is logged and
`message.read` is skipped (`:577-583`), so the message stays unread and the answer never reaches her
(webby-0151 N2).
- Sessions are per thread: `threadStore(dataRoot, business, thread)` (`packages/seat/src/session.mjs:59-64`)
and the Claude session id file under it (`adapters/claude/adapter.sh:47-53`). One thread per agent
means one session per agent.
@@ -696,8 +996,8 @@ reads the mirror at its current served commit (7.2) and returns that commit.
| Tool | Arguments | Returns |
|---|---|---|
| `library_find` | `universe` (required), `query`, `type?`, `book?`, `scope?` (`canon` default, or `playground`) | up to 50 `{path, type, name, appears_in, scope, universe, book}` |
| `library_get` | `path` | `{path, commit, scope, frontmatter, lines: [[n, text]], links_out, links_in}` |
| `library_find` | `universe` (required), `query`, `type?`, `book?`, `scope?` (`canon` default, `playground` or `supporting`) | up to 50 `{path, type, name, appears_in, scope, universe, book}` |
| `library_get` | `path` | `{path, commit, blob, scope, frontmatter, lines: [[n, text]], links_out, links_in}` |
| `library_search` | `universe`, `text`, `book?`, `scope?` | up to 50 `{path, line, excerpt, scope}` |
| `library_manuscript` | `universe`, `book`, `scope?` (`canon` default), `chapter?`, `from?`, `to?` | `{path, commit, scope, lines}`, one chapter file, at most 400 lines per call |
| `library_universe` | `universe` | its books, type folders, and for a hybrid the sources, constraints and notes |
@@ -712,22 +1012,32 @@ reads the mirror at its current served commit (7.2) and returns that commit.
Her repo has 203 Markdown files today. I estimate a rebuild well under a second, and C2-05a's gate
measures it on a 3,000-file fixture.
Writes. `library_doc_write {universe, path, base, content}` and `library_doc_delete {universe, path,
base}` call the agent verbs of the ruling after r11. Only a creative session holding `library.write` gets
them (M3). `base` is the `blob` that `library_get` returned, or `null` to create. Each returns `{commit,
path}` or a refusal code, and a successful write also stores the notice.
## 4. Creative roles, Bean and Chet
Six new role definitions. Each is a version 2 role file plus a `.md` contract.
| Role | Purpose | `tools` | Reply item kinds |
|---|---|---|---|
| `companion` | Her general writing partner. Backs Bean and Chet | `read`, `web` | `idea`, `question`, `finding`, `fact` |
| `ideas` | Brainstorming, mostly in the playground | `read` | `idea`, `question` |
| `research` | Real-world facts from the web | `read`, `web` | `fact`, `question` |
| `proofreader` | Spelling, grammar, punctuation | `read` | `correction`, `question` |
| `fact-checker` | Text against canon and her research notes | `read` | `finding`, `question` |
| `continuity` | Consistency across books, timeline and her tracker | `read` | `finding`, `question` |
| Role | Purpose | `tools` |
|---|---|---|
| `companion` | Her general writing partner. Backs Bean and Chet | `read`, `web` |
| `ideas` | Brainstorming, mostly in the playground | `read` |
| `research` | Real-world facts from the web | `read`, `web` |
| `proofreader` | Spelling, grammar, punctuation | `read` |
| `fact-checker` | Text against canon and her research notes | `read` |
| `continuity` | Consistency across books, timeline and her tracker | `read` |
All six have `authority: {withinRole: ["message.send"], crossRole: []}`, `credentials: []`, and
`messaging: {sendTo: ["human"], receiveFrom: ["human"]}`, so they can't relay through each other.
`network` is `none`, except `api-only` for `companion` and `research`.
All six have `messaging: {sendTo: ["human"], receiveFrom: ["human"]}`, so they can't relay through each
other. `network` is `none`, except `api-only` for `companion` and `research`. C2-08 landed them with
`authority: {withinRole: ["message.send"], crossRole: []}` and `credentials: []`. C2-08b changes both,
which is a role change Mos authorized in mos-1309 item 5: `withinRole` becomes `["message.send",
"library.write"]`, and `credentials` becomes `[{"service": "gitea"}]`, the definition credential the
grant path needs (`business.mjs:132-167`). Each creative instance also gets the `identity` and `publish`
fields that path reads. `roles/chief-of-staff.json` gets the same credential and no `library.write`, so
the chief can hold the read grant the mirror fetches with.
Bean and Chet. Jason's ruling (Q180): "She will want to keep those agent names and personalities." They
are two business role instances, `bean` and `chet`, both with definition `companion`. The personas are
@@ -844,12 +1154,13 @@ wins on any conflict, and the broker checks every reply whatever the persona say
It is fenced the way skills are (`SKILL_END`, `bundle.mjs:107`, `:170`). The bundle refuses
`persona-invalid` for text that holds `</persona-file>` or has a line starting `# ` or `## `, so the text
can't close its fence or open a section of its own. C2-08's snapshot pins contract, persona, session, in
that order. The order is a hint to the model. The enforcement is the broker guard in 5.2, which doesn't
read the persona, so no persona text can lift a guard.
that order. The order is a hint to the model. The enforcement is in code, M12 on writes and the
citation guard in 5.2, and neither reads the persona, so no persona text can lift a guard.
What changes for her. In Hermes, Bean and Chet answer in free text and may write story text when she
asks. In Mosaic they answer in short cited items, and a request for prose gets a `question` or an `idea`
item, not a passage. That follows from Jason's "NEVER" and Q176. She should hear it before the move, not
What changes for her. In Hermes, Bean and Chet answer in free text and may write story text into her
files when she asks. In Mosaic they still answer in free text, of any length, and they never write into
her story files. Whatever they write lands in a supporting folder she marked, as a commit she sees and
can revert. That follows from Jason's "NEVER" as Q223 reads it. She should hear it before the move, not
discover it (10 step 7).
The five specialists stay available as further instances. Whether the slice starts with Bean and Chet
@@ -873,64 +1184,73 @@ New constants in `vocabulary.mjs`, each changed only by a reviewed commit:
as needed; unless user forbids per agent"). If Melanie wants it forbidden for an agent, the request
comes through Jason (mos-1038).
- `CREATIVE_DEFINITIONS`, the six above.
- `CREATIVE_KINDS`, mapping each definition to the item kinds in the table.
- `CREATIVE_TOOLS = ["read", "grep", "find", "ls", "web"]`. `validateRoleDocument` refuses a creative
definition whose `tools` include anything else, whose authority holds anything but `message.send`, that
has no `messaging` key, or whose `messaging` names anything but `human` (N6).
definition whose `tools` include anything else, whose authority holds anything but `message.send` and,
after C2-08b, `library.write`, that has no `messaging` key, or whose `messaging` names anything but
`human` (N6).
- C2-08 also landed `CREATIVE_KINDS`. C2-08b removes it, since replies have no item kinds now.
## 5. Guardrails enforced in code
### 5.1 Agents never write prose for the user
### 5.1 Agents never write story
| Mechanism | What it refuses | Test |
|---|---|---|
| M1 role validator (`role.mjs`, `CREATIVE_TOOLS`) | A creative role file with `write`, `edit` or `bash`, any authority but `message.send`, or a missing or wider `messaging` | C2-08: each rejected variant refuses with its message |
| M1 role validator (`role.mjs`, `CREATIVE_TOOLS`) | A creative role file with `write`, `edit` or `bash`, any authority but `message.send` and `library.write`, or a missing or wider `messaging` | C2-08: each rejected variant refuses with its message |
| M2 tool gate (`gate.mjs:137-138`, unchanged) | `Write`, `Edit` or `Bash` from a creative session | C2-08: `decide()` with each creative policy refuses all three, on pi and claude-code |
| M3 typed tool set (`tools.mjs`) | The tool list is exact: lookups and `send_message`. No write tool, no `raise_decision` | C2-05b: a snapshot of tool names per creative role |
| M4 library verbs are human only | `library.file`, `library.sync` and `library.status` from an agent session, with `human-required` as at `broker.mjs:1434-1435`. Later write verbs join the list | C2-04 and C2-03a: each verb from an agent session refuses |
| M5 cited-reply guard (5.2) | A reply that isn't a list of short cited items. At most 10 items and 1,200 characters of item text per reply. An `idea` item is at most 200 characters, at most 5 per reply. A `fact`, `finding`, `correction` or `question` item is at most 300. A `correction`'s `to` is within edit distance max(12, 25% of `from`) of its `from` | C2-06: each limit refused one past the boundary and accepted at it |
| M3 typed tool set (`packages/harness/src/tools.mjs`) | The tool list is exact: the five lookups, `send_message`, `library_doc_write` and `library_doc_delete`, plus `web_search` and `web_fetch` for `companion` and `research` (mos-1308, ruling after r11). No `raise_decision`, `read_spec`, `list_agents`, `inbox`, `trail`, `write_record` or tracker read | C2-05b: a snapshot of tool names per creative role (row 224, six or eight). C2-02 adds the write tools to it: eight names, or ten for `companion` and `research` |
| M4 human verbs and agent verbs | `library.file`, `library.file.save`, `library.sync`, `library.status`, `library.limits.set` and `library.revert` from an agent session refuse `human-required`, as at `broker.mjs:1434-1435`. `library.doc.write` and `library.doc.delete` refuse every caller but a creative session holding `library.write` and a live grant | C2-04, C2-03a, C2-06 and C2-02: each human verb from an agent session refuses, and each agent verb from the human, the tester, the chief of staff and a creative session without a grant refuses |
| M5 citation guard (5.2) | A cite that fails a check in 5.2, whenever a reply carries one. More than `citationsPerReply` citations. A reply with none when `requireCitation` is true. Both keys are `replyLimits` settings read at check time (Q222, Q226), and a missing or invalid one uses its default. An unreadable business file resolves `requireCitation` to true | C2-06: each check refused once and passed once. `citationsPerReply` refused one past the limit and accepted at it. `requireCitation` true refuses an uncited reply and false accepts it. A changed value takes effect on the next reply. Each invalid value uses the default, `"true"`, `1`, `null`, `[true]`, `{}`, `0`, `1001`, `2.5`, `true` for the count and `"5"` included. With the business file unreadable, a reply with 21 citations refuses, an uncited reply refuses `citation-missing`, and `messages` is unchanged in both |
| M6 no free-text decision channel | `decision.raise` from a creative definition refuses `decision-not-allowed` in the broker. The typed tool is also absent (M3) | C2-08: a creative session's raise refuses and no decision row appears. An assistant's raise still works |
| M7 no raw session view | On a business with a library project, the conversation reader leaves creative sessions out of `catalogue()`, and `open()` refuses them with `conversation-not-listed` | C2-10a: a creative session file under the root isn't listed and won't open by id. An assistant session still lists |
| M8 no raw Discord engine | On a business with a library project, `discord check` and `discord run` refuse a binding whose engine isn't `mosaic-bus` (`discord-engine-not-allowed`), and a `mosaic-bus` binding with `tools` | C2-10a: both refuse on a library business. A pi binding on a business without a library still checks clean |
| M9 no agent write to the mapping | An organization write whose `projects` differ from the file's refuses `organization-projects-protected`, in `organizationWriter.write` | C2-04: `write` called directly with a changed playground prefix, and again with a changed hybrid entry, refuses, and the file and `changes.jsonl` are unchanged |
| M10 personas and creative instances are the human's | Any write by an actor other than the business file's `human` that touches a creative instance: the chief of staff's `assistants`, `instances` and `models` keys through `organization.set`, and the tester's `instances.set` and `routes.set`. A persona file that fails its digest, mode or fence check. A harness argv that holds the prompt text | C2-08: each path refuses with its code (4) and the file is unchanged. The human's `instances.set` and `routes.set` on `bean` work, and so does her `limits.set` change to Bean's `launch.models` key. Tester cases through a bound tester session. A mutant without the `models` check fails. No `instances` read, `changes.jsonl` line or adapter argv holds a marker string from a fixture persona |
| M11 reply settings are the human's | `library.limits.set` from an agent session (`human-required`) or the tester (`tester-refused`). Any organization write whose `replyLimits` differs from the on-disk file's refuses `organization-reply-limits-human-only` in `organizationWriter.write`, on `document` and on `written`, unless the actor is the on-disk `human` and not an agent actor shape | C2-06: the human's `library.limits.set` works. The chief of staff's `organization.set`, the tester's `instances.set` and a direct `write` with a changed `replyLimits` refuse. A direct `write` that changes both `human`, to its own actor, and `replyLimits` refuses. With a dotted human id equal to a role's actor, that role's write refuses. `library.limits.set` with an unknown key refuses `invalid-request`. In each refusal the file and `changes.jsonl` are unchanged. A mutant without the writer check fails, and so does one that compares `document.human` |
Residual, in numbers. One reply can carry up to 5 `idea` items of 200 characters, which is 1,000
characters of free suggestion text inside a total of 1,200 characters of item text. Each item must quote
a real line of her repo, but the quote can be any line. Over a chat that adds up. Code can't tell a
sentence of story from a sentence of advice, and this design doesn't propose a classifier. Jason wrote
"NEVER", and Q176 says the system enforces it, so the residual is his or Melanie's to accept (10 step
8). Lower numbers are a constant change: 3 ideas of 120 characters would make the residual 360. What
lands in her repo stays hers alone. M2 to M4 make an agent write there impossible.
| M12 agents write supporting docs only | `supportingPath` refuses `library-write-story-path` on `library.doc.write` and `library.doc.delete` for every path class the ruling after r11 lists, before any git call. A write commits only by the plumbing steps, pushes fast-forward only and carries the `Mosaic-Agent` trailer | C2-01d: one refusal per path class against a fixture tree with a symlink, a gitlink and a case variant. C2-02: each refusal through the broker leaves the remote ref, the served ref and the mirror's refs unchanged. One write and one delete land, each with one parent, the seat's identity and the trailer. A moved tip with an unchanged blob re-applies and a changed blob refuses `stale-base`. A mutant without the supporting check fails |
Residual. The ruling after r11 states it. A supporting doc can hold any text, and the seat token's reach
at Gitea is wider than M12. Chat has no length or kind limit now (Q223), so the 360-character residual of
Q219 is gone with the caps. What lands in her story files stays hers alone. M2, M4 and M12 make an agent
write there impossible through the hub.
### 5.2 Agents always refer to the source material
The cited-reply guard runs in the broker. It covers every message from an agent session to `human` in a
The citation guard runs in the broker. It covers every message from an agent session to `human` in a
business with a library project, from the runner's RESULT and from `send_message` alike. It doesn't look
at the sender's definition, so a misconfigured role can't skip it. Citation checks need git, so they run
before the insert, outside the SQL transaction, as `#requestProject` does (`broker.mjs:1378-1430`).
Every channel to Melanie shows stored messages only (D8), so this one check covers the Console, Discord
and any later channel.
The reply body is JSON:
The reply body is free text, or a JSON object when the reply carries citations:
```json
{"items": [{"kind": "finding", "text": "Mara's eyes are grey in chapter 3 but green on her character page.",
"cites": [{"path": "<book>/<chapters>/03.md", "commit": "<40 hex>", "lines": [41, 41], "quote": "her grey eyes"},
{"path": "<book>/<characters>/Mara.md", "commit": "<40 hex>", "lines": [9, 9], "quote": "green eyes"}]}]}
{"text": "Mara's eyes are grey in chapter 3 but green on her character page.",
"citations": [{"path": "<book>/<chapters>/03.md", "commit": "<40 hex>", "lines": [41, 41], "quote": "her grey eyes", "as": "canon"},
{"path": "<book>/<characters>/Mara.md", "commit": "<40 hex>", "lines": [9, 9], "quote": "green eyes", "as": "canon"}]}
```
A body that parses as a JSON object must fit this schema: `text` a string, `citations` a list of library
cites `{path, commit, lines, quote, as?}` or web cites `{url, quote}`, and no other keys. Any other body
is a reply whose text is the whole body and which carries no citation. `as` is optional and its one value
is `"canon"`, which says the agent states the cited line as fact about her world.
| Check | Refusal code |
|---|---|
| The body parses as the schema, within the M5 limits | `cited-reply-required` |
| The item kind is allowed for the sender's definition (`CREATIVE_KINDS`). A sender outside `CREATIVE_DEFINITIONS` may send only `question` and `finding` | `reply-kind-not-allowed` |
| Every item has at least one cite | `citation-missing` |
| The path is canon or playground (not unmapped) and indexed, it exists at `commit`, `commit` is reachable from the served commit, the lines are in range, and `quote` occurs in those lines after whitespace normalization | `citation-invalid` |
| A `correction`'s `from` equals one cite's `quote`, after the same normalization, inside that cite's lines | `correction-unbound` |
| `fact`, `finding` and `correction` cite only canon paths | `playground-cited-as-canon` |
| All library cites in an item are in one universe, or a hybrid plus its sources | `citation-cross-universe` |
| A JSON object body fits the schema | `reply-invalid` |
| `requireCitation` is true and the reply carries no citation | `citation-missing` |
| The reply carries at most `citationsPerReply` citations | `citations-over-limit` |
| The path is canon, playground or supporting (not unmapped) and indexed, it exists at `commit`, `commit` is reachable from the served commit, the lines are in range, and `quote` occurs in those lines after whitespace normalization | `citation-invalid` |
| A cite with `"as": "canon"` names a canon path | `playground-cited-as-canon` |
| All library cites in a reply are in one universe, or a hybrid plus its sources | `citation-cross-universe` |
| A `url` cite is allowed only for `research` and `companion`, only for a URL this session fetched through the broker, and its quote must occur in the kept text of that fetch | `citation-unfetched` |
The guard doesn't read what the text says, only its citations (Q223). The check result stored with the
message names each library cite's scope and whether the file's last commit at `commit` carries a
`Mosaic-Agent` trailer, so the Console can label it agent-written (7.4).
Fetched text (B7). The web trail keeps the URL (`broker.mjs:1521`) and a summary of status, bytes and
redirects (`packages/bus/src/web.mjs:46`), not the page. Case 2 adds a page store in the broker's memory,
keyed by session: the text of the last 20 broker fetches, each capped at 200 KB, dropped when the session
@@ -939,21 +1259,42 @@ runner retries, and the agent fetches again. Fetches through the harness's own w
broker, so `webPlan` (`bundle.mjs:28-35`) refuses the `harness-builtin` source for a creative definition
(`web-source-not-allowed`).
Notices (N3). The guard accepts exactly three bodies with no items: `{"items": [], "notice": "no-cited-answer"}`,
`{"items": [], "notice": "no-answer"}` and `{"items": [], "notice": "turn-failed"}`. Any other notice
value is refused. In a library business the runner sends `no-answer` and `turn-failed` instead of its two
texts at `runner.mjs:449`. The failure reason stays in the run log.
Runner texts. The runner's two fixed texts at `runner.mjs:572` (aca16404c) are plain text, which the
guard accepts while `requireCitation` is false. In a library business the runner sends `(the session
gave no answer)` and `(the turn failed)`, with the reason only in the run log, and the guard accepts
exactly those two bodies, and the two retry texts below, when `requireCitation` is true. An agent that
sends one of them gains nothing it couldn't say anyway.
Runner retry (C2-07). When `message.send` refuses with one of the codes above, the runner starts a new
turn in the same session with the code and reason, at most twice. Then it sends `no-cited-answer`. Today
the answer would be dropped (`runner.mjs:451-456`).
Runner retry (C2-06, which takes in C2-07). When `message.send` refuses with a code from the table, the
runner starts one new turn in the same session with the code and reason. If that reply also refuses, it
sends `(no answer passed the citation check)`. Today a refused send is logged and `message.read` is
skipped, so she gets no answer (`runner.mjs:577-583` at aca16404c). A send that refuses
`request-too-large` at the client (`packages/bus/src/client.mjs:19-20`), before the broker, gets the same
one retry, and the new turn names the 64 KiB limit. If that reply is still too long, the runner sends
`(the answer was too long to send)`. If it refuses with a code from the table, the runner sends the
citation text. Only one retry runs per answer (webby-0151 R1).
The unslop note and the clip. The runner appends `unslop.note()` to every RESULT body, and `clip` cuts a
body over 32,000 characters (`runner.mjs:130-131`, `:572-573` at aca16404c). Either one turns a JSON body
into plain text and drops its citations, and either one breaks the fixed texts. In a library business
C2-06 changes both. A body that parses as a JSON object gets the note added to the end of its `text` and
is serialized again, and is never clipped. A body too long for the 64 KiB request limit gets the retry
above. A fixed text goes out with no note, and the note waits for the next
reply, as it does after a failed send. A plain body keeps today's clip and note.
Tests (C2-06): one passing and one refused case per row of the table, through both the runner's RESULT
path and `send_message`, and once with a non-creative sender in a library business (N8). The fixture is
a repo the test builds in her layout's shape, with spaces and capitals in folder names, a `.canvas` file
and an unmapped top-level note. Each refusal must leave the `messages` count unchanged. C2-07 adds a
runner test with a fake harness that answers free text first and then a valid reply, and asserts one
stored message.
path and `send_message`, and once with a non-creative sender in a library business (N8). Plain text
passes with `requireCitation` false and refuses with it true. The fixture is a repo the test builds in
her layout's shape, with spaces and capitals in folder names, a `.canvas` file, a supporting folder and
an unmapped top-level note. Each refusal must leave the `messages` count unchanged. A runner test with a
fake harness that answers with a bad cite and then a valid reply stores one message. One that refuses
twice stores the retry's fixed text. With `requireCitation` true, an empty and a failed turn store their
fixed texts. With a flagged unslop event, a cited reply is stored with its citations whole and the note
at the end of `text`, a fixed text is stored with no note, and the next reply carries it. A cited reply
over 32,000 characters isn't cut. A cited reply over 64 KiB, then a valid one, stores the second. Two
replies over 64 KiB store `(the answer was too long to send)`, and with `requireCitation` true the guard
accepts it. With the business file unreadable, an uncited reply refuses `citation-missing` and
`messages` is unchanged.
### 5.3 Tool use is guarded
@@ -966,6 +1307,9 @@ stored message.
- `research` web use goes through the broker's web tools (`tools.mjs:197-206`), which check the grant on
every call (`broker.mjs:1516`).
- Discord: the relay has no tools of its own, and M8 refuses one that does.
- Writes: M12 and the grant check on `library.doc.write` and `library.doc.delete`. The broker re-checks
every call, so a session whose grant was revoked gets `library-write-no-grant` from a tool it still
holds.
## 6. One chat with N agents and @tag addressing
@@ -1009,17 +1353,19 @@ the first word is plain text. `room.open` against a same-name thread on another
### 7.1 Obsidian
She has it working already. `~/src` is her vault, the books repo is a folder in it, and obsidian-git
pushes it to git.mosaicstack.dev (mos-1000). The hub changes nothing on her machine and commits nothing
to her repo. The setup sheet (C2-12) tells her which folders the agents read, that a folder named in
`playground` is her playground, and how to add a graph colour group for that path in her own vault
settings if she wants one. The graph view and backlinks stay Obsidian's own. The sheet also says two
pushes it to git.mosaicstack.dev (mos-1000). The hub changes nothing on her machine. It commits to her
repo only an agent's supporting doc write, her revert and her own Console save, and obsidian-git pulls
each on its next pull. The setup sheet (C2-12, rewritten by C2-12b) tells her which folders the agents
read, that a folder named in `playground` is her playground, that a folder named in `supporting` is where
agents may write, and how to add a graph colour group for those paths in her own vault settings if she
wants one. The graph view and backlinks stay Obsidian's own. The sheet also says two
things she might not expect. Moving a note into a book folder makes it canon, so agents may cite it as
fact (N2). And a short link to a name that also exists in another of her repos can point somewhere else
for the agents than in Obsidian. A full-path link avoids that (N4, 1.3).
### 7.2 Sync (bus side)
One function, `syncCommand(kind, url)`, builds every fetch (and later push). Its argv is `SAFE`
One function, `syncCommand(kind, url)`, builds every fetch and push. Its argv is `SAFE`
(`git.mjs:7-9`), then `-c protocol.https.allow=always`, `-c http.followRedirects=false` and
`-c credential.helper=`, then `-C <mirror>`, then the command with the URL as an argument. Its env is
`gitEnv()` plus `LC_ALL=C` and `GIT_ASKPASS` naming a helper script outside the mirror, which reads the
@@ -1044,8 +1390,12 @@ written atomically, so it survives a bus restart. The human verb `library.status
`library.sync` runs on a 60-second timer in the bus, and before any lookup that finds the last fetch
more than 60 seconds old.
Later (C2-03b, with C2-02): push of bus commits, the push race retried once, `unpushed`, and
`library-remote-invalid` refusals of bus writes (B9).
C2-03b adds the push for C2-02's commits, the one race retry, `library-push-failed`, and
`library-remote-invalid` refusals of writes (B9). It also changes whose token the helper answers with: the
chief of staff's grant token and Gitea user for the fetch, in place of slot `library-gitea`, the writing
seat's own grant token for its write, and slot `library-human` for her save and revert (ruling after
r11). The fetch falls back to slot `library-gitea` only while the events table holds no gitea grant
event for the chief, and nothing else ever reads that slot. There is no `unpushed` state.
Tests (C2-03a): `syncCommand` argv equals `SAFE` plus the fixed sequence above, with no other
`protocol.*` key. Its env holds `LC_ALL=C` and `GIT_ASKPASS` pointing outside the mirror. The helper
@@ -1054,30 +1404,37 @@ refuses a wrong host, a wrong port and another user. A 302 to another host fails
a bare repo: fast-forward, R-problems recorded without holding the head, `library-diverged` kept until
`--accept`, a failed fetch kept serving. A mutant that sets `protocol.allow=always` fails the argv test.
### 7.3 Console WYSIWYG (after the slice)
### 7.3 Console WYSIWYG (slice rows that don't gate 10-27)
- Library pages (C2-11b) show a tree of universes, books, type folders, manuscript and playground, with
the playground in its own section and colour. They open files in the existing editor through
`library.file` and `library.file.save`, with `base` as today. Saving needs the write rows.
- Library pages (C2-11b) show a tree of universes, books, type folders, manuscript, playground and
supporting folders, the last two each in their own section and colour. They open files in the existing
editor through `library.file`. Each save is one commit through `library.file.save {path, base,
content}` (ruling after r11, F41). A file that moved refuses `stale-base`, and the page keeps her text
and offers a reload.
- Wikilinks render as links in the editor (C2-11a). Clicking one opens the target.
- Promote (C2-19) and crossover (C2-15) buttons.
- Promote (C2-19) and crossover (C2-15) buttons come later.
### 7.4 Console chat (C2-10b)
- The chat view renders a cited reply as items. Each item shows its kind, its text and its cites as
path, lines and quote, with a scope badge (canon, playground or web). It marks a cite "changed since"
when the file's last commit is newer than the cited one. A notice shows a fixed line ("No cited
answer", "No answer", "The turn failed").
- The chat view renders a reply as its text, then its citations as path, lines and quote, each with a
scope badge (canon, playground, supporting or web). A cite whose file's last commit carries a
`Mosaic-Agent` trailer gets an agent-written label (Mos point 3). It marks a cite "changed since" when
the file's last commit is newer than the cited one.
- A `library-write` notice shows a fixed line, "<name> wrote <path>" or "<name> deleted <path>", with the
commit and a Revert button that calls `library.revert`.
- A status line reads `library.status`: the served commit and its age, `library-diverged`, a fetch
error, and the count of R-problems with a link to the list.
- `renderItems(body)` is one pure function in `packages/library`, shared with the Discord relay.
- `renderReply(body)` is one pure function in `packages/library`, shared with the Discord relay. It
replaces C2-01's `renderItems`.
### 7.5 User and agent sync
- Agents read the served commit through the broker. They never have a private copy (2.1).
- Every reply cites a commit, and the Console marks stale cites.
- Her push is visible to agents within about 60 seconds.
- The bus token is read-only on the books repo in the slice. Nothing the hub does can change her repo.
- Agents write only supporting docs, each a commit with a notice she can revert. Her Console saves are
commits too. Obsidian sees both on its next pull. A race with her Obsidian push refuses `stale-base`
and never merges (D6).
### 7.6 Discord relay (C2-18, after the slice, optional)
@@ -1088,13 +1445,14 @@ stays and its engine changes, as below.
- A new engine module, `engine-bus.mjs`, next to `engine-pi.mjs`. Binding field `engine.provider:
"mosaic-bus"`, plus a map from each channel id to one thread id. `prompt(text)` calls `thread.send` on
the bound thread with her message as the body. It then waits for the next message in that thread from
the instance to `human`, up to the binding's turn timeout, and returns `renderItems(body)` as `text`.
the instance to `human`, up to the binding's turn timeout, and returns `renderReply(body)` as `text`.
The connector's loop, journal, delivery and authorization stay as they are (`connector.mjs:381-387`).
- The relay posts only bodies the broker stored, and those passed the guard (D8). It never sees a model's
output. A timeout posts the fixed line "No answer yet. It will appear here when it arrives", and a later
poll posts the stored reply.
- Rendered items can pass Discord's 2,000-character limit, because cites add to the 1,200 characters of
item text. The relay splits at item boundaries, each part with its own nonce.
- A rendered reply can pass Discord's 2,000-character limit, since replies have no length cap now. The
relay splits at paragraph boundaries, and a paragraph over the limit at line and then character
boundaries, each part with its own nonce.
- On each change of `library.status`, the relay posts one line to her channels: diverged, a fetch error,
a new R-problem count, or "in sync again".
- M8 refuses any other engine on a library business, and a `mosaic-bus` binding that sets `tools`.
@@ -1105,11 +1463,11 @@ stays and its engine changes, as below.
Tests (C2-18), against a fake bus and fake REST, with no Discord server:
- A posted Discord message equals `renderItems` of the stored body, byte for byte.
- A posted Discord message equals `renderReply` of the stored body, byte for byte.
- A refused agent reply (nothing stored) makes the relay post nothing but the fixed notice the runner
then stores.
- A pi-engine binding and a relay binding with `tools` both refuse on a library business.
- A long reply splits at item boundaries, and no part goes over 2,000 characters.
- A long reply splits at paragraph boundaries, and no part goes over 2,000 characters.
- Each `library.status` change posts exactly one line.
## 8. Row list
@@ -1119,7 +1477,8 @@ work as Sage-dispatched subagents (lead decision 129). Codex gpt-6.1-sol (Rocko)
gpt-6-luna takes low-level work. An independent reviewer (Filbert, Dewey or Webby) reviews every row.
Each gate is its package's changed test files run singly plus the full gate before landing. No test may
touch `~/.mosaic-dev`, `~/.config/mosaic-dev`, live units, slot files, her repo or any Discord server.
Chains follow Q177 merge-chaining. S marks a slice row.
Chains follow Q177 merge-chaining. S marks a slice row. C2-11a and C2-11b are slice rows that don't gate
10-27 (Q195).
| Row | S | Scope | After | Owner | Test gate |
|---|---|---|---|---|---|
@@ -1128,36 +1487,41 @@ Chains follow Q177 merge-chaining. S marks a slice row.
| C2-04 | S | Business file `kind: "library"` entry, skips at each consumer in 2.1, M9, mirror creation at the `projectRepo` path, human verbs `library.file` (read) and `library.status` | C2-01 | Sol (Herb, sage-1482) | business file tests. One test per consumer skip. A launcher test that the workspace has no clone. M9 test. Each verb from an agent session refuses (M4) |
| C2-03a | S | Fetch-only sync (7.2): `syncCommand`, askpass helper, served ref, `library-diverged`, `--accept`, sync state file, timer, `library.sync` | C2-04 | Opus | the C2-03a tests in 7.2 |
| C2-05a | S | Lookup index and queries: find, get, search, manuscript with scope, universe, isolation | C2-01 | Haiku high | isolation, scope labels on every tool, unmapped files never returned, paging under `REPLY_CAP`, rebuild time on a 3,000-file fixture |
| C2-05b | S | Broker lookup verbs and typed tools. Sync before a read older than 60 s | C2-05a, C2-03a | Sol (Rocko) | broker verb tests. Typed tool name snapshot per creative role (M3) |
| C2-06 | S | Cited-reply guard (5.2) in `packages/library`, the broker's pre-check on agent-to-human `message.send`, the page store, the three notices | C2-05a | Opus | one pass and one refusal per check in M5 and 5.2, through the RESULT path and `send_message`, and with a non-creative sender. `messages` count unchanged on each refusal |
| C2-07 | S | Runner retry on a guard refusal (at most 2), then `no-cited-answer`. `no-answer` and `turn-failed` in a library business | C2-06 | Haiku high | fake harness: free text then a valid reply stores one message. An empty and a failed turn store the fixed notices |
| C2-01d | S | `supporting` lists and their prefix rules, `library-supporting-other-universe`, the `supporting` scope ahead of playground and canon, the `editor` field, and `supportingPath` (M12) | C2-01c | Opus | one test per prefix rule and refusal, a folder in both lists, scope order, every M12 path class, `editor` refused when not two non-empty strings |
| C2-05b | S | Broker lookup verbs and typed tools. Sync before a read older than 60 s. Row 224, built to the earlier brief and landing as built (sage-2833) | C2-05a, C2-03a | Sol (Rocko) | broker verb tests. Typed tool name snapshot per creative role without the write tools (M3) |
| C2-05c | S | `scope` with `supporting` in the lookup verbs and tools, `blob` in `library_get` (sage-2833) | C2-05b, C2-01d | Sage picks | a `supporting` lookup in find, search and get, canon by default leaves supporting files out, `blob` equals the served blob id, the M3 snapshot unchanged |
| C2-06 | S | Citation guard (5.2) in `packages/library`, the broker's pre-check on agent-to-human `message.send`, the page store, `replyLimits` with `requireCitation` and `citationsPerReply` and M11 (Q222, Q226), the runner texts, and the runner retry that was C2-07 | C2-05a, C2-04 | Opus | one pass and one refusal per check in M5, M11 and 5.2, through the RESULT path and `send_message`, and with a non-creative sender. `messages` count unchanged on each refusal. The runner tests in 5.2 |
| C2-07 | | Folded into C2-06 (ruling after r11) | | | |
| C2-08 | S | Six role files and contracts, the new constants, M1, M6, the `webPlan` refusal, gate tests (M2), messaging test. `persona.name` and `persona.sha256` in `REGISTRY`, the persona file read and checks, and the fenced persona block between the contract and "## This session" in `prompt()`. M10: the `organization.set` refusals, the `models` check, the `routes.set` and `instances.set` creative branches (4). Both adapters pass the prompt as a file, not its text (4) | none | Opus | role tests, gate tests, a creative raise refused, `harness-builtin` refused for `research` and `companion`, `scripts/test-config.sh`. Persona vars refused at other layers, malformed and on a non-creative definition. The persona file refused when missing, a symlink, another uid's, group or other readable, over 8,000 characters, off its digest, holding `</persona-file>` or a `# `/`## ` line. A prompt snapshot pinning contract, persona, session. The M10 tests, tester cases through a bound tester session, the `models` mutant. Adapter tests: argv holds the prompt path and no marker from the prompt, and pi's adapter refuses a missing and an unreadable prompt file. M10's actor case: the human's `limits.set` change to Bean's `launch.models` key works next to the chief's refusal. Business file, `.history` and `changes.jsonl` modes after a write |
| C2-09a | S | `#threaded` and launcher read `THREAD_DEFINITIONS`. `thread.create` takes `instance`. The Console new-thread control picks it | C2-08 | Sol (Rocko) | the C2-09a tests in section 6 |
| C2-10a | S | M7 in the conversation reader. M8 in `discord check` and `discord run` | C2-08 | Haiku high | the M7 reader test, the M8 tests |
| C2-10b | S | Console chat: cited-item renderer through `renderItems`, the status line | C2-06, C2-09a, C2-03a | Haiku high | webui tests for items, scope badges, notices, "changed since" and the status line |
| C2-10b | S | Console chat: the reply renderer through `renderReply`, which replaces `renderItems`, the agent-written label, the notice line with Revert, the status line | C2-06, C2-09a, C2-03a, C2-02 | Haiku high | webui tests for text and citations, scope badges, the agent-written label, the notice line and its Revert call, "changed since" and the status line |
| C2-12 | S | The setup sheet for Melanie (7.1, including N2 and N4, and what changes for Bean and Chet in 4) and the survey run sheet for `<host>` | C2-01 | Luna | doc only, reviewed against the survey's real flags |
| C2-12b | S | Setup sheet rewrite for Q223 and Q226: free-text chat, supporting folders and how to mark one, agent writes as commits with notices and Revert, her Console saves, the `editor` name and email, the settings that replace the caps, and the residual | C2-12 | Luna | doc only, reviewed against the ruling after r11 |
| C2-20 | S | Persona import (9.1): a script that reads only the persona definitions of the two Hermes profiles, confirms profile to name, and writes each persona file (4) with its `persona.name` and `persona.sha256` for the business file. Prints names, byte counts and sha256 only. A `--dry-run` mode, run before Jason's go, prints only counts of files, lines and lines starting `# ` or `## ` | C2-08 | Opus (Gosalyn), run under Jason's go | fixture Hermes profile trees: the right fields are taken, memory and session files are never opened (a read trace on the fixture), a profile with no persona field refuses, a persona that would fail C2-08's fence check refuses, output holds no persona text, each written file is 0600 and its digest matches, `--dry-run` writes nothing and prints the right counts for a fixture with two heading lines and no marker string from it |
| C2-13a | S | `scripts/case2-unit.sh`, which renders the bus and Console system units from their existing templates and which root runs from a root-owned checkout in 10 step 2c, and `scripts/case2-precheck.sh` with the host checks. No library dependency, so row 195 chains on it (sage-1480) | none | Opus (Gosalyn) | the C2-13a brief's render and precheck cases: token rules, `@PATH@` from `--node`, the four hardening lines, the Console's capability lines and privileged port, one `WantedBy=multi-user.target` on each unit (Q189), copy-id pairing, the serve flag set, the key file and the unprivileged port start. Scratch unit directory and scratch data root |
| C2-13b | S | Install script for `<host>` as `<user>`: bootstrap, the stack copy, business file with the mapping, `bean` and `chet` with their persona vars and files, any specialists she picks, her human id, token slot path. The install writes no unit. Adds the library checks to the precheck. Jason or his delegate runs it (10) | C2-13a, C2-04, C2-03a, C2-20 | Opus (Gosalyn), Jason's go | precheck library checks: the business file, `.history`, `changes.jsonl` and persona files have the modes in 4 and each persona digest matches, no Discord binding exists, and after step 4 `--library-status` reads the bus's recorded sync state and lists R-problems (10 step 4a). Tested against a scratch data root and a local bare repo |
| C2-14 | S | End-to-end acceptance on a scratch data root and a bare repo in her layout: sync, ask a creative agent in its thread, a reply whose cites verify, a playground cite as canon refused, a force-push held as diverged, the Console shows the reply as items | slice rows | Opus | one script, which the reviewer also runs |
| C2-02 | | Bus writes: `writeLibraryFile`, `commitFile` refactor, r1's S0 rule on the working tree | C2-04 | Sol (Rocko) | writer tests, `packages/project/tests` unchanged and green |
| C2-03b | | Push path, race retry, `unpushed`, `library-remote-invalid` on writes | C2-02, C2-03a | Opus | push tests against a bare repo |
| C2-08b | S | Role changes, authorized in mos-1309 item 5: `library.write` in the creative authority and in `checkCreative`, the gitea credential on the six creative definitions and the chief of staff, instance `identity` and `publish`, `CREATIVE_KINDS` removed | C2-08 | Opus | role tests: a creative role with `library.write` validates, one with any other added action refuses, the chief holds the credential and not `library.write`. `scripts/test-config.sh` |
| C2-21 | S | Grant executor for `kind: "library"` (ruling after r11, F40): the creative raise check narrowed to library projects (mos-1163), collaborator in step 4, the level check in step 5, the rendered block, revoke. The install enrolls the chief of staff | C2-08b, C2-13b | Opus | first, the two Gitea facts proven on a scratch Gitea 1.27.1. Then executor tests: `write` for a creative definition, `read` for the chief and any other, a wider permission or `admin` refuses `gitea-reach-wider`, revoke deletes the token, a non-library project still forks, a creative raise naming a non-library project refuses `gitea-not-declared` |
| C2-03b | S | Push and credentials (7.2): the push form of `syncCommand`, the race retry once, `library-push-failed`, `library-remote-invalid` on writes, the helper answering with a seat's grant token, the chief's for the fetch, and slot `library-human` | C2-03a | Opus | push tests against a bare repo. The helper refuses another seat's user, and no token reaches argv, config or a log |
| C2-02 | S | Writes (ruling after r11): `library.doc.write`, `library.doc.delete`, their typed tools, the plumbing commit and trailer, the notice, `library.revert`, the trailer label in the check result, and `library.file.save` | C2-01d, C2-03b, C2-04, C2-05c, C2-08b | Opus | the M12 broker tests, write, delete and revert against a bare repo, the grant refusals, the notice body, her save's author, the M3 snapshot with the write tools |
| C2-14 | S | End-to-end acceptance on a scratch data root and a bare repo in her layout: sync, ask a creative agent in its thread, a reply whose cites verify, a playground cite as canon refused, a force-push held as diverged, an agent's supporting doc write and its notice, a story path write refused, a revert, her Console save as one commit, the Console shows the reply | slice rows | Opus | one script, which the reviewer also runs |
| C2-19 | | `library.promote`, multi-path commit | C2-02, C2-03b | Sol (Rocko) | promote in one commit, human only |
| C2-09b | | `room.open`, `room.send` with @tags, `room.read` | C2-09a | Sol (Rocko) | the C2-09b tests in section 6 |
| C2-18 | | Discord relay (7.6), optional, after its own Jason ruling | C2-09a, C2-06, C2-03a | Opus | the C2-18 tests in 7.6 |
| C2-10c | | Console room view: merged timeline, @name completion | C2-09b, C2-10b | Haiku high | webui tests, @tag parse parity with the broker |
| C2-11a | | Editor wikilink node. First measure whether `[[` round-trips today | none | Sol (Rocko) | `roundTrip` tests: plain, labelled, heading, in a list, next to front matter |
| C2-11b | | Console library pages: tree, open, save (with C2-02), promote and crossover buttons | C2-04, C2-11a | Haiku high | webui tests. Save refuses `stale-base` in the UI |
| C2-11a | S | Editor wikilink node. First measure whether `[[` round-trips today | none | Sol (Rocko) | `roundTrip` tests: plain, labelled, heading, in a list, next to front matter |
| C2-11b | S | Console library pages: tree with playground and supporting sections, open, one commit per save through `library.file.save`, `stale-base` keeps her text | C2-02, C2-11a | Haiku high | webui tests: a save makes one commit with her `editor` as author, a moved file shows `stale-base` and keeps her text, an unmapped path offers no save |
| C2-15 | | Hybrid universes in the business file, `exclude` and `pin` | C2-05a | later | |
| C2-16 | | Agents see each other's replies in a room | C2-09b | later | |
| C2-17 | | Automatic replay of unpushed commits on divergence | C2-03b | later | |
| C2-17 | | Dropped: there is no unpushed state (ruling after r11) | | | |
Parallel waves:
- Start now: C2-01, C2-08, C2-11a, C2-13a.
- On C2-01's candidate: C2-04, C2-05a, C2-06, C2-12. On C2-08's: C2-09a, C2-10a and C2-20.
- Next: C2-03a, C2-07. Then C2-05b, C2-10b and C2-13b.
- Last: C2-14. The write rows and rooms follow when a seat is free.
- Landed: C2-01, C2-01c, C2-03a, C2-04, C2-08, C2-12, C2-13a, C2-20. C2-13b is in progress.
- Next, side by side: C2-01d, C2-03b, C2-05a, C2-08b, C2-09a, C2-10a, C2-11a, C2-12b.
- Then: C2-05b (row 224, built), C2-05c, C2-06, C2-21 and C2-02. Then C2-10b and C2-11b.
- Last: C2-14. Rooms, the relay and promotion follow when a seat is free.
Dependencies on other tracks: row 190 doesn't apply, since nothing else runs as `<user>`. SSO (Q175,
#412) gates the Console from her machine, and the Console is now her channel, so the slice needs SSO
@@ -1241,14 +1605,15 @@ The in-VM tightening is Vikunja #423. Only its step 1, the `localadmin` account,
### 9.2 The slice and the date
The smallest slice that lets her start is the S rows: C2-01, C2-03a, C2-04, C2-05a, C2-05b, C2-06, C2-07,
C2-08, C2-09a, C2-10a, C2-10b, C2-12, C2-13, C2-14 and C2-20. With it she keeps writing in Obsidian exactly
as now. In the Console she opens a thread with Bean, Chet or a specialist and reads each answer as items
whose citations the broker checked. A status line tells her when the hub falls behind her repo. Hermes
keeps running beside it.
The smallest slice that lets her start is the S rows: C2-01, C2-01c, C2-01d, C2-02, C2-03a, C2-03b, C2-04,
C2-05a, C2-05b, C2-05c, C2-06, C2-08, C2-08b, C2-09a, C2-10a, C2-10b, C2-12, C2-12b, C2-13a, C2-13b, C2-14,
C2-20 and C2-21. C2-11a and C2-11b are slice rows too, and they don't gate 10-27 (Q195). With it she keeps
writing in Obsidian exactly as now. In the Console she opens a thread with Bean, Chet or a specialist and
reads each answer as free text whose citations the broker checked. Each agent write shows as a notice she can
revert. A status line tells her when the hub falls behind her repo. Hermes keeps running beside it.
Not ready by 10-27: bus writes and the Console editor (C2-02, C2-03b, C2-11a, C2-11b, C2-19), rooms with
@tags (C2-09b, C2-10c), the Discord relay (C2-18), and C2-15 to C2-17. The slice makes 10-27 only if
Not ready by 10-27: promotion (C2-19), rooms with @tags (C2-09b, C2-10c), the Discord relay (C2-18), C2-15
and C2-16. The editor may land after 10-27 without holding it. The slice makes 10-27 only if
section 10 steps 1-7 are done by about 10-24. The Console from her machine needs SSO (#412) or an interim
sign-in ruling, and that is the step most likely to be late.
@@ -1334,7 +1699,9 @@ What closes the rest.
The Gitea host. Her books repo is already on git.mosaicstack.dev (mos-1000). On this host that name
resolves to 10.1.1.222, and STATE.md lists its certificate among the edge certificates, so I treat it as
reachable from the internet. Her writing is already there by her own choice. The hub adds no new remote.
What it adds is one bot account with read access to that one repo.
It adds no shared bot account. Each writing seat gets its own Gitea account through the grant path, a
collaborator on that one repo with `write`, and the chief of staff one with `read` (ruling after r11).
Her own token signs her Console saves.
1. The host is set (Q181): 10.1.1.54 as `mosaic` under `/srv/mosaic`, 16 GiB and 6 cores. Jason
confirmed Q4 (Q183), so #421 is closed and the install doesn't wait on it.
@@ -1382,17 +1749,25 @@ What it adds is one bot account with read access to that one repo.
the one file decision 119 and `packages/cli/README.md` document, so I kept it. He places it, or
says who may, per the Q173 secrets rule. Agents never print it. Until #423 step 5, `melanie`'s
sudo can read it, and so can her Hermes processes (9).
3. A new Gitea bot account for the hub, with read access to the books repo only. Not bean-brain, and not
her other three repos. Her repo's access list is hers, so adding the bot means asking her or acting as
admin, which is his call.
4. The bot's token in slot `library-gitea` under `<configDir>/secrets/<business>/`, which is
`/srv/mosaic/.config/mosaic-dev/secrets/<business>/` for `mosaic`, mode 0600. He puts it there, or
says who may, per the Q173 secrets rule. Agents never print it.
3. No bot account. The install enrolls the chief of staff, which raises the gitea grants for `books`
through the existing step, and the executor makes each seat a collaborator on her repo (C2-21). The
seats reach the books repo only, not bean-brain and not her other three repos. Whether each grant also
needs his Approve is Q31, asked of him, and this design adds no step for it. The collaborators change
her repo's access list, which is hers, so step 7 tells her.
4. Her own Gitea token for her Console saves, in slot `library-human` under
`<configDir>/secrets/<business>/`, which is `/srv/mosaic/.config/mosaic-dev/secrets/<business>/` for
`mosaic`, mode 0600. He puts it there, or says who may, per the Q173 secrets rule. Agents never print
it. If the install runs before C2-03b lands, the C2-03a fetch still reads slot `library-gitea`, which
then needs a read token as r11 said. After C2-03b the fetch reads that slot only while the bus has
no gitea grant event for the chief (7.2). Step 4b removes it.
4a. `scripts/case2-precheck.sh --business <business> --library-status` as `<user>`, once the bus is up
and step 4's token is in place. It reads the bus's recorded sync state only and syncs nothing. It
and the fetch token is in place. It reads the bus's recorded sync state only and syncs nothing. It
refuses when the mirror is missing, when `lastError` is set, or when `lastFetch` is null. It prints
each R-problem as a `problem` line and then a count, never text (sage-2189). The bus syncs every 60
seconds, so the first check after step 4 may have to wait for one sync.
4b. Once C2-21 has run and `library.status` shows the fetch on the chief's grant, Jason deletes slot
`library-gitea` and revokes its token in Gitea. The fetch never reads the slot again, since a gitea
grant event for the chief then exists and events are never deleted (webby-0151 R3, webby-0156 R4).
5. Her Console sign-in from her machine: her Authentik user and `sub` once SSO lands (#412 step 3 is the
model), or his ruling on an interim. The proxy forwards to the Console's port below 1024 and sets the
proof header. The proof file goes under `<configDir>/secrets/`, mode 0600, owned by `mosaic`, and
@@ -1401,12 +1776,14 @@ What it adds is one bot account with read access to that one repo.
6. The C2-20 persona import: who runs it on `<host>`, his check of the printed profile-to-name pairing,
and whether the per-channel prompts count as personality.
7. Running `library survey` on `<host>` after step 4, which prints names and counts, never text. Then
contacting Melanie: who tells her what changes for Bean and Chet (4), sends the setup sheet (C2-12),
and asks her to confirm the mapping, the universes, the playground folders and which specialists to
start with, and when. A specialist she wants later is added by the human through `instances.set`
contacting Melanie: who tells her what changes for Bean and Chet (4), sends the setup sheet (C2-12b),
tells her the agents become collaborators on her repo, and asks her to confirm the mapping, the
universes, the playground and supporting folders, her `editor` name and email, and which specialists
to start with, and when. A specialist she wants later is added by the human through `instances.set`
(4), with no hand edit and no new row.
8. The residual in 5.1: up to 5 idea items of 200 characters per reply, 1,000 characters of free text
within 1,200 total. He or Melanie accepts it or orders lower caps. Mos takes it to him.
8. The residual in 5.1. Closed. Q219 set lower caps, and Q223 then removed the caps with the item
format. What remains is the residual the ruling after r11 states, a supporting doc that holds any
text, and C2-12b tells her about it.
9. The instance start. Stopping Hermes comes later, and only when she chooses it.
- The bus and the Console are already running from step 2c, and both come back after a boot on
their own (Q189, which reverses mos-1097 item 1). Nobody starts the Console by hand here. With a
+331
View File
@@ -0,0 +1,331 @@
# Row 229 design: a per-instance pi agent dir for Chet's Codex login (#428, Gitea #1730)
Design round 2, Gosalyn, 2026-10-22, for Webby's webby-0148 (R1, R2, N1-N5). Brief:
`docs/plans/2026-10-20_case2-rows.md`, "Brief: row B, a per-instance pi agent dir for Chet's Codex login
(#428)". Base: `refactor` at aca16404c. Pi is the pinned
`@earendil-works/pi-coding-agent` in the repository's `node_modules`. Cites into pi are
`node_modules/@earendil-works/pi-coding-agent/dist/...` (`pi:`) and
`node_modules/@earendil-works/pi-ai/dist/...` (`pi-ai:`).
This is a design. Nothing in it is built or tested. The brief's gate holds: no build row starts until
the live check in section 9 passes, run with Jason's or Mos's go, after 10-27. Mos ruled that "starts
after 10-27" covers the build and the live check, not the design, and that the check gates every build
row (mos-1306). Mos asks Jason for the go when the build rows are ready.
## 0. Summary
- Each pi instance whose route uses provider `openai-codex` gets its own agent dir,
`<dataRoot>/pi-agents/<business>/<instance>/`. The host makes it with the same checks as a thread store:
a real directory, owned by the bus user, mode 0700, no symlink in the path.
- The launcher binds that dir read-write at the same path into the session and sets
`PI_CODING_AGENT_DIR` to it through the pi module's `env()`. No other instance's session sees it.
- Pi refreshes in place under its own file lock. The new tokens are on the host disk when the run ends,
so the next run starts with them.
- No tool in the session can read the dir. The gate holds every path tool to the workspace, and a role
with `bash` refuses at launch with `pi-agent-dir-shell`.
- Before each launch the host checks the dir's contents against a short allowlist: `auth.json`, its
`auth.json.lock` dir, and a `settings.json` holding only the three keys a fresh `/login` writes. Pi
reads `models.json`, `models-store.json` and `settings.json` from the agent dir, and any of them could
send the token elsewhere or run a command. Anything outside the allowlist refuses `pi-agent-dir-unsafe`.
- The pi module's preflight runs `pi auth check --provider openai-codex --no-refresh --json` against the
dir. No login refuses `pi-login-missing`, and a broken file refuses `pi-login-invalid`.
- The human logs in with a new `mosaic pi-login <business> <instance>`. It runs pi's own `/login` with
the dir set, `--offline`, `--no-session` and an empty temporary cwd, so Mosaic gets its own fresh
grant and pi writes nothing else there. It never copies Hermes's `auth.json`.
- Chet's route has no fallback by default (mos-1306). A fallback would quietly move him back onto Bean's
Z.ai key.
- Two build rows: 229a, the store, the checks, the bind, the env, the preflight and the tests. 229b, the
login command and the README.
## 1. Facts at aca16404c
Pi side:
- `getAgentDir()` returns `PI_CODING_AGENT_DIR`, else `~/.pi/agent` (`pi:config.js:421`). `auth.json`,
`settings.json`, `models.json`, `themes/`, `tools/`, `bin/`, `prompts/`, `sessions/` and `pi-debug.log`
all sit under it (`pi:config.js:430-462`), and so do `models-store.json`
(`pi:core/models-store.js:30`), a cached provider catalog, and `keybindings.json`
(`pi:core/keybindings.js:301`).
- `checkProviderAuth` builds a model runtime, and `ModelRuntime.create` loads `models.json` from the
agent dir (`pi:core/model-runtime.js:76`). So the auth check itself reads `models.json`.
- Interactive mode downloads `fd` and `rg` into `<agentDir>/bin/` when they are missing
(`pi:modes/interactive/interactive-mode.js:699-705`, `pi:utils/tools-manager.js:300-330`) unless
`PI_OFFLINE` is set, which `--offline` does (`pi:main.js:440-444`, `pi:utils/tools-manager.js:13`).
Without `--no-session` it makes `<agentDir>/sessions/` (`pi:core/session-manager.js:242-252`).
- A fresh interactive start writes `lastChangelogVersion` to `settings.json`
(`pi:modes/interactive/interactive-mode.js:918`). A first `/login` then selects the provider's
default model with `persist: true` (`:4766`), which writes `defaultProvider` and `defaultModel`
(`pi:core/settings-manager.js:470-476`). `enabledModels` changes only when scoped models are set
(`pi:core/agent-session.js:1272-1285`), and a fresh dir has none. No settings write runs outside
interactive mode.
- `AuthStorage` writes `auth.json` with mode 0600 only when it creates the file
(`pi:core/auth-storage.js:14-15`). It never fixes the mode of an existing file. It takes a
proper-lockfile lock before a refresh write (`:39`, `:85`), which is the directory `auth.json.lock`
beside the file, and writes with `writeFileSync` (`:66`, `:140`). The lock is a sibling path, so the
dir itself must be writable. A file-only bind isn't enough.
- pi-ai refreshes under a double-checked lock and treats a token with less than 5 minutes left as
expired (`pi-ai:...resolve.js:62-74`). Two sessions sharing one dir therefore serialize their refreshes.
- The `openai-codex` OAuth client has a browser flow with a callback on `localhost:1455` and a device-code
flow at `auth.openai.com/codex/device` (`pi-ai:auth/oauth/openai-codex.js:26-33`). A refresh posts
`grant_type=refresh_token` (`:125-140`), and the response carries a new refresh token. The old one is
then spent.
- Startup migrations write into the agent dir only when legacy files exist there: `oauth.json`, session
files in the dir root, `commands/`, `tools/` (`pi:migrations.js:17-63`, `:76-129`, `:161-177`).
`settings-manager.js:101` writes `settings.json` when a setting changes.
- `pi auth check --provider <p> [--json] [--credentials] [--no-refresh]` (`pi:cli/auth-command.js:5`,
`:23-25`). `checkProviderAuth` (`pi:cli/auth-check.js:5-34`) returns `ready`, `not_ready` or `invalid`.
It builds its model runtime with `allowModelNetwork: false` (`:43-49`). With `--no-refresh` it only
checks that a credential is present: a revoked refresh token still reads `ready`. It prints no
credential unless `--credentials` is given. `auth print-bearer-token` and `print-api-key` print
secrets, and Mosaic never runs them.
Mosaic side:
- `binds()` lists every mount and says why (`packages/seat/src/sandbox.mjs:102-131`). The thread store
binds read-write at `:119-125`. `sandboxArgv` sets `HOME` to the run's own `home/` (`:163-166`).
- `threadStore()` makes `<dataRoot>/threads/<business>/<thread>/` and refuses a symlink, a non-directory,
another owner or any group or other mode bit (`packages/seat/src/session.mjs:59-72`). `ENV_ALLOW`
doesn't list `PI_CODING_AGENT_DIR` (`:83-88`).
- The launcher's check order is at `packages/cli/src/launcher.mjs:1-40`. One instance per thread runs at
a time (`:289`, `registry.running(b.id, instance, thread)`), so a thread instance such as Chet can run in
two threads at once. Preflight is `harness.preflight({ env })` (`:333`). A failed primary preflight
takes the stated fallback (`:334-345`, row 65). The thread store is made at `:388-394`, mounts at `:456`,
the session file with `harnessEnv: bundle.env` at `:461-484`, the paths at `:486-495`.
- The pi module's `preflight()` checks only the binary (`packages/plugins/harness/pi/index.mjs:33-37`).
`env({ policy, tools, session })` builds the harness env (`:63-71`). The adapter runs pi with
`--no-extensions`, `--no-skills`, `--no-prompt-templates`, `--no-approve` and `--offline`
(`adapters/pi/adapter.sh:104-123`).
- The gate refuses a path tool whose path is outside the workspace (`packages/harness/src/gate.mjs:79-83`,
`:152`). Chet's role, `roles/companion.json`, has tools `["read","web"]`.
## 2. Where the dir lives and who can read it
`<dataRoot>/pi-agents/<business>/<instance>/`. A new seat function `agentStore(dataRoot, business,
instance)` in `session.mjs`, beside `threadStore()`, makes each level with mode 0700 and the same refusal:
a symlink, a non-directory, another owner or any 0077 mode bit refuses `pi-agent-dir-unsafe`. Business and
instance must match `ID_PATTERN` and hold no `..`.
The dir belongs to the instance, not the thread. Chet's threads share one login, and pi's lock keeps
their refreshes apart (section 1). Only the bus user can read it on the host. A seat that shares the bus
uid can read it too, which is the same exposure the thread stores and the bus's own credential files
have today (case 2 r11 B1). This row doesn't change that.
Which routes get one. The pi module exports `agentStore({ provider })`, true for `openai-codex`. The
launcher asks the primary route's module, and the fallback's if one is taken. A module without the hook
gets no dir. A provider key route such as Bean's Z.ai keeps working exactly as today.
## 3. How the launcher mounts it
After step 5 picks the route and before the bwrap check:
1. If the module says the route needs a store, the launcher calls `agentStore()`. A refusal stops the
launch with its code.
2. The contents check in section 5 runs.
3. The module's preflight runs with the dir (section 6).
4. `paths` gains `agentStore`, and `binds()` adds one entry:
`{ kind: "rw", src: dir, why: "the instance's pi agent dir, its own login, kept across runs (row 229)" }`.
It binds at the same path, so the path pi sees is the host path.
5. The session object passed to `bundle` gains `agentDir`, and the pi module's `env()` adds
`PI_CODING_AGENT_DIR: session.agentDir` when it is set. It reaches pi through `session.harnessEnv`,
which the runner spreads into the adapter's env. `ENV_ALLOW` doesn't change, so no host value of
`PI_CODING_AGENT_DIR` ever passes.
The preflight signature changes to `preflight({ env, agentDir })`. Every other module ignores the new key,
so their behaviour doesn't change. The adapter contract in `adapters/README.md` gains one line for it.
## 4. How a refresh survives the run
Pi writes the refreshed `auth.json` into the bound dir, which is the host dir. Nothing copies it in or out.
A run that dies mid-write leaves pi's lock behind. Pi's lock goes stale on its own timer. The contents
check in section 5 accepts a lock entry and never deletes one, so the host never breaks a lock a running
session holds.
Two clients on one grant would race: a refresh spends the old refresh token, and the other client's next
refresh fails. Mosaic avoids that by never sharing a grant. `mosaic pi-login` makes a new one (section 7).
Hermes keeps its own grant. Whether OpenAI revokes an older grant when a new one is made, or limits grants
per account, I don't know. Section 9's live check exists to find out.
## 5. What the session can read, and what the dir may hold
The session's tools:
- `read` and every other path tool go through the gate, which refuses a path outside the workspace. The
agent dir is outside it.
- `web` sends a URL and gets text back. It has no file argument.
- `bash` would reach the dir, since pi itself must open it. A role whose tool list has `bash`, or a route
with the harness's built-in shell, refuses `pi-agent-dir-shell` at launch. The same check runs in
`validate` for the role file, so the conflict shows before a launch.
- The pi process reads the dir. That is the point of the bind.
The dir's contents. Pi reads more than `auth.json` from the agent dir. A `models.json` there can set a
provider's base URL, which would send the OAuth token to another host, and `models-store.json` is a
cached catalog of the same kind. `settings.json` is worse than it looks. Every dangerous setting is a
key pi knows, so a check against pi's own key set would pass all of them (webby-0148 R1):
- `httpProxy` applies to every request pi makes, the Codex bearer and the refresh POST included
(`pi:main.js:455`, `:685`)
- `packages`, `extensions`, `skills`, `prompts` and `themes` name code or text to load
- `shellPath`, `shellCommandPrefix`, `npmCommand`, `externalEditorCommand` and `sessionDir` name a
command to run or a place to write
`--no-extensions` and the other flags turn discovery off, but this design doesn't rest on them alone.
Before each launch the host lists the dir and allows only:
- `auth.json`, a regular file, mode 0600, owned by the bus user. Pi sets 0600 only when it creates the
file, so this check is what holds the mode
- `auth.json.lock`, a directory, owned by the bus user. It is the only directory allowed, and the check
never deletes it (section 4)
- `settings.json`, a regular file of at most 4 KiB that parses to a plain object, empty or with only
these keys: `lastChangelogVersion`, a string of at most 32 characters matching
`^\d+\.\d+\.\d+[0-9A-Za-z.+-]*$`, `defaultProvider`, exactly `openai-codex`, and `defaultModel`, a
string of at most 64 characters matching `^[A-Za-z0-9._-]+$`. These are the keys a fresh `/login`
writes (section 1). Any other key refuses, `httpProxy` and the paths and commands above by name
Anything else refuses `pi-agent-dir-unsafe` and names the entry: `models.json`, `models-store.json`,
`keybindings.json`, `extensions/`, `tools/`, `bin/`, `themes/`, `prompts/`, `skills/`, `sessions/`,
`pi-debug.log`, `oauth.json`, a symlink, a device, or any other file or directory. Round 1 allowed
`models-store.json` and `keybindings.json`. Round 2 refuses both: the first can carry a base URL like
`models.json`, and nothing in a headless run or a login needs the second. Nothing in the session can
create a refused entry, because no session tool reaches the dir. The check guards against a human
copying a whole `~/.pi/agent` in by hand, and against a login that wrote more than it should.
The settings allowlist is the three keys above, written in Mosaic's code, not read from pi. A test pins
the sorted list of `SettingsManager` setter names in the pinned package by its sha256, so a pi bump that
adds, drops or renames any setting fails the suite, and the human reviewing the bump re-checks the three
keys before re-pinning.
## 6. How a launch with no login refuses
The pi module's `preflight({ env, agentDir })`, after the binary check, runs when `agentDir` is set:
```
node <pi bin> auth check --provider openai-codex --no-refresh --json
```
It runs only after the section 5 contents check passes, because the auth check loads `models.json` from
the dir (section 1). It uses `PI_CODING_AGENT_DIR=<agentDir>`, `HOME` and the cwd both set to one empty
temporary dir made with `mkdtemp` under the scratch tmp and removed afterwards, and `PATH` from the
system. It runs on the host with a 10-second timeout. Never `--credentials`. Results:
- exit 0, `ready`: pass
- exit 1, `not_ready`: `pi-login-missing`, with the message "run `mosaic pi-login <business> <instance>`"
- exit 2, `invalid`, a timeout or any other exit: `pi-login-invalid`
The check never refreshes, so a launch never spends a refresh token on the host. That leaves one gap I
can't close without a network call: a revoked or expired refresh token still reads `ready`. Pi then
fails the first turn when its refresh fails, and the turn's failure is visible in the run record. The
fix is the same login command. I chose `--no-refresh` over a refresh at preflight because a host-side
refresh would race a running session of the same instance.
Fallback. Row 65 lets a failed primary preflight take the stated fallback. Chet's route ships with none
(mos-1306). A fallback onto Bean's Z.ai key would make a lost login silent: Chet answers on another
provider and Melanie can't tell. Without one, the launch refuses with the preflight's code. A human can
still add a fallback through `routes.set`. No agent path can: Chet is a `companion` instance, a creative
definition (`packages/business/src/vocabulary.mjs:207`), and `creativeHumanOnly`
(`packages/business/src/launch-settings.mjs:13-17`, called for routes at `:293`) refuses
`organization-creative-human-only` unless the actor is the business file's human. The tester is not
that human, though `routes.set` is a tester verb (`packages/cli/src/broker.mjs:139`). This row only
makes none the default.
## 7. The login command (229b)
`mosaic pi-login <business> <instance>` in `packages/cli`, a human verb:
1. Refuses unless the instance's primary route is pi with provider `openai-codex`
(`pi-login-not-needed`), and unless the instance isn't running (`instance-running`).
2. Calls `agentStore()` and runs the contents check.
3. Moves an existing `auth.json` out of the dir, to
`<dataRoot>/pi-agents/<business>/.replaced/<instance>-<UTC>.json` at 0600, so every login is a fresh
grant and the contents allowlist doesn't grow. It never reads or copies `~/.pi/agent` or Hermes's
files.
4. Runs interactive pi on the attached terminal with `PI_CODING_AGENT_DIR=<dir>`, `HOME` and the cwd
both set to one empty temporary dir under the scratch tmp, and these flags: `--offline`, so pi
downloads no `fd` or `rg` into `<dir>/bin/` and sends no telemetry, `--no-session`, so it makes no
`<dir>/sessions/`, and the `--no-extensions`, `--no-skills` and `--no-prompt-templates` the adapter
uses. `--offline` doesn't block `/login`: it sets `PI_OFFLINE` and skips the version check and the
catalog refresh, and the OAuth flow makes its own requests. The command tells the human to type
`/login`, choose OpenAI Codex and pick the device code flow, since the callback on `localhost:1455`
won't reach a browser on another machine.
5. Afterwards runs the section 5 contents check, then the section 6 check, and prints only their
status. A login that left any entry outside the allowlist refuses `pi-agent-dir-unsafe` here, before
any launch.
The command prints no token. `.replaced/` gets the `agentStore()` checks. It can't collide with an
instance dir, since `ID_PATTERN` doesn't allow a leading dot.
`reset.sh` wipes the data root, and the login with it. The README says so.
## 8. Files the build touches
229a:
- `packages/seat/src/session.mjs`: `agentStore()`, the contents check
- `packages/seat/src/sandbox.mjs`: the `agentStore` bind in `binds()`
- `packages/cli/src/launcher.mjs`: the store, the check and the preflight argument after step 5, the
`pi-agent-dir-shell` refusal, `paths.agentStore`, `agentDir` on the session
- no change to `packages/harness/src/bundle.mjs`, which already passes `{ ...session, ...model }` to `env()`
(`:375`), so `agentDir` on the launcher's session reaches the module
- `packages/plugins/harness/pi/index.mjs`: `agentStore()`, the preflight, `PI_CODING_AGENT_DIR` in `env()`
- `adapters/README.md`: the `agentDir` preflight key
- the role validator for `pi-agent-dir-shell`
- tests in `packages/seat/tests`, `packages/cli/tests`, `packages/plugins/harness/pi/tests`
229b:
- `packages/cli/src/pi-login.mjs` and its dispatch
- `packages/cli/README.md`
- tests in `packages/cli/tests`
## 9. The live check (the gate)
Run after 10-27, with Jason's or Mos's go, on Chet's real ChatGPT login. A human does the login.
1. Hermes answers as Chet. Record the time.
2. `mosaic pi-login` for Chet. The status reads `ready`.
3. Launch Chet on the new route. Run one turn.
4. Force a Mosaic refresh. The human edits only `expires` in Mosaic's `auth.json` to a past time, then
runs one more turn. It succeeds, and `auth.json` changed. After steps 2, 3 and 4 the record lists the
dir's entry names, and each passes the section 5 check.
5. Hermes answers again. This is the check that Mosaic's refresh didn't revoke Hermes's grant.
6. Wait for Hermes's own next refresh, at least one access-token lifetime, then run a Mosaic turn. This is
the check the other way.
7. The record lists each step's time and result, and never a token.
Any failure stops the row. The answer then goes to Jason: Chet waits until Hermes stops, or he moves
alone. No build row starts before step 6 passes.
## 10. Tests the build rows carry
229a:
- `agentStore()` refuses a symlinked level, a wrong owner, a 0750 dir and a `..` id
- the contents check passes the allowed set and refuses each listed entry, `models-store.json`,
`keybindings.json`, `bin/` and `pi-debug.log` included, and a second directory beside
`auth.json.lock`
- `settings.json` passes when absent, `{}` and with the three allowed keys. It refuses each of
`httpProxy`, `packages`, `extensions`, `skills`, `prompts`, `themes`, `shellPath`,
`shellCommandPrefix`, `npmCommand`, `externalEditorCommand` and `sessionDir` by name, a
`defaultProvider` other than `openai-codex`, a non-string or overlong value, an array, a file over
4 KiB and a file that doesn't parse
- `auth.json` at 0644 refuses, so a file that pi didn't create still meets the mode
- the bump test: the sha256 of the sorted `SettingsManager` setter names in the pinned package matches
the pinned value, so any change to pi's settings key set fails it
- a `models.json` in the dir refuses `pi-agent-dir-unsafe` before the fake pi bin runs. The fake bin
records each call, and the record is empty
- the preflight's fake pi bin records its cwd and `HOME`, which are the same empty temporary dir
- the tester's `routes.set` adding a fallback to Chet's route refuses `organization-creative-human-only`
- a companion role with `bash` refuses `pi-agent-dir-shell`
- `binds()` adds the entry only when `agentStore` is set
- `env()` sets `PI_CODING_AGENT_DIR` only when `agentDir` is set
- a host `PI_CODING_AGENT_DIR` never reaches the session
- preflight maps exit 0, 1, 2 and a timeout to pass, `pi-login-missing` and `pi-login-invalid`. A fake
pi bin records its argv, which must hold `--no-refresh` and never `--credentials`
- a route with no store gets no bind and no preflight change. Bean's route is unchanged
- Chet's route with a failed preflight and no fallback refuses with the preflight's code
- a mutant without the contents check fails, and so does one without the shell refusal
- every test uses `mktemp -d` under the scratch tmp, never `/tmp`, and no real login
229b:
- `pi-login` refuses a non-codex route and a running instance
- it moves an existing `auth.json` out of the dir, never reads `~/.pi/agent`, and prints only the status
- its fake pi bin records argv, which holds `--offline` and `--no-session`, and a cwd that is an empty
temporary dir
- after a fake login that writes `auth.json`, `auth.json.lock/` and a `settings.json` with the three keys,
the dir holds only allowlisted entries and the command reports `ready`
- a fake login that also writes `bin/fd` or `sessions/` makes the command refuse `pi-agent-dir-unsafe`
## 11. Open items
- Whether OpenAI revokes or caps grants per account. Section 9 answers it.
- Whether pi's request-time refresh works under the adapter's flags. I read nothing that blocks it.
`--offline` affects only the model catalog (`pi:core/model-runtime.js:88`). Section 9 step 4 proves it.
- Whether a real `/login` on the pinned pi writes only the three settings keys. Section 1 reads it from
the code. Section 9's dir listings prove it, and a fourth key there stops the row for a design change.
+8
View File
@@ -0,0 +1,8 @@
# Huey
Builder seat, Codex gpt-6.1-sol, started by Mos for the T1 sessions design. Owns row 272 under Q220, reviewer Scrooge.
No launcher in this directory. The seat runs as a T3 thread (thread cabd45ca) and works in its own worktree. It has no
credentials of its own; it pushes only its own `huey/*` branches through the repository credential helper (mos-0981),
never `refactor`, `main` or another seat's branch. Registered by Sage on 2026-10-22 so the queue can name the seat as
row owner under Q220 (mos-1301).
+251
View File
@@ -0,0 +1,251 @@
# 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.
+8
View File
@@ -0,0 +1,8 @@
# Louie
Builder and research seat, Codex gpt-6-luna, started by Mos for the editor evaluation. Owns row 174 under Q220.
No launcher in this directory. The seat runs as a T3 thread (thread bfd13d49) and works in its own worktree. It has no
credentials of its own; it pushes only its own `louie/*` branches through the repository credential helper (mos-0981),
never `refactor`, `main` or another seat's branch. Registered by Sage on 2026-10-22 so the queue can name the seat as
row owner under Q220 (mos-1301).
+8
View File
@@ -0,0 +1,8 @@
# Ludwig
Builder and reviewer seat, Claude Opus 5.5, started by Mos for the post-v1 email PRD. Owns row 258 (E01) under Q220.
No launcher in this directory. The seat runs as a T3 thread (thread 8615b2a6) and works in its own worktree. It has no
credentials of its own; it pushes only its own `ludwig/*` branches through the repository credential helper (mos-0981),
never `refactor`, `main` or another seat's branch. Registered by Sage on 2026-10-22 so the queue can name the seat as
row owner under Q220 (mos-1301).
+190
View File
@@ -0,0 +1,190 @@
# Row 203, FED R2: the federation key in the system config (#1701)
Owner Quackerjack, reviewer Webby, gate owner Sage. Brief: docs/plans/2026-10-20_federation-r1-r2-r5.md,
"Brief: FED R2". Design: DESIGN.md section 3.1 (D1), section 4 (the key shape), section 7 row R2, at
gizmoduck/federation-design b93f098f0.
Base: aca16404c, rebased onto f2735bb6a (origin/refactor, shared object store). Rebased onto 253's tip
only if 253 lands first, under a new branch name.
## Commits
- 0eb4b6f1f, rebased to e704a18ae: the loader, the first FED R2 tests, and these notes.
- Second commit on top of e704a18ae (sage-2998): B1 and B2 below, the fixture fix, and the notes
rewrite. Its sha goes in the status line once committed.
## Shape
```json
"federation": {
"self": "hub",
"peers": [{ "id": "factory", "origin": "https://mosaic.woltje.com", "label": "Software factory" }]
}
```
- Optional. Absent: the config loads and prints what it printed before (no `federation` in the output).
- `self` and each `peers[].id` match `^[a-z][a-z0-9-]{0,31}$`. A peer id may not equal `self` or another peer's id.
- `peers[].origin` must be an https origin exactly as `new URL(origin).origin` returns it: no path, no query,
no userinfo, no trailing slash.
- `peers[].label` is a non-empty string of at most 80 characters.
- Labels and URLs only. Nothing here grants trust to a peer (section 3.1 D1, section 3.2 W1).
## Readers of the key
Two readers read the raw key. Nothing else reads it (`git grep "federation"` at e704a18ae):
1. The loader, `scripts/mosaic-config.mjs` (`validate`, the `federation` branch). This is the only reader of
the validated value. Its output is read by the CLI and business code, which read named fields only.
The output is checked in `scripts/test-config.sh`.
2. The stack-release probe, `config_checks` in `scripts/stack-release.sh` (B1, below). It reads the config
file itself, before a copy is activated or rolled back to, and asks whether the target copy's loader
refuses a key the config holds.
Stale wording, NOT changed in this row (Sage rulings sage-2808 and sage-2809):
`packages/cli/src/access.mjs` is out of row 203. Megavolt's row 207 changes that file. Follow-up, to be
carried to DEFERRED by Sage:
- access.mjs lines 26-27 (`ACCESS_HELP` comment): "Peer homes are empty until the federation key lands
(row 203)". Still partly true after this row, since the access context does not read the key.
- access.mjs line 36 (inside the `ACCESS_HELP` string, user-visible help): "Peer homes are not read yet
(row 203 adds the federation key)". The same claim, in help text. No test in `packages/cli/tests` pins it
(`git grep` for "row 203" and "Peer homes" finds nothing in tests).
- access.mjs line 44 comment: "peers is empty until row 203".
- `packages/cli/README.md` line 1107: "until row 203 adds the". Outside the manifest; flagged in the
same follow-up.
Behavior after this row: `accessContextFor` still returns `peers: []` (access.mjs line 58). Row 203 adds the
config key only, so grants naming a peer home still refuse. Wiring the key into the access context is not in
the FED R2 brief.
## B1: the stack-release probe (sage-2998, in scope)
Webby's early look (webby-0189): a config holding `federation` and no `console` passed `config_checks`, because
the probe looked only for `console`. A rollback or activate to a pre-203 copy would then pass its checks, and
that copy's loader refuses `unsupported configuration key: "federation"` on the next read.
Fix (scripts/stack-release.sh, `config_checks`):
- The probe's node check now reads `console` or `federation` (`Object.hasOwn(d,"console")||Object.hasOwn(d,"federation")`).
- The refusal keeps the check name `config-console-key`, so existing callers and tests do not change. The
message now reads "holds a console or federation key". This is a wording change to an existing refusal,
and `KEY_MSG` in `packages/cli/tests/stack-release.test.mjs` moves with it.
- The new test is `row 203: a config whose federation key the target loader refuses ...` in
`packages/cli/tests/stack-release.test.mjs`, after the row 200 console test. Target B's loader has no
federation key in its list, so it refuses the key. Activate to B and rollback to B are both refused, and
a rollback succeeds once the key is removed.
Fixture (packages/cli/tests/stack-release-fixture.mjs, line 300): `LOADER_OLD` builds B's loader by a string
replace on the `rejectUnknownKeys` list. Adding `federation` to that list in this row left the replace with no
match, and the fixture's `assert.notEqual(LOADER_OLD, LOADER)` would fail at load. The replace now targets
`"execution", "console", "federation"]`, so B's loader refuses both keys. This regression came from my
e704a18ae, and the fix is in this commit.
## B2: a case and a mutant for each refusal (sage-2998: "one mutant per refusal")
Webby's list is covered below. Each refusal has a case in `scripts/test-config.sh` (FED R2 block) and a
mutant. M5 is killed by the self case; M13 covers the duplicate half of the id check that M5 did not reach.
## Refusals and their tests
`scripts/test-config.sh`, FED R2 block:
| Case | Refusal (exit 2) | Mutant (killed by) |
|---|---|---|
| valid key loads and is in the output | none | base |
| unknown top-level sub-key | `unsupported federation key: "extra"` | M1 |
| unknown peer sub-key | `unsupported federation.peers[0] key: "trust"` | M2 |
| origin over http | `federation.peers[0].origin must be an https origin ...` | M3 |
| origin with a path | same | M4 |
| origin with userinfo | same | M4 |
| origin with a trailing slash | same | M4 |
| peer id equal to self | `federation.peers[0].id ... is already named` | M5 |
| duplicate peer id | `federation.peers[1].id "factory" is already named` | M13 |
| peer label empty | `federation.peers[0].label must be a non-empty string ...` | M6 |
| peer label over 80 characters | `federation.peers[0].label must be ...` | M12 |
| federation not an object | `"federation" must be a JSON object` | M7 |
| self does not match the pattern | `federation.self must match ...` | M8 |
| peers not an array | `federation.peers must be an array` | M9 |
| peer not an object | `federation.peers[0] must be a JSON object` | M10 |
| peer id does not match the pattern | `federation.peers[0].id must match ...` | M11 |
| self missing | `federation.self must match ...` | M15 |
| peer id missing | `federation.peers[0].id must match ...` | M16 |
| peer label missing | `federation.peers[0].label must be ...` | M17 |
| no federation key | loads, no federation output | base |
| B1: target loader refuses federation | `config-console-key` refusal (stack-release.test) | M14 |
Mutant plan (run on Sage's gate only, one edit each, a named test must fail). Before the gate each edit string
must occur exactly once in its file. I checked all of M1 to M17 with grep -cF: each is 1. The
gate's mutant runner checks the count again before it applies each edit:
- M1: `rejectUnknownKeys(value, ["self", "peers"], "federation");` removed. Killed by "federation unknown sub-key".
- M2: `rejectUnknownKeys(peer, ["id", "origin", "label"], where);` removed. Killed by "federation peer unknown sub-key".
- M3: `url.protocol !== "https:" ||` removed. Killed by "origin over http".
- M4: `|| url.origin !== value` removed. Killed by "origin with a path", "origin with userinfo" and
"origin with a trailing slash".
- M5: `if (ids.has(peer.id)) {` changed to `if (false) {`. Killed by "peer id equal to self".
- M6: `peer.label.trim() === "" ||` removed. Killed by "peer label empty".
- M7: `if (!isPlainObject(value)) {` changed to `if (false) {`. Killed by "federation not an object".
- M8: `|| !FEDERATION_ID_PATTERN.test(value.self)` removed. Killed by "self does not match the pattern".
- M9: `if (!Array.isArray(value.peers)) {` changed to `if (false) {`. Killed by "peers not an array".
- M10: `if (!isPlainObject(peer)) {` changed to `if (false) {`. Killed by "peer not an object".
- M11: `|| !FEDERATION_ID_PATTERN.test(peer.id)` removed. Killed by "peer id does not match the pattern".
- M12: ` || peer.label.length > FEDERATION_LABEL_MAX` removed. Killed by "peer label over 80 characters".
- M13: ` ids.add(peer.id);` removed. Killed by "duplicate peer id".
- M14 (stack-release.sh): `||Object.hasOwn(d,"federation")` removed. Killed by the row 203 federation test in
`stack-release.test.mjs`.
- M15: `typeof value.self !== "string" || ` removed. Killed by "federation self missing". Without the typeof
half, an absent self passes the pattern (`/^[a-z]…$/.test(undefined)` is true).
- M16: `typeof peer.id !== "string" || ` removed. Killed by "federation peer id missing". Same reason.
- M17: `typeof peer.label !== "string" || ` removed. Killed by "federation peer label missing". Without it, a
missing label throws a TypeError at `.trim()` (exit 1), not the exit 2 refusal.
## Boundaries
- No config is written. `~/.config/mosaic-dev/config.json` is not touched. Tests use `$SANDBOX` only.
- No change to `packages/cli/src`, `scripts/bootstrap.sh`, or any reader beyond the stack-release probe.
## Manifest (sha256 of committed blobs; the list is the review scope)
- scripts/mosaic-config.mjs
- scripts/test-config.sh
- scripts/stack-release.sh (B1)
- packages/cli/tests/stack-release.test.mjs (B1 test, KEY_MSG)
- packages/cli/tests/stack-release-fixture.mjs (B's loader fixture; without this, the suite fails to load)
- agents/quackerjack/work/queue-203/BUILD.md
## Slot estimate (for Sage's gap)
- test-config.sh: about 5 min, padded.
- The cli suite: about 5 min, padded.
- stack-release.test.mjs: up to 1800 s, per Sage. It copies the loader, so it runs whole at the gate (Webby N3).
- Mutants M1–M13 and M15–M17 each re-run the FED block of test-config.sh. M14 re-runs stack-release.test.mjs.
The per-mutant cost is not measured: this run has not been timed.
## Status
- e704a18ae: the loader, first tests and notes, rebased onto f2735bb6a.
- 8d34a9e94: B1, B2, the fixture fix and the notes rewrite (Mos approved the two old-test edits, mos-1346).
- Third commit on top: B3 cases M15–M17 and these notes (sha in the message to Sage and Webby).
- NOT YET RUN: no node during Gander's slot. The test-config.sh, cli and stack-release gates run when Sage
announces a CLEAR. `bash -n` passes on both shell scripts.
- Not pushed, not claimed. Per invariant 8 the gate runs before any commit is called green.
## Review notes (Webby, webby-0189, answered here)
- B1: fixed as above (scope per sage-2998). The probe's check name `config-console-key` is kept on purpose (Webby
N5, Sage): renaming it would change callers and tests for no gain in this row. The message says "console or
federation key"; the name is the only old word left.
- B3 (webby-0191): missing self, missing peer id, and missing peer label each have a case and a mutant (M15–M17).
The typeof halves are the only thing refusing an absent value, since the pattern test coerces undefined.
- B2: a case and a mutant for each uncovered refusal, plus the trailing-slash case under M4 (N4) and the
duplicate-peer case (M13).
- N1: this file is rewritten with the current base and status.
- N2: consumers checked by `git grep "mosaic-config"`. The loader's output is read by `packages/cli/src/config.mjs`,
`packages/business/src/*.mjs`, `packages/webui/src/sso-config.mjs`, `packages/seat/src/seat.mjs`,
`packages/discord/src/binding.mjs`, `packages/conversation/src/guard.mjs`, and the shell scripts that call
`validate` (bootstrap.sh, common.sh, instance.sh, land-work.sh, stack-copy.sh, stack-release.sh,
conductor-apply.sh). Checked by `git grep "federation"` over packages and scripts: no reader outside the
loader and the stack-release probe mentions `federation`. I did NOT read each call site for which fields it
uses; that is still open for Webby's static read. Not run.
- N3: the cli stack-release tests copy the loader; they run whole at the gate. Listed in the manifest and
the slot estimate.
- N4: trailing-slash case added (see table; M4).
+8
View File
@@ -0,0 +1,8 @@
# Scrooge
Builder and reviewer seat, Claude Opus 5.5, started by Mos for the Q198 review. Owns row 252 under Q220 and reviews row 272.
No launcher in this directory. The seat runs as a T3 thread (thread 629b6cd3) and works in its own worktree. It has no
credentials of its own; it pushes only its own `scrooge/*` branches through the repository credential helper (mos-0981),
never `refactor`, `main` or another seat's branch. Registered by Sage on 2026-10-22 so the queue can name the seat as
row owner under Q220 (mos-1301).
+24
View File
@@ -1525,3 +1525,27 @@ are never rewritten or removed; corrections are new entries.
2026-10-22 11:32 UTC | Sage (Opus 5.5, 1ef1e4f8) | Vikunja 502 landing; 281 and 264a slot reds placed | V502 LANDED 6af85df4b (ff from 980cec88c, Dewey carry dewey-0836). Row 281 c174f37ac and 264a r7 a23afbf95 each went red on first-run test defects. 281 b3 is test-only (T6b ruling accepted by Dewey 0837). 264a r8 pins the 3l seq-0 rows. Megavolt reruns 281 on 6af85df4b.
2026-10-22 13:10 UTC | Sage (Opus 5.5, 1ef1e4f8) | Rows 278 and 281 landings; slot order 281 then 264a r10 then 253 | 278 LANDED 385a7194c (Webby c30588), 281 LANDED e479521d3 (Dewey c30614). DEFERRED: grant gap closed, N1/N2 open. 264a rebasing to r10 on e479521d3; 253 waits on 264a.
2026-10-22 14:22 UTC | Sage (Opus 5.5, 1ef1e4f8) | Row 264a landing; doc sweep | 264a LANDED 84f53540d (Duckworth c30640, #1769 closed c30646, P19 463 c669) after four slot reds ruled in mos-1276 to mos-1279. Doc sweep: V502, 278/281 and 264a BUILD-LOG lines, DEFERRED 281 N1/N2 and 264a N1/N2. 253 rebases onto the landing next.
2026-10-21T16:04:23Z | Gyro (T3 Codex gpt-6.1-sol) | Q195/Q196 design only, #1752 / Vikunja #448, gyro/editor-design | Code-measured writing-editor draft with three options, local thesaurus, pane/pins, guarded API/MCP, DesignCraft handoff and proposed briefs; queue next refused linked worktree, canonical next gyro returned nothing; no implementation or live actions.
2026-10-22T16:02:06Z | Gyro (T3 Codex gpt-6.1-sol) | Q195/Q196 independent-review follow-up, mos-1300, Q220 | Recovered candidate 105811400 and current refactor aca16404c; canonical queue next gyro returned nothing; sent read-only reference drift map to Fenton, whose review is active. Design candidate left unchanged pending verdict. Docs-only fixes authorized on gyro/editor-design, no MVP gate slot.
2026-10-22T16:06:34Z | Gyro (T3 Codex gpt-6.1-sol) | Q195/Q196 fenton-0004 F1-F8 docs fixes, mos-1300, #1752 / Vikunja #448 | Verified findings against aca16404c, retained O3, corrected grants/dependencies/model routing/folds/citations and reduced checklist to six; docs-only candidate for Fenton re-check, no product checks or MVP gate slot.
2026-10-21T22:03:18Z | Della (GPT-6.1-Sol, ludwig-0006 / mos-1227) | PRD 1.11 candidate ea0b2a16d independent read-only review, Vikunja 459 | APPROVED for Sage landing after W12 and required landing gate. Every changed hunk checked against Q175/Q176/Q179-Q181/Q186-Q190/Q195-Q196/Q203/Q209-Q211; exact four-file scope and parent, Q211 quote, 71 ordered ids, version 1.11, max title 120 and both test expectations verified without Node. M1 provider choice stays with Mos/Jason; M2/M4 tracked follow-ups, no scope cuts inferred. Candidate unchanged, no product tests/service/secret/live action; verdict sent directly to Ludwig and Mos.
2026-10-21T22:03:48Z | Della (GPT-6.1-Sol, ludwig-0009 / mos-1229) | PRD 1.11 focused follow-up 73222011f | APPROVED exact head 73222011f2d2901e8e29e53e2c249eed6911f581. Read full PRD-only delta on reviewed ea0b2a16d: Q211 path attributed to Mos and VM provider open on 461, with conditional bundled Pocket ID v1 scope. Static version/71 ids/whitespace pass; JSON/tests unchanged. Node tests remain landing-slot work after W12; M2/M4 follow-ups on 462. Sent exact-pin verdict to Ludwig/Mos, candidate unchanged.
2026-10-21T22:16:56Z | Della (GPT-6.1-Sol, ludwig-0013 / mos-1229) | PRD 1.12 independent review 3576afffb, Vikunja 462 | CHANGES: viewer-own-thread access, thread-owner coverage for shares, stale sandbox/broker risk claim and fixed cited-reply notice exceptions. Checked pinned 193/194/Case2 designs, Q176/Q181/Q182/Q211, source sandbox and requirements diff. Static 82 ids/version/order/unique/title/test-count checks pass; no Node or live tests. Candidate untouched; direct numbered verdict to Ludwig/Mos.
2026-10-21T22:20:09Z | Della (GPT-6.1-Sol, ludwig-0016/0018 / mos-1231) | PRD 1.12 combined r2+r3 review, Vikunja 462 | APPROVED exact head 1509c7cc5747d4927323615afe29f87bfdbb1818. All della-0008 fixes plus owner-owned thread share exception and Mos Q1-Q5 checked. T1.7 463 and playground 464 stay v1 work; residual 465 stays with Jason. Static 82 ids/status/order/title/tests/whitespace pass. No Node or product/live tests. JSON/tests unchanged from r2; landing gate runs both requirements files after 1.11. Direct final-pin verdict to Ludwig/Mos; candidate untouched.
2026-10-21T22:35:30Z | Della (GPT-6.1-Sol, ludwig-0022) | PRD 1.13 independent review e38e755e3, Vikunja 463 | CHANGES: REQ-SESS-2 needs D4 A1 organization-owner own-thread exception to collaborator write-grant condition. Reviewed full diff against Huey 2c843d190 D1-D9/T1-T7/sections 7-8. Static parent/four-file scope/85 ids/version/title/test pins and unchanged ROLE-2 revoke protections pass. Design landing before/with PRD remains required; N2 466 is build dependency. No Node/product/live test; candidate untouched; direct verdict to Ludwig/Mos.
2026-10-21T22:36:30Z | Della (GPT-6.1-Sol, ludwig-0026) | PRD 1.13 focused r2 review, Vikunja 463 | APPROVED exact pin bd1f7d0b125a64bb1c367656b507acb17fab77fe. Owner own-thread and collaborator write-grant cases now match design D4 A1/A2; unproven-count title fixed. Exact parent/two-file delta/85 ids/status/title limits/unchanged test pins/whitespace checks pass. No Node/product/live test. Sage lands Huey 2c843d190 design before/with PRD and runs both full requirements tests on final candidate. Direct exact-pin approval to Ludwig/Mos.
2026-10-21T22:41:00Z | Della (GPT-6.1-Sol, ludwig-0030) | PRD 1.14 independent review, Vikunja 464 | APPROVED exact pin 9bd665976f866a9ee802ddd8470970b99e839eb9. Three playground levels, prefix-first scope, all mapping refusals/codes and null-book/draft lookup behavior checked against Gosalyn 51fdfb7e2 and Webby 7c00c8345. Static parent/three-file scope/85 ordered unique ids/status/title bounds/unchanged JSON titles and bus test/old LIB-2 quote/whitespace pass. No Node/product/live tests; Sage lands the cited ruling before/with PRD and runs both full requirements test files on the final candidate. Direct exact-pin verdict to Ludwig/Mos; candidate unchanged.
2026-10-22T16:09:56Z | Della (GPT-6.1-Sol, mos-1303) | Vikunja 460 Q217/Q222 email-plan settings revision | Canonical queue next della: nothing. Q1/Q2 closed; per-source default-none human-only scope and acceptance tests; Q3-Q7 grouped as settings/review, conditional external decisions only. Docs/static verification; no implementation, live access, secret read or mailbox/account creation. Commit/push only della/email-plan, direct Ludwig/Mos review handoff.
2026-10-22T16:12:26Z | Della (GPT-6.1-Sol, ludwig-0035) | Vikunja 460 email settings focused corrections | REQ-WAIT-2/3 checked at db1e7732a; Jason PRD approval gate restored. Parser/attachment settings/defaults and reviewed resource bounds explicit; Q8 retained as Jason choice. Static docs verification only; corrected pin pushed on della/email-plan for focused Ludwig/Mos review.
2026-10-22T14:26Z | Gizmoduck (Opus 5.5, 9312a896) | rows 264a (#1769) and 268 (#1773) after-landing docs; row 269 plan | 264a LANDED 84f53540d after r13 and r13p4 (mos-1279). Runs doc f7e4ec3df landed (mos-1280 item 1). Row 268 Runs doc 2428c4225 on gizmoduck/row268-runs-b for Sage. Row 216 may resume (268 landed c27207f6e). Row 269 (264b, #1774) plan sent as gizmoduck-0228, not claimed. r10-r13 worktrees removed, branches kept.
2026-10-22 13:06 UTC | Megavolt (Claude Opus 5.5, 81590691) | row 281 #1786 fire and delete revoke host and Gitea grants (owner) | design d1-d6 (Dewey approve dewey-0829), build b1-b5 on megavolt/row281 to row281-e; slot on 7979fb41e green (base 121+24, M1-M22 killed, dewey-0840 files, bus 819, business 231, cli 837, ten shells under 3x limits); docs e479521d3; in-review revs 3203/3204, Dewey APPROVE rev 3207; landed e479521d3 by Sage
2026-10-22T16:36:30Z | Della (sage-2795 / sage-2804, Q220) | Row 224 C2-05b #1725, della/row224 base 4f3fbc25a | Read both pinned briefs and C2-05a source; preparing broker/typed tools and authorized host runtime/bundle seams. No queue command, Node, HTTPS Git, claim, test or push during Gander gate. Dewey reviewer; Rocko/Huey runtime coordination sent. Tests and integration verification deferred to CLEAR.
2026-10-22 16:37:54 UTC | Rocko | row219 planning/code on rocko/row219 aca16404c; scope UI correction requested; no runs/claim.
2026-10-22 | Herb (gpt-6.1-sol, f6cf4287) | row214 C2-11a editor wikilinks authoring | On herb/row214 at aca16404c, authored baseline measurement, both-outcome design, inert inline node and tests under sage-2794/Q220. Claim, runs and pushes held during253; measurement/test results unavailable.
2026-10-22 | darkwing (T3 7525c8e4) | re-pin 86f95bb27->84f53540d window closeout (STATE.md b900d6dc, Gitea #1769/#1742/#1692/#1689/#1782/#1783 comments 30668-30673, window files on darkwing/window-repin-84f53540d 65edf2653); Vikunja 531 scratch move (~/darkwing-scratch -> /mnt/storage/scratch/darkwing-scratch); row 211 (#1709/#426) push guard, installer and slot-busy built on darkwing/row211 6db25b1f0 | window and 531 done; 211 waiting for a CLEAR and review
2026-10-22 | Della | Row258 E01 a8f0cf924 manifest/source review | CHANGES F1 lifetime/state fail-closed, F2 canonical secret placement; N1 pure lifecycle claim, M1-M11 named. Local Git reads/static manifest only; no Node/tests/HTTPS/live access.
2026-10-22 | Della | Row258 E01 3094e3bfa focused manifest review | APPROVED 11-path manifest/static F1/F2/N1 corrections; file and full-blob digest recipes verified. M1-M11 fixed; all execution/landing evidence still required in CLEAR. No shared-log edit, Node, tests, HTTPS Git or live action.
2026-10-22T17:55:40Z | Sage (Opus 5.5, 1ef1e4f8) | Row 253 landing; post-253 doc sweep | 253 LANDED 48e450ce8. Sweep on sage/sweep-253: Gosalyn Case 2 design and rows, 229 design, Gyro 0acbdb0d8, Della 179391055, 268 docs, seat dirs for Della, Scrooge, Huey, Louie and Ludwig, human-users-build:70 under Q173, DEFERRED 253 N1, 198 N2, row 203 text, 224 residual and 206 sharee list, and seat log lines.
2026-10-22 14:48 UTC | Researcher (Sonnet 5.5, 9bac0794) | Rows 220 and 216 resumed (sage-2708, sage-2716, sage-2719); local commits only | 220 e4a204836 on researcher/row220-r2 (on ea6dc9eff): C1 run tests, C2 default-reader M7 test, configDirectory fixture scope, BUILD.md round 2b. 216 4f3fbc25a on researcher/row216-r2 (on aca16404c): index cache, find/get/search/manuscript/universeInfo, PRD 1.14 levels. Nothing pushed, claimed or run; node --check not run. Waits for Sage's CLEAR and go; rebase onto 253 on a new branch before any gate.
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -67,7 +67,8 @@ section 5.5 share filters. Schema 3k to 3l (Sage, 2026-10-20: machine grant row
the #436b build owns 3j to 3k, so this row builds on #436b's landing). The table is append-only, and row 181's restore guard and
`COUNTED` include it. Bus tests for shares and the migration, and mutants 2, 4, 7, 8 and 9 from section 8,
plus mutant 3 (rule 3 dropped), moved here from 193b1 because only shares can kill it (sage-2288).
Going live needs a `--migrate` window with Jason. Sage takes it to Mos when this row is near.
Going live needs a `--migrate` window on Mos's go under Q173 (mos-1310), with a backup first and a STATE report after.
It no longer waits for Jason. Sage takes it to Mos when this row lands.
Gate: Duckworth approves the manifest. The bus suite, the cli suite and every `scripts/test-*.sh` are green on
Sage's gate, and the mutants are killed.
+68 -68
View File
@@ -1,9 +1,9 @@
# Post-v1 email processing and monitoring plan
**Status:** phase C candidate, mos-1223, Vikunja 460. Ludwig reviews before Sage lands it.
**Status:** Q217/Q222 revision, mos-1303, Vikunja 460; Ludwig reviews before Sage lands it.
**Goal:** Stack-owned mail intake, monitoring, authorized reading/routing and local drafting for every harness.
**Boundary:** all rows post-v1, none required for 2026-10-27. No PRD 1.10 or live change here.
Q210 ready rows go to non-v1 seats after handoff. No ready label authorizes live connections,
Q210/Q220 ready rows proceed on non-v1 seats after handoff. No ready label authorizes live connections,
addresses/accounts, DNS/MX, exposure, spend or external mail. Original plans/reviews stay unchanged.
R21: **“The email specialist reads and drafts, never sends.”**
@@ -78,7 +78,11 @@ unnecessary. Any later finding is reported by path and commit only, never by val
per configured source. It owns connection pools, OAuth refresh, scheduling, cursors and intake. A harness can
stop, restart or change without losing monitoring. Multiple accounts are isolated by instance/business/source
ID. “One service” does not mean one global credential, mailbox or shared authorization. Default installation
is disabled and opens no connection.
is disabled and opens no connection. Per-source `mailboxScope` defaults to `[]` (none), including
agent ingress. A human sets exact mailbox IDs through the audited CLI and later Console; no agent session
may change this setting, even to narrow it. Recheck scope before scan/fetch and fence removed mailboxes
before returning in-flight content. Effective reads intersect source scope, provider rights and current
principal grants. Enrollment never grants agent body access. No personal mailbox is enrolled under Q222.
Use Node modules aligned with the Stack, adapting the Python design rather than embedding both applications
and their separate databases/authentication/UI. A Python sidecar would reuse more syntax but duplicate
@@ -112,7 +116,8 @@ are protocol references checked 2026-10-21, not evidence of installed support.
**D3 Addressing.** Version a route table mapping trusted delivery evidence to a stable Stack user or
role-instance ID, never a display name or a newly invented role. The org file holds this human-approved table
through its versioned audited write path. No agent reroutes messages or widens its own route. A route grants
no read right. The first source follows Q1’s provider. Offline adapters need not wait for that choice. Review
no read right. Q217 selects one agent mailbox on woltje.com at InMotion, created by Jason; its offered
protocol pilots first (IMAP expected, not yet verified). Exact address/slot enrollment awaits Jason. Review
maintained exact-pinned IMAP/MIME dependencies in their owning rows, initially Ludwig’s
`imapflow`/`mailparser` candidates. Do not write either protocol/parser in-house. Aliases on an approved
ingress mailbox are the recommended inexpensive start. Plus-addresses are acceptable only after fixtures and a
@@ -142,8 +147,11 @@ deliveries and 100 MiB of retained content initially. Persist quota accounting a
Reaching a quota holds new intake and raises an alert, while read-only health checks continue. First
activation establishes a from-now baseline without actions on old mail. Historical import is a separate
approved time/count window, previewed before activation. Cap violations, disk-full and unavailable audit
storage hold work without advancing past unstaged mail. These limits are proposed settings to validate, not
measured capacities.
storage hold work without advancing past unstaged mail. Mail-owned limits/caps/scopes are settings with
these defaults, not hardcoded policy; tuning cannot override the Q198 manager contract.
Human-only audited CLI, then Console, changes tune settings within resource bounds set by implementers and
reviewers in the versioned schema, backed by resource-exhaustion tests in E01/E04; widening bounds needs a
reviewed schema/test change. Agents cannot change protective settings; invalid settings fail closed. No gate bypass.
Provider identity is scoped to business/source/account: Graph immutable ID, IMAP folder/UIDVALIDITY/UID or
POP3 UIDL. RFC Message-ID, thread ID and content similarity are secondary correlation signals, never sole
@@ -155,13 +163,15 @@ expiry. Replay needs a new authorized intent, parent link and current grants. A
executes preview work.
**D5 Parsing and rules.** Preserve provenance for normalized sender, recipients, timestamp, subject, text,
thread references and attachment descriptors. Bound MIME depth to 10, parts to 100 and normalized text to 256
KiB. Decode safely, flag malformed or truncated content and quarantine failures. Prefer text, sanitize HTML
thread references and attachment descriptors. Human-tunable settings (D4) default to `parserInputBytes: 30 MiB`,
`mimeDepth: 10`, `mimeParts: 100`, `normalizedTextBytes: 256 KiB`. E04 tests changed values and bound refusals.
Decode safely, flag malformed/truncated content and quarantine failures. Prefer text, sanitize HTML
for display, disable remote images/URLs and never execute active content. Rules use finite allowlisted
fields/operators, stable priority and first-match by default. Reject executable rules and unbounded regex.
Record rule/version and routing reason. Unmatched mail is held for its authorized owner, not dropped or copied
fleet-wide. Routing a user notification does not launch an agent. Agent wake-up is a separate Q198-admitted
work request. Wake requires an approved route, current access grant, sender allowlist and trusted aligned
work request. `unattendedWake` defaults false; a human may enable it per route and set its empty-by-default sender
allowlist. When enabled, wake requires an approved route, current access grant and trusted aligned
DMARC authentication. Strip/ignore sender-supplied Authentication-Results. Ambiguous forwarded/list mail or
failed authentication goes to restricted review.
@@ -177,7 +187,8 @@ intake working and model-dependent work held. Summaries/drafts run as ordinary g
extra mail-service model client.
**D6 Attachments.** Initially enumerate metadata only. An authorized explicit fetch quarantines bytes under an
opaque ID, with a 10 MiB per-part and per-message total cap. Raw quarantine fetch needs no scanner. It never
opaque ID; human-tunable `attachmentPartBytes` and `attachmentMessageBytes` each default to 10 MiB (D4 bounds).
E04 tests changed caps and out-of-bounds refusal. Raw quarantine fetch needs no scanner. It never
implies permission to expose extracted content. Read-only files are not execution prevention.
Scanner/extraction is gated E16. Never trust filenames as paths, unpack archives, execute macros or follow
embedded links. Content extraction requires isolated no-network processing, resource bounds, scanner success
@@ -193,9 +204,10 @@ Protected key expiry/retirement metadata also stays outside the mail-backup rest
of ordinary logs. Default durable records hold opaque IDs, state, versions and counts, not bodies, subjects,
recipient lists or attachment names. Content access is explicit and audited.
Recommended content policy for an enabled pilot: encrypted bodies and local drafts for 30 days, explicitly
fetched attachments for 7 days, minimal metadata/tombstones for 365 days. Without Jason's approved
content/retention policy, persist metadata only and show that later content fetch requires the source online.
Content settings default to `persistContent: false`, bodies/drafts 30 days, explicitly fetched attachments
7 days and minimal metadata/tombstones 365 days. A human may set these through audited CLI, later Console,
within approved retention authority and holds. Until that authority exists, persist metadata only and show
that later content fetch requires the source online. A duration setting never authorizes deletion.
Expired content returns an explicit unavailable result. Define backup/key expiry and restore suppression
together: restoring an old backup must not resurrect expired content or redeliver old work. Use tested
backup-copy deletion or crypto-erasure with expiry of every relevant key copy/escrow, plus restore
@@ -225,7 +237,7 @@ every user's mail. Cross-business IDs return no data.
New mediated access uses proposed `mail.access`, distinct from provider credential creation through
`credential.mint`. The chief raises it to the authenticated human Approve path, with exact
principal/target/rights/expiry bound once. Add its access grant schema and action routing only through a
Jason-approved PRD version and reviewed vocabulary/role changes. Until then new actions remain hard waits.
Jason-approved post-v1 PRD version and reviewed vocabulary/role changes (REQ-WAIT-2/3). Until then hard waits apply.
Grant creation stays human-only. Read/draft actions become routine only inside approved grants. Manual mode needs
per-use approval. A hand-edited grant or approving email creates no authority.
@@ -309,28 +321,29 @@ have landed. Sage queues it now and assigns an available non-v1 seat when prereq
without a scheduled start or calendar wait. WAIT Qn identifies the positive authority needed before that row's
governed integration. Unanswered questions never block independent READY work. All rows remain disabled by
default, use synthetic mail/fake providers and run named tests plus affected existing suites. No ready row
enables a real source or weakens current grants/hard waits. Q3/Q9/Q10 propose keeping restrictive defaults,
not gates on code that preserves them. Q7 is a technical dependency review, not a new permission gate. Q11
enables a real source or weakens current grants/hard waits. Q3-Q7 are settings/review work, not unanswered
Jason choices. PRD/grant/deletion gates still apply;
Q9/Q10 retain external-action boundaries. Q7 dependency review adds no permission gate. Q11
affects neither build nor pilot because no old repository/service is altered. Queue enrollment/landings belong
to Sage after Ludwig approves this plan, with the v1 path and protected files preserved.
| Row / gate | Deliverable and dependencies | Acceptance tests (proposed filenames, not run here) |
| --- | --- | --- |
| E01 **post-v1 READY**; Jason: none for draft | PRD addendum draft, source/data/access/service and external content-key location/lifecycle schemas in `docs/prd/`, `packages/mail/schemas/`. Core mail-source registration/policy activation awaits approved addendum/Q4; content policy awaits E13/Q6. | `packages/mail/tests/schema.test.mjs`: unknown fields, wildcards, secret values, incomplete scopes and mismatched principal/source rejected; key location excludes dataRoot/backups, mode/symlink checks, disabled schema compatible with v1. Every requirement maps to a row. |
| E02 **post-v1 WAIT Q4** | Protected slot reader, source-service enrollment/credential lifecycle and separate `mail.access` grant action in `packages/{bus,business}/`. After E01 and approved PRD. No real credential enrollment in this build row. | `packages/bus/tests/mail-grants.test.mjs`, `packages/bus/tests/mail-principal.test.mjs`: non-chief/nonhuman approval, modified targets, expiry, duplicate consume, hand grants, failed issuance undo, manual mode, immediate revoke/in-flight fencing and secret sentinel leakage. Reject repo/dataRoot slots; existing grant tests pass. |
| E01 **post-v1 READY**; Jason: none for draft | PRD addendum draft, source/data/access/service and external content-key location/lifecycle schemas in `docs/prd/`, `packages/mail/schemas/`. Core mail-source registration/policy activation awaits approved addendum/Q4; retention authority awaits E13; settings use Q6 defaults. | `packages/mail/tests/schema.test.mjs`: unknown fields, wildcards, secret values, incomplete scopes and mismatched principal/source rejected; key location excludes dataRoot/backups, mode/symlink checks, default mailbox scope empty, disabled schema compatible with v1. Every requirement maps to a row. |
| E02 **post-v1 WAIT Q4** | Protected slot reader, source-service enrollment/credential lifecycle and separate `mail.access` grant action in `packages/{bus,business}/`. After E01 and approved PRD. No real credential enrollment in this build row. Add human-only per-source mailbox-scope CLI writer; Console parity follows in E13. | `packages/bus/tests/mail-grants.test.mjs`, `packages/bus/tests/mail-principal.test.mjs`: non-chief/nonhuman approval, modified targets, expiry, duplicate consume, hand grants, failed issuance undo, manual mode, immediate revoke/in-flight fencing and secret sentinel leakage. Default none makes zero reads; human mailbox-scope change audited; agent change (direct/tool/proxied) refused, revoked scope fences in-flight reads, widening creates no agent grant. Reject repo/dataRoot slots; existing grant tests pass. |
| E03 **post-v1 READY**; Jason: none | Durable scoped ledger, encrypted state, leases, cursor/continuation, outbox and quota accounting in `packages/mail/src/{store,intake,service}.mjs`. After E01. No dependency on live grants. | `packages/mail/tests/intake.test.mjs`, `packages/mail/tests/store.test.mjs`: crash every staging/delivery boundary, concurrent owners, missing/reused Message-ID, source-scoped identity collisions, 1,000/100-MiB quotas, disk/audit failure, restart and zero-connection disabled startup. Missing/expired key fails closed; restored backup with expired key returns unavailable, never plaintext. No skips or duplicate recipient effects. |
| E04 **post-v1 READY**; Jason: none, Q7 advisory | Bounded MIME parsing/metadata quarantine in `packages/mail/src/{parse,attachments}.mjs`. After E01; independently of grants. Review license/advisories and exact-pin maintained MIME dependency. | `packages/mail/tests/parse.test.mjs`, `packages/mail/tests/parser-corpus.test.mjs`: multipart, bad charset, missing IDs, S/MIME, 30-MiB input, depth/part/text limits, traversal, hidden HTML, comments, bidi/zero-width display tricks, archive and encrypted attachment quarantine. Fuzz corpus; no expansion/execution/network, no content logs. |
| E05 **post-v1 READY** offline; core registration **WAIT Q4** | Graph descriptor/module in `packages/plugins/mail-source/m365/` against test-local registry `packages/mail/tests/fixtures/mail-source-registry.mjs`. After E01/E03/E04. `packages/plugins/src/kinds.mjs` stays unchanged until E01 addendum approval/Q4; core entry lands together with or after that approved addendum, with its first production module. Q1 selects pilot, not offline work. | `packages/mail/tests/m365.test.mjs`, `packages/mail/tests/source-contract.test.mjs`: fake Graph delta/paging, immutable moves, token/cursor expiry, 401 single refresh, 429/5xx scheduling, restart, denied mailbox, additive-scope check and alias/plus/Bcc provenance. No source writes; test-local registry works with unchanged core registry before approval. Existing plugin suite passes. |
| E06 **post-v1 READY**; Jason: none, Q7 advisory | IMAP module `packages/plugins/mail-source/imap/`. After E05's test-local shared contract; no core registration before approved E01/Q4. Review/license/exact-pin maintained IMAP dependency. | `packages/mail/tests/imap.test.mjs`: local fake TLS server, certificate denial, selected-folder scope denial, BODY.PEEK/no Seen or STORE/EXPUNGE, IDLE drop/poll fallback, renumbering, UIDVALIDITY/move reconciliation and alias/plus/Bcc/forged-header provenance. Ambiguity holds effects. |
| E07 **post-v1 READY**; Jason: none for pure evaluation | Address/rule evaluator `packages/mail/src/{addresses,rules,route}.mjs`. After E01/E03/E04; no org-file activation or delivery privilege. | `packages/mail/tests/rules.test.mjs`, `packages/mail/tests/routing.test.mjs`: port all 36 prior rule cases; bounded operators, rule version/order, previews never execute, history import window, recipient ambiguity, multiple recipients, retired agents, repeated identical mail and no content logs. |
| E08 **post-v1 WAIT Q4/Q5** | Approved org route writer and narrow service delivery API/principal in `packages/{bus,business}/`, `packages/mail/src/delivery.mjs`. After E02/E03/E07. No agent route writer/reroute. | `packages/bus/tests/mail-delivery.test.mjs`, `packages/bus/tests/mail-routes.test.mjs`: exact human approval and versioned changes, fixed actor/business, recipient receipt idempotency, spoofed principal refused, route addition grants no read, revoke before dispatch fences content, no human-input impersonation. |
| E08 **post-v1 WAIT Q4 governance** | Approved org route writer and narrow service delivery API/principal in `packages/{bus,business}/`, `packages/mail/src/delivery.mjs`. After E02/E03/E07. No agent route writer/reroute. | `packages/bus/tests/mail-delivery.test.mjs`, `packages/bus/tests/mail-routes.test.mjs`: exact human approval and versioned changes, wake off/empty allowlist by default, human-only wake/route edits, fixed actor/business, recipient receipt idempotency, spoofed principal refused, route addition grants no read, revoke before dispatch fences content, no human-input impersonation. |
| E09 **post-v1 WAIT Q4** | Mediated typed/MCP tools, per-message thread view and local drafts in `packages/{bus,harness}/`, `packages/mail/src/access.mjs`. After E02/E08. | `packages/harness/tests/mail-tools.test.mjs`, `packages/mail/tests/access.test.mjs`: same schema/results/refusals on all supported harnesses, separate metadata/body/attachment/draft rights, page caps, thread filtering, cross-business ID denial, revoke/cache races, manual mode, local ack/draft and absence of send/reroute tools. |
| E10 **post-v1 WAIT Q5** | Restricted mail-triggered launch profile, sender-auth gate and origin propagation in `packages/{mail,harness,bus}/`. After E08/E09 and Q198 admission interface. | `packages/mail/tests/mail-turn.test.mjs`, `packages/mail/tests/injection.test.mjs`: fresh context/grant intersection, no git/credential/external tools at broker and harness, body/subject/display-name/filename injection, forged auth headers, aligned DMARC/allowlist, forwarded ambiguity, no resume of powerful thread, no authority laundering through fleet messages/decisions. |
| E10 **post-v1 READY** offline; governed activation after E08/E09 | Restricted mail-triggered launch profile, sender-auth gate and origin propagation in `packages/{mail,harness,bus}/`. After E08/E09 and Q198 admission interface. | `packages/mail/tests/mail-turn.test.mjs`, `packages/mail/tests/injection.test.mjs`: disabled wake never launches; enabled human-configured allowlist still requires DMARC/grants/admission; fresh context/grant intersection, no git/credential/external tools at broker and harness, body/subject/display-name/filename injection, forged auth headers, aligned DMARC/allowlist, forwarded ambiguity, no resume of powerful thread, no authority laundering through fleet messages/decisions. |
| E11 **post-v1 WAIT Q4** (mail decision schema approval) | `packages/mail/src/decisions.mjs` using the one approved Q207 decision API; no local model client. After E07/E09 and implemented Q198 decision module. | `packages/mail/tests/decisions.test.mjs`: 4-KiB closed schema, no body/free text, extra metadata denied until approved, out-of-enum/missing/uncalibrated confidence held, central fallback only, unavailable model leaves deterministic intake working; useful metadata-only outcomes or explicit unknown. |
| E12 **post-v1 READY**; Jason: none for synthetic recovery | Health/manager adapter `packages/mail/src/health.mjs`. After E03/E08 and implemented/verified SM D13-D15; fixtures can precede integration. | `packages/mail/tests/health.test.mjs`, `packages/mail/tests/outage.test.mjs`: mailbox/folder scope proof, full 60..600 cooldown and Retry-After, independent probes/one retry owner, bad-auth single retry then stop, no model probes or outage DLQ, synthetic canary, five-item/30-second batches, three-exit latch/release, human kill, alert-once/recovery and source gap reporting. |
| E13 **post-v1 WAIT Q4/Q6** | Authorized CLI/Console mail/draft/digest views and content/export/retention handlers in `packages/{mail,cli,webui}/`. After E09/E12. No live purge; keep `data.delete` hard wait. | `packages/mail/tests/retention.test.mjs`, `packages/cli/tests/mail.test.mjs`, `packages/webui/tests/mail.test.mjs`: per-user ACL on every item/export, separate decision inbox, exact deletion approval/holds/receipts, expiry of backup content and all relevant keys, restore suppression/tombstones, local draft ownership and metadata-only digest. |
| E13 **post-v1 WAIT Q4 governance/deletion authority** | Authorized CLI/Console mail/draft/digest views and content/export/retention handlers in `packages/{mail,cli,webui}/`. After E09/E12. No live purge; keep `data.delete` hard wait. | `packages/mail/tests/retention.test.mjs`, `packages/cli/tests/mail.test.mjs`, `packages/webui/tests/mail.test.mjs`: human-only Console mailbox/settings parity with CLI, default metadata-only and 30/7/365 settings, refused agent edits, per-user ACL on every item/export, separate decision inbox, exact deletion approval/holds/receipts, expiry of backup content and all relevant keys, restore suppression/tombstones, local draft ownership and metadata-only digest. |
| E14 **post-v1 READY**; Jason: none for offline proof | Operator runbook/acceptance packet in `docs/`; offline end-to-end harness test. After E01-E13. | `packages/mail/tests/predeploy-safety.test.mjs`, `packages/mail/tests/end-to-end.test.mjs`: port all 15 safety-gate cases; synthetic intake → route → authorized tools → local draft across supported adapters, zero source connections while disabled, from-now baseline, bounded preview, revoke/outage/restart and disable/rollback with evidence preserved. |
| E15 **post-v1 WAIT Q1/Q4/Q5/Q6**, plus Q2 if personal mail | Explicit separately authorized live pilot: enrolled agent ingress source, approved grant/route/content policy and reviewed re-pin. After E14. No v1 gate or external test mail. | `packages/mail/tests/pilot-reconcile.test.mjs` validates a redacted identity-level acceptance manifest; authorized operator supplies live receipts for delivered/held/missing identities, recipient proof, health, restart/revoke and bounded catch-up. Counts alone prove neither loss nor duplication. Complete on coverage/evidence, not a mandatory week or scheduled wait. |
| E15 **post-v1 WAIT** Jason mailbox provisioning, Q4 governance and live-pilot authorization | Explicit separately authorized live pilot: enrolled agent ingress source, approved grant/route/content policy and reviewed re-pin. After E14. No v1 gate or external test mail. | `packages/mail/tests/pilot-reconcile.test.mjs` validates a redacted identity-level acceptance manifest; authorized operator supplies live receipts for delivered/held/missing identities, recipient proof, health, restart/revoke and bounded catch-up. Counts alone prove neither loss nor duplication. Complete on coverage/evidence, not a mandatory week or scheduled wait. |
| E16 **post-v1 OPTIONAL WAIT Q8**, plus Q4 access | Isolated scanner/content extractor only if Jason chooses attachment content support, `packages/mail/src/extract.mjs`. After E04/E09, separate reviewed dependency/resource boundary. | `packages/mail/tests/extract.test.mjs`: no-network/resource isolation, scan failure/unavailable/encrypted/unsupported hold, execution/archive expansion prohibited, hostile extracted text marked untrusted, access/revoke checked before release. No scanner implies no extracted-content feature. |
Run proposed Node files with `node --test <filename>`; owning reviewers select affected existing suites and
@@ -338,62 +351,49 @@ require green evidence before commit/landing. Pure store/parser/rule rows can pr
approval work. Later adapters Gmail API/JMAP/POP3 require a named need and separate rows, not a committed
expansion of this add-on.
## Questions for Jason (merged list, recommendations are not approvals)
## Decisions and settings (stable Q1-Q11 identifiers)
1. **Q1 Pilot provider/domain/addresses:** which controlled domain, provider and agent ingress mailbox,
aliases or plus-tags? Recommend one approved non-USC agent mailbox, aliases after recipient proof, no
catch-all/MX change. Dedicated mailbox only for approved isolation. Its protocol pilots first; build both
adapters offline meanwhile.
Q217/Q222 settle Q1/Q2; mos-1303 separates settings from external decisions. Schema names are proposals.
Changes are validated/revisioned/audited with human identity; agent proxies cannot pose as human writers.
Configuration never replaces grants, PRD or external authority.
2. **Q2 Personal mail scope:** which existing user mailbox, if any, may be read? Recommend none initially.
Later enrollment names user/mailbox and exact consent or delegation, separately from agent ingress.
1. **Q1 CLOSED, Q217:** Jason creates/names one agent mailbox on woltje.com at InMotion and fills its protected
slot. No agent account creation/catch-all/MX change; aliases after delivery proof. Verify protocol before pilot.
2. **Q2 CLOSED, Q222:** no personal mailbox now. Per-source `mailboxScope: []` defaults none; exact IDs are
human CLI/Console settings (D1/E02/E13). Later personal enrollment needs user consent/delegation and grants.
3. **Q3 Source mutation:** mark read, flag, label, move or save provider drafts? Recommend no, read-only
sources and Stack-local drafts/acknowledgments.
### Group (a): Q3-Q7 settings and ordinary technical review
4. **Q4 Service/access grants:** approve protected source-service credential ownership, distinct
per-agent/user `mail.access` approval, mail-source kind and closed mail decision schema? Recommend yes through reviewed
post-v1 PRD/vocabulary changes, chief/human approval, exact targets/rights/expiry/revoke. New access action
stays a hard wait until the approved PRD explicitly routes it. Provider credential creation remains
distinct.
| Item | Setting and default | Who may change it; remaining boundary |
| --- | --- | --- |
| Q3 source mutation | `sourceMutations: []`; sources read-only, drafts/ack local | Human-only configuration within reviewed adapter capabilities. No mutation capability is implemented in this increment. Enabling provider writes needs a reviewed extension and exact provider authority; this setting cannot enable sending or override R21. |
| Q4 service/access | `sourceEnabled: false`, `mailboxScope: []`, access grants empty, `accessMode: manual` | Authenticated human changes source settings; chief raises exact per-principal grants and human approves through existing approval boundary. `mail.access`, service principal, mail-source kind and closed decision schema still require approved PRD/vocabulary extensions before integration. Routing `mail.access` out of hard waits requires a PRD version Jason approves under REQ-WAIT-2/3: an existing gate, not a new Q number. Q211 is not assumed to waive it. No self-grant or source credential shared with agents. |
| Q5 unattended wake | `unattendedWake: false`, `wakeSenderAllowlist: []`, route table empty | Human-only CLI/Console writer. When enabled, require allowlist AND trusted aligned DMARC, approved route/current grant, restricted profile and Q198 admission. Manual mode prevails; ambiguous/failing mail holds. No per-message Jason question inside this approved envelope. |
| Q6 retention | `persistContent: false`, `bodyDraftDays: 30`, `attachmentDays: 7`, `metadataDays: 365`, `automaticPrune: false` | Human-only writer, respecting holds and approved retention authority. Metadata-only default; encrypted content only within approved authority. Settings never grant `data.delete`; exact deletion approval or explicitly approved retention authority, backup/key expiry and restore suppression remain required. |
| Q7 dependencies | `sourceAdapter` unset until human enrollment; reviewed exact-pinned IMAP/MIME modules, initially imapflow/mailparser candidates | Humans select enrolled adapters via CLI/Console. Implementers/reviewers choose and pin maintained modules under repository license/security checks in E04/E06; not a Jason product question. Paid dependencies or external services go to group (b). |
5. **Q5 Unattended wake:** which senders/routes may wake granted agents without per-message approval?
Recommend allowlist plus trusted aligned DMARC, approved route, restricted fresh profile and Q198
admission. Manual mode overrides this. Ambiguous/failing mail quarantines. Neither identity nor email
approves external effects.
### Group (b): real Jason decisions, only when needed
6. **Q6 Content/retention/deletion:** what may be retained, for how long, with what holds? Recommend encrypted
30-day bodies/drafts, 7-day explicitly fetched attachments, 365-day minimal evidence/tombstones,
metadata-only until approved. Keep exact `data.delete` authorization, backup/key expiry and restore
suppression.
**No new external question is needed for default offline work.** Existing Q4 approval is still required;
Mos asks one at a time if a later increment requires:
7. **Q7 Dependencies:** maintained IMAP/MIME packages after license/security review and exact pins rather than
custom clients/parsers? Recommend yes, investigate Ludwig's imapflow/mailparser candidates in owning rows.
Routine technical review unless an existing repository policy adds an approval gate; no calendar wait for
package review.
- A new provider account/license, paid dependency/service, new spending, or outside-party permission beyond
Q217's Jason-created mailbox. Recommend use the existing InMotion service and free reviewed dependencies.
- New exposure/listener/webhook/DNS/MX/addresses/accounts: recommend none. Q217 creation is Jason’s existing task.
8. **Q8 Attachments:** allow bytes/extraction and require scanning? Recommend metadata first, explicit capped
raw fetch into quarantine, no scanner for opaque quarantine. Extracted content waits for isolated
no-network processing, resource limits and a scanner. Do not enable arbitrary execution or automatic
archive expansion.
Q4 Jason PRD approval/grants and Q6 deletion gates persist. No live access is authorized; offline work proceeds.
9. **Q9 Exposure/spend:** new listener/webhook, DNS/MX, paid infrastructure or model spend? Recommend none
initially. Outbound monitoring/internal alerts suffice. Every later exposure, address/account change or
spend needs its own authorization.
10. **Q10 Sending:** should this add-on send/forward/reply? Recommend no. R21 remains permanent for the
specialist. A future separate sender needs its own approved plan and exact-recipient human approval
through `message.external`.
11. **Q11 Prior repositories/service:** is the configured USC deployment still running, and should either
repo/service be archived or retired? Recommend no operational change here. Preserve both histories, use MP
as consolidated design reference. Any archival/retirement is a separate authorized task after actual-state
verification.
8. **Q8 Attachments:** metadata first; capped raw quarantine fetch. Extraction remains Jason’s choice (E16),
with isolation/scanning, no execution/archive expansion. Group (b) is not the full list of Jason gates.
9. **Q9 Exposure/spend:** none initially; later changes need their own authorization as group (b) describes.
10. **Q10 Sending:** remains out of scope. Recommend no sending/forwarding/replying; R21 remains permanent
for the specialist. Any future separate sender needs its own plan and exact-recipient human approval
through `message.external`; it is not a configurable bypass of this add-on's boundary.
11. **Q11 Prior repositories/service:** preserve histories/USC service; status unverified. Retirement is separate.
## Verification and handoff
Sources: Della 191dff32f, Ludwig 4a27b73ac, reviews 9182134e5/143415779, Mos's routing ruling.
Verify citations, coverage, row gates/tests and unchanged original blobs. No product suite,
live mail, secret-file read, install or service change here. After Ludwig approval, send Sage
this pin for refactor landing and READY-row queueing on non-v1 seats, copying Mos.
Resolve review changes without a calendar wait. Jason-gated work waits on its numbered decision.
Verify coverage, row gates/tests and unchanged originals. No product suite, live mail, secret read or service change.
After Ludwig approval, send Sage this revision for landing/row-gate updates on non-v1 seats, copying Mos.
Q220 authorizes progress without calendar waits, not live access/gate bypass. Q217/Q222 applied; originals unchanged.
@@ -0,0 +1,835 @@
# Q195 writing editor design
Gitea #1752, Vikunja #448, DECISIONS Q168, Q177, Q194, Q195 and Q196. Design for review, not build
authority. Originally measured at `6c75887b0`, rechecked against `origin/refactor` at
`aca16404c` for Fenton's fenton-0004 review. Repository line references use `aca16404c` unless
a different revision is named. WordCraft and DesignCraft retain their separately pinned revisions.
Work is confined to `gyro/editor-design`.
## Outcome and boundaries
D1. Give Melanie a familiar Word-like writing interface inside the Console. She writes, saves,
selects a word for synonyms, and moves between documents without losing her draft or chat. Her
corpus includes hundreds of .doc files, several .qxd books and recent Markdown. Obsidian is not the
interface model. Ship a useful writing slice before legacy import. This add-on does not gate the
October 27 Markdown-library MVP, which Q196 leaves unchanged. Editor build gates are
post-October 27 work and never take the gate slot ahead of MVP rows. Design and review
continue now under Q220.
D2. Q196 withdraws the earlier assumption that the current Markdown editor must be the solution.
Compare all three options below. WordCraft's canvas/WebGPU browser UI
(`apps/wordcraft-web/Cargo.toml:3,26`, `index.html:17`) has a different rendering and build system.
Read-only reference checkout: `/mnt/storage/src/artcraft/wordcraft`, commit
`e05b7fe86f3b85454000dffc321de638d7009817`. No code is copied by this design. Any later copy must
preserve its applicable MIT or Apache-2.0 license and notices.
D3. No direct agent writes. Proposals appear outside the document until Melanie accepts one, then
become an undoable draft edit. Saving remains a separate human action. Q176 and Case 2 C2-06 already
prohibit agent prose and constrain corrections. Q195 does not explicitly remove that rule. The
design below specifies the interface, but agent access to unsaved text and new replacement-prose
permissions remain gated on Jason's choices, not silently enabled.
All examples and tests use invented fixtures. No books content, host access, live stack changes,
role changes, queue changes or access to `~/.mosaic-dev` is part of this assignment.
## What exists
| Code | Measured finding and implication |
|---|---|
| F1 | Row 154 landed as `9353675d1` (#1648). `packages/webui/editor/model.mjs:16-18` imports ProseMirror and markdown-it. `view.mjs:162` mounts the DOM editor with history, formatting, lists and a link bar. `model.mjs:1-14` and the serializer retain original blocks unless edited. Front matter and unsupported structures remain source blocks. This is the implementation to extend. |
| F2 | The actual vendor path is `packages/webui/third_party/editor/`, relative to the WebUI package, not repository-root `third_party/editor/`. `PROVENANCE.json:1-13` records 170 files, 19 packages and no local changes. `NOTICE:1-10` names tagged source, hashes and license paths. `editor/build.mjs:16-24,34-56` aliases source and produces an unminified bundle with inline legal notices and hashes. `editor/BUILD.json:7-14` records 758,420 JS bytes and 4,462 CSS bytes. These are checked against bytes, not inferred from the README. |
| F3 | `packages/webui/src/public/bus.js:892-911,949-951` keeps a shared Markdown draft and falls back to Source when exact round-trip fails. Redrawing destroys the editor. `editor/view.mjs:183-205` creates a new history on `setText`. The README's “Visual editor” section (`packages/webui/README.md:572`) records this limit. A writer needs a stable editor mount while chat polls. |
| F4 | Row 148's save is `POST /api/project-file`, with exactly `{project,path,base,content}` (`bus.js:999-1010`, `serve.mjs:522-555`, keys :527 and handler :531). Authentication, exact Origin, Fetch Metadata, strict keys and a 1 MiB request bound apply. `packages/project/src/index.mjs:244-260` restricts it to a human and refuses a changed file base. `paths.mjs:12-15` allows only `spec/north-star.md` and direct `docs/*.md`. This route cannot save book paths today. Do not widen its regexp to admit the vault. |
| F5 | The Console already has chat projects, named threads, message paging, send and explicit resume (`packages/webui/src/chat.mjs:19-27`, `public/chat.js:9-14,66-70`). Chat has its own route and render lifecycle (`public/bus.js:1124-1128`), so Docs and Chat currently replace each other. `chat.js:68` renders bodies as escaped text. There is no document pin in these structures. |
| F6 | Sessions are durable thread identity plus replaceable runs. Row 121 landed as `2c16d6b93` (#1615). `packages/bus/src/broker.mjs:845-853` exposes thread state and idle release. Thread creation at `:5716-5728` checks FED R3 grants; open/send at `:5766-5780` also use `#grantedThread` (`:790-793`) after thread ownership checks. Open requests launch explicitly. Current chat uses the thread's default session; pins remain thread IDs, never individual runs. Existing tester isolation is in rows 177/182 and `chat.mjs:1-4`. |
| F7 | C2-11a, row 214, owns `editor/model.mjs`, bundle output and editor tests (`docs/plans/2026-10-20_case2-rows.md:235`). It measures and preserves wikilink forms. All proposed changes under `editor/` wait behind it. C2-10b owns `public/bus.js`, its stylesheet, `serve.mjs` and WebUI tests (`:784`). It supplies cited items, scope badges and sync status. Our shared-file rows wait behind it and reuse its renderer. Neither brief proves that its feature has landed. |
| F8 | Sage's input via mos-1179: row 220, C2-10a (#1721), makes the conversation reader hide creative sessions and refuse direct opens with `conversation-not-listed` (`case2-rows.md:522`). Row 198 (#1696) retires the 7331 board writer, currently referenced at `serve.mjs:14`. This design uses neither raw session files nor the board route. It does not depend on row 5 (waiting on Jason) or parked row 29. |
Original draft verification at `6c75887b0`: `node --test packages/webui/tests/editor-provenance.test.mjs
packages/webui/tests/editor.test.mjs` passed 10 tests with 1 explicit skip. The skip is the rebuild
because pinned esbuild 0.28.2 is absent. The source tree, output hashes, licenses, route serving and
exact round-trip tests passed. This is fixture evidence, not a live check.
## Options and recommendation under Q196
O1. Extend row 154 into a Word-like DOM interface. Reuse its safe source-built editor, add a
File/Home/Review ribbon, named heading/paragraph styles, a document navigation pane, familiar
shortcuts, status bar and synonyms. It preserves browser text behavior and the current CSP. The
measured JS/CSS sizes are F2, not an estimate. Its current schema has no footnote, comment or
tracked-change nodes (`editor/model.mjs:27-114`), no page layout engine and no DOCX writer.
Word-like controls do not create those features. This option needs a production export path before
it meets Q196's DesignCraft requirement.
O2. Put WordCraft's engine and canvas interface in the document pane. It offers Word-like commands
and rich file support rather than recreating all of them in ProseMirror. Louie's scratch evaluation
is the source for build/runtime and round-trip measurements below. Its own browser file
pickers/downloads and preferences are not a Mosaic save or permission boundary. An adapter must
disable direct uncontrolled I/O, connect owned git documents, expose selection/version/undo
operations, and put all agent access through Mosaic's broker. Its native MCP server is not
automatically a browser bridge. Q167 D3 bars the required CSP loosening today. An iframe or separate
origin is not a way around that ruling. O2 requires Jason's explicit scoped exception and an
independent security review before build rows.
O3. Keep a Word-like DOM writing interface, use WordCraft's headless formats/engine behind a narrow
server operation for production export. Recommend this bounded mix for the first release: O1
supplies editing and local synonyms, a pinned headless exporter supplies DOCX for DesignCraft. Do
not run two live document models or send every keystroke to a server. This avoids introducing a
canvas editor and a CSP exception while adding a concrete book handoff. The cost is a reviewed
format adapter and an explicit feature boundary. It does not promise full Word parity. If footnotes,
comments, tracked changes or precise pagination must be editable in the first release, choose O2
after its bridge and CSP gates instead of silently extending Markdown beyond what it can represent.
The first release includes prose, headings, emphasis, lists, links, native spelling, find, word
counts, undo, explicit save and synonyms in familiar controls. True page layout, headers/footers,
rich tables, footnotes, comments and tracked changes are later rich-document work, not buttons that
discard unsupported information. Existing documents with unsupported structures remain
source-preserved or read-only with a clear reason. Jason confirms that feature cut, not an
assumption that the existing vault defines her needs.
## WordCraft measurements used for O2 and O3
Louie's evaluation is pinned at `origin/louie/wordcraft-eval`, commit
`880ef19e017dad0194ecc9921ae55a0cf2e8fef2`,
`docs/plans/2026-10-21_q195-wordcraft-eval.md`. Louie built a scratch clone of WordCraft
`e05b7fe86f3b85454000dffc321de638d7009817` (0.9.0), with no source modifications.
These are Louie's measured results, not estimates or a claim that this combined design ran
the application in the Console.
| Evidence | Measured result | Design implication |
|---|---|---|
| Release build twice | 392.786 s, then 172.324 s, under `nice -n 19`, four Cargo jobs | First-build cost is material. Exact timings are that scratch host, not a product startup benchmark. |
| WASM | 43,994,316 bytes, SHA-256 `90dec375924b47b40054eb4ecafb4235b6ed0bd63880b91ec138fdbb6f939e75` | The current shipped build is much larger than the packaging README's approximate figure. Use the measured artifact. |
| JS / HTML / icon | 164,941 / 1,604 / 18,378 bytes | Total 44,179,239 bytes, identical sorted hashes for all four artifacts across both builds. No compression transfer size was measured. |
| Review scope for web target | 15 local crates, 251,457 physical Rust source lines and 199 third-party packages | O2 imports a maintained application, not a small editor widget. All dependency licenses and pinned build inputs need review. |
| Browser API | Session and selection stay in WASM. No exported page API or postMessage bridge. Browser has no TCP control channel | The native MCP server cannot control the browser document. The O2 adapter is new work. |
| `.doc` → `.docx` fixture | Heading 1, italics and footnote reference/body preserved. Comment absent, inspect reports zero comments | Useful import candidate, with a real loss boundary. Legacy import must retain the original and disclose comment loss. |
| `.doc` → `.md` fixture | Heading and italics preserved, footnote reference/body and comment absent | Markdown is not a lossless intermediate for rich legacy books. |
| `.md` → `.docx` → `.md` fixture | Heading and italics preserved. Footnote marker/definition stay ordinary text and brackets are escaped | O3's adapter must detect unsupported constructs, not rely on conversion success as proof of semantics. |
| Thesaurus | 29 headwords, 122 synonym values | WordNet is needed under every option. Hunspell spelling dictionaries are a separate mechanism. |
| MCP | 16 native tools, stdio/headless or loopback desktop connection | Reuse operation ideas through Mosaic's scoped tools. Do not expose native execute/save or a loopback control port to agents. |
For the unmodified O2 browser app, Louie identifies `script-src 'wasm-unsafe-eval'` for
WebAssembly, `frame-src 'self' blob:` for the PDF preview, and `frame-ancestors 'self'`
on an embedded WordCraft response rather than the shell's existing `'none'`. A restricted
integration could disable PDF preview, but must demonstrate the resulting narrower policy.
The shell policy stays unchanged when only an editor response changes. Q167 D3 still
requires Jason's ruling on that editor policy. No worker/GPU-specific CSP directive is
needed for this build. The report's Console CSP reference was `serve.mjs:495` at
`6c75887b0`; the identical policy is now `packages/webui/src/serve.mjs:605` at `aca16404c`.
Pane fitting is a source-based inference: eframe 0.36.2 uses ResizeObserver on the canvas.
Louie did not prove a functioning Console embedding, keyboard escape, browser startup
latency or browser selection bridge. Those remain O2 acceptance work, not measured successes.
The native fixture conversions do prove format behavior on the synthetic fixture. They do
not prove preservation of Melanie's corpus or the complete Word feature set.
For O3, the headless format path is supported by the successful native fixture commands.
A restricted exporter service and its exact native dependency subtree, memory usage and
service latency have not been measured. W6 must measure them. O3 avoids the measured
browser WASM/app UI and a browser CSP exception, but is not claimed to be a zero-cost or
already-built backend. This is why the recommendation is limited to the tested prose,
heading/emphasis/list subset plus explicit loss checks, with richer editing as a separate
choice in Jason's checklist.
## Stored format and DesignCraft handoff
D20. For the recommended first slice, existing Markdown stays canonical in git, including front
matter. No forced migration. Produce DOCX as a derived, human-requested book handoff from a specific
commit. The first slice stores Markdown only in git and downloads the derived DOCX with an export
manifest. Optional storage of that output under a dedicated `exports/` directory in the writing
project requires its own accepted write-path policy brief. The manifest records source paths, source
commit/digests, converter revision, style map, output digest and conversion warnings. The Markdown
source is the sole editable copy. The DOCX is regenerated, never independently edited then merged
back. Existing Case 2 mapping does not automatically index exports or gain write permission for
them.
Markdown-only git is easiest to inspect but cannot retain all Word features. DOCX-only git retains a
richer document model but is binary to git and outside Case 2's Markdown reader, writer and
citations. Two editable masters drift. If git later holds both, DOCX is a derived output, not
another editable format to synchronize. A later rich-document mode may make DOCX canonical per
document, with a derived Markdown reading projection and stable identity mapping, but that needs
separate writer, index, citation and loss-preservation design. It does not change this week's Case 2
MVP.
D21. DesignCraft checkout `3ce3a24f08d03935bc8cf7cb23755aca8065558c` lists DOCX and MD at
`docs/file-format-parity.md:69,72`. Code qualifies that claim: `crates/textimport/src/lib.rs:94-126`
recognizes `.md` but falls through to plain text. Markdown headings, emphasis and YAML are not
automatically interpreted. `crates/textimport/src/docx.rs:493-548` reads named styles and footnotes.
Its regression `tracked_changes_import_as_accepted_including_moves` at `:706` shows that
insertions/move destinations become accepted content, with deletions removed, not an editable review
history. `crates/engine/src/cmd/place_text.rs:46-93` maps style names and resolves conflicts.
`crates/engine/src/cmd/file.rs:138` documents `file.place` for DOCX/MD and
`crates/mcp/src/tools.rs:98-110` exposes typed commands through the MCP server.
D22. `POST /api/editor/export` takes exactly `{project,path,base,format:"docx"}` under the existing
human write fences, reads the authorized saved commit and returns a bounded download containing DOCX
plus manifest. Dirty drafts must be saved first. No arbitrary server path or browser-selected
converter command is accepted. Export strips front matter into metadata, resolves source links as
text or explicit cross-reference labels and maps Normal, Title, Heading 1-3, Quote and list styles
by name. It refuses unsupported semantic content or offers an explicit loss report before export.
Jason's later layout agent fetches the reviewed Markdown commit into its own approved workspace,
produces the matching DOCX and manifest (or reads the separately authorized export commit), verifies
hashes, and uses DesignCraft MCP `execute` with `file.place`, sets an explicit style map and
`styleConflicts`, then reviews autoflow and warnings before saving a layout. That later agent
automation is not authorized by this design assignment. No editor agent gets DesignCraft credentials
or automatically sends Melanie's text to another service.
Acceptance uses invented fixtures: body, styled headings, emphasis, list, table, link and
footnote/track-change cases appropriate to the chosen format. Compare source text and style
identities after WordCraft export and DesignCraft placement, including Unicode and paragraph
boundaries. Unsupported footnotes/revisions in Markdown must refuse or warn, not vanish. For rich
DOCX input, explicitly choose a final accepted reading before production layout, retaining the
original review-bearing file. A direct `.md` place is a plain-text fallback only, not the production
path for styled Markdown.
D23. Legacy import is a separate assignment. It must supply stable document IDs, original hashes, a
read-only retained original, converted editable file, named styles, metadata, attachment mapping and
a loss/warning report with a human acceptance decision. It must also name the supported
format/features, source-to-output paragraph identifiers and asset paths so navigation, pins and
citations can be repaired explicitly. Never replace .doc or .qxd originals. WordCraft's `.doc`
parser is a candidate to test, not proof that every 20-year-old document survives. Neither project
reads .qxd today (DesignCraft's gap at `docs/file-format-parity.md:32`). QXD extraction needs its
own evaluated tool and license. The editor accepts only reviewed converted artifacts, not a bulk
import button.
## Writing and moving between documents
D4. Present a File/Open view with recent documents and a collapsible book/chapter navigation pane,
plus a heading outline. The Case 2 mapping supplies scope underneath it. Show book, folder and
document in the breadcrumb, without requiring Melanie to operate a vault UI. The host recon at
`/mnt/storage/src/jason-master-restart/projects/mosaic-mvp/MELANIE-HOST-RECON.md:23` describes
Markdown book folders, chapters, characters, summaries, locations, research and tracker notes, with
front matter keys `type`, `name`, `appears_in` and `relationships`. Only that structure is used
here. `.base` and `.canvas` stay untouched and are not offered as editable documents.
D5. Reuse C2-04's human-only `library.file` read (`packages/bus/src/broker.mjs:99,4610-4616`).
The future `library.file.save` verb does not exist at `aca16404c`. C2-11b has no accepted
brief yet (`docs/plans/2026-10-20_case2-rows.md:11-14`), and the Case 2 design
(`origin/gosalyn/case2-design:agents/gosalyn/work/case2/DESIGN.md`, section 7.3) proposes
its navigation and save UI. C2-11b's brief, the future human save verb, C2-02's writer and
C2-03b's push behavior must be implemented and independently reviewed before W2 can ship
book editing. They are explicit blockers, not an existing save boundary or work hidden in
a UI row. Until then only `docs/` fixtures can exercise the new writing surface.
D6. Keep one working editor with a collapsible file tree, heading outline, recent documents and a
path/name filter. Opening a fixture such as `Lantern Book/Chapters/03 - Rain.md` uses the mapping's
document identity, not an absolute path. Back/forward and wikilink navigation use the same open
operation. Do not invent a second tree or a vault-wide search service.
D7. Each document keeps its text, base commit, selection, scroll and undo history in memory for this
signed-in tab. Chat polling and divider movement never remount it. Switching back restores it. Keep
at most ten clean inactive editor states. Before releasing a dirty state, require Save, Copy or
explicit Discard. Never evict a dirty document silently. No unsaved text goes in URLs, localStorage
or logs. A dirty-document badge and an unsaved count remain visible. Closing/reloading a dirty tab
warns. Explicit sign-out clears text and access context, with an unsaved warning before it proceeds.
Expired credentials revoke agent shares and block writes/server reads, while preserving the tab's
local draft for Copy or reauthentication. The UI distinguishes expiry from an explicit sign-out.
D8. Use Word-like File/Home/Review controls and a familiar Normal/Heading 1-3 style picker. Provide
a clean page-colored writing surface, readable line width (about 70 characters), text-size controls,
spacious line height, native spellcheck, keyboard formatting, find within the open file, word count
and a focus view. Keep the toolbar compact and keyboard accessible. Hide front matter behind a
collapsed “Document details” source panel, preserving its exact bytes. Unsupported blocks remain
marked source blocks. Do not reinterpret YAML or normalize wikilinks. All-day use means stable
selection/history and clear save status, not Word-style pagination.
D9. Save is explicit (button and Ctrl/Cmd+S), with “Unsaved”, “Saving”, a returned commit, or a
refusal. No autosave commits initially. Newer content refuses `stale-base` and preserves the draft.
Show current saved text beside the draft for manual reconciliation, never an automatic overwrite.
Unknown save/push results are read back before retry. A local library commit awaiting push is
labelled as such, using C2-03b's result, never called synced. No create, rename, delete, promote or
crossover work enters this add-on's first slice.
## Thesaurus
D10. Use Princeton WordNet 3.0, indexed locally by synset (meaning) and part of speech. This is
substantially broader than WordCraft's small static `T` table
(`crates/engine/src/cmd/review.rs:914`). WordNet is a lexical database, so it will miss some
inflected or literary terms and does not rank words for a sentence. Group results by meaning, show
the gloss, omit the selected word and duplicates, and say “No local synonyms” on a miss. Do not
promise every alternative is interchangeable.
Princeton's [statistics](https://wordnet.princeton.edu/documentation/wnstats7wn) report 117,659
synsets and 206,941 word-sense pairs. The 155,287 strings total counts words again across parts of
speech. The distinct total is 147,278. Its
[license](https://wordnet.princeton.edu/license-and-commercial-use) permits copying, modification
and distribution for any purpose, retaining its copyright, terms and disclaimer on copies and
modifications. This fits this repository's private `UNLICENSED` package and source-review rules. Do
not label WordNet MIT or Apache. Preserve the actual WordNet license.
Measured download on this design session: Princeton's archive host timed out. The NLTK project's
[WordNet data
mirror](https://github.com/nltk/nltk_data/blob/96f9b3252457a2b97e52aec64c3dfceeb5c312d5/packages/corpora/wordnet.zip)
at commit `96f9b3252457a2b97e52aec64c3dfceeb5c312d5` supplied the archive in memory. The archived
LICENSE is WordNet 3.0's Princeton license. No archive entered this repository.
| Measurement | Exact value |
|---|---|
| Archive | 10,775,600 bytes |
| SHA-256 | `cbda5ea6eef7f36a97a43d4a75f85e07fccbb4f23657d27b4ccbc93e2646ab59` |
| All uncompressed entries | 36,353,991 bytes |
| `data.*`, `index.*` and exception files | 35,435,978 bytes |
| Measured synsets | noun 82,115, verb 13,767, adjective 18,156, adverb 3,621 |
D11. At build time, import the original text, license and a provenance manifest under
`packages/webui/third_party/thesaurus/`. A small reviewed converter bakes a static index into a
self-hosted Web Worker asset, shipped with the Console build. Start that worker when the writing
surface loads. No runtime dependency install, upstream lookup, CDN, on-demand dictionary fetch or
model call occurs when she selects a word. Data is reviewable source in the release, like row 154's
editor source. Browser assets still come from the installed Console itself. CSP stays byte-for-byte
unchanged. Worker startup and index decoding stay off the typing thread.
The build row records exact generated bytes and worker memory/load measurements rather than guessing
compression size here. Gate limits: at most 50 MiB shipped dictionary assets, at most 128 MiB added
worker heap on the fixture browser, warm lookup under 100 ms for 95 of 100 fixture lookups. A failed
limit requires a reviewed representation change, not dropping words silently. Worker startup never
blocks typing. Only the selected word goes to the worker, and it stays in the browser.
D12. Right-click an explicitly selected word to open an accessible Synonyms menu. Also support
Shift+F10/Menu key and a toolbar action. Freeze its selection bookmark and document revision when
opening. Preserve selection while the menu has focus. For a whole-word selection, normalize lookup
case and use WordNet exception lists for inflections, while displaying the original word. Do not
silently inflect a replacement. Clicking a result replaces exactly that selection in one undoable
transaction and retains surrounding marks. If the document or selection changed while loading, close
the menu and ask her to select again. Empty/multiword selection, source blocks and read-only mode
offer no replacement. Escape closes and returns focus. Keep ordinary browser copy/paste reachable.
D13. “Ask the agent for options in this sentence” is an optional second menu action, never a
fallback request sent automatically. It previews the selected word and one sentence, then sends only
that context to the chosen thread after her action. Default off pending the policy/access choices
below. With Q176 unchanged, the answer must be allowed cited advice or bounded corrections under
C2-06, never an unrestricted rewritten sentence.
Reviewer checks: fetch the exact mirror commit, hash the archive, compare the archived LICENSE with
Princeton's published text, recompute every imported file hash and the four synset counts, and run
the converter twice from separate directories for identical output. Include license text with both
original and derived data. Check generated records against source offsets, malformed input refusals,
missing license/hash failures, no executable data, no network calls, CSP equality and absence of
document/word logging. The mirror's pin and measured hash establish repeatability, not a claim that
its bytes were compared with the unavailable Princeton archive.
## Agent API and MCP access
D14. Reuse the existing broker and harness typed-tool channel. Mosaic already implements
`initialize`, `tools/list` and `tools/call` in `packages/harness/src/mcp-server.mjs:23-43`, with
session-scoped tool dispatch (`tools.mjs:1-17`). WordCraft's `get_text`, `inspect_document` and
`select_text` (`crates/mcp/src/tools.rs:109-122`) illustrate small, typed operations. Its `execute`,
`type_text` and `save_document` are too broad here. Its `server.rs:130-163` exposes document
resources, but Mosaic needs no second MCP server.
D15. The browser publishes an immutable, short-lived context snapshot only when Melanie chooses
“Share selection” or “Share document” in the visible thread. A pin does not grant access. Snapshot
identity includes owner, business, library/project, mapped path, tab ID, thread ID, editor revision,
base commit, scope, text digest and expiry. An opaque ID is a reference, not authority. The broker
binds it to the authenticated thread holder and user. No agent chooses another user's tab, changes
the active file, reads a session file or uses `library.file` to evade M4. Sign-out, permission loss
or explicit revoke removes access. A browser heartbeat lease expires after 30 seconds without
renewal, including a lost connection. Absolute expiry is 15 minutes. Navigation suspends access
until explicit sharing for the new file. Multiple tabs have separate IDs and never guess which is
“open”.
Proposed new interfaces, all gated design contracts:
| HTTP / broker operation | Typed MCP tool | Result and authority |
|---|---|---|
| `POST /api/editor/contexts` → `editor.context.publish` | none (human only) | Publish selection or document snapshot. Return context ID, revision, digest and expiry. Max 256 KiB snapshot UTF-8, within a 1 MiB HTTP envelope. Reject overflow without truncating silently. |
| `DELETE /api/editor/contexts/:id` → `editor.context.revoke` | none (human only) | Revoke one owned share. Browser disables its card immediately. |
| `GET /api/editor/contexts/:id` → `editor.context.read` | `editor_read_document({context,revision,cursor?})` | Only explicitly shared document text. Page whole blocks below `REPLY_CAP`, with revision/digest on every page. Selection-only sharing refuses whole-document reads. |
| Same read boundary → `editor.selection.read` | `editor_read_selection({context,revision})` | Exact selected text, optional shared sentence and offsets, no implicit surrounding document. |
| `POST /api/editor/suggestions` → `editor.suggestion.propose` | `editor_propose_edit({context,revision,expected,replacement,reason,requestId})` | Store a proposal only. Bind to shared selection offsets and exact expected text, not a browser-supplied filesystem path. No file save. |
| `GET /api/editor/contexts/:id/suggestions` → `editor.suggestion.list` | none required | Human reads proposals for this context. |
| `POST /api/editor/suggestions/:id/decision` → `editor.suggestion.decide` | none (human only) | Accept or reject the card. Acceptance authorizes one local transaction, never a git write. |
D16. All arguments use strict schemas with unknown keys refused. Reads stay within the existing
session capability and approved universe/scope. HTTP mutations use the signed-in human, exact Origin
and same-origin Fetch Metadata fences, never a browser-selected actor. No MCP accept, save or
general execute tool ships. Publishing unsaved text is a new broker boundary and needs a reviewed
capability/policy decision. The first agent slice can remain read-only on explicitly shared saved
material through existing allowed lookups.
D17. Use normalized LF Markdown UTF-16 offsets at valid code-point boundaries, not ProseMirror
positions or rendered DOM offsets. The browser builds a source mapping for the selected editable
text and verifies the exact substring. Refuse selections crossing raw blocks, front matter, nodes or
unmappable syntax. The context revision increases on every document edit. A changed digest/revision,
revoked share or changed text makes a proposal stale. Do not auto-rebase it. Acceptance is one
ProseMirror transaction, undoable, and a later Save uses the original file base and the existing
human writer. Applying an accepted card twice is refused. A crash leaves a proposal unapplied, never
retries it.
D18. Bound proposals to 20 per context, each replacement/expected at most 4 KiB and reason at most
500 characters, with 15-minute expiry and idempotent `requestId` per thread/context. Deduplicate
transport retries without resending to the agent. Log IDs, hashes and outcomes, not bodies. Proposal
content remains protected context data and is deleted on expiry/revoke. Audit records retain
metadata only. Display all generated text as text, with authoring thread, old/new text, reason and
Accept/Reject. Nothing is interpreted as HTML or commands.
D19. Q176 remains enforced on this new channel too. With no new ruling, only the already permitted
C2-06 cited corrections/advice may appear, with its exact citation and edit-distance limits, and no
agent replacement tool is exposed. Unsaved text has no git citation. Sharing it does not turn a
digest into a valid C2-06 source. Supporting cited unsaved drafts needs an explicit decision and
review of M3-M5 and the guard, not a private suggestion channel around the existing guard. Reject
arbitrary prose even when a human would later approve it until that decision is made. Direct edits
remain outside this recommendation.
## Split pane, chat and pins
D24. This section folds Fenton's pane, chat and pin design from `origin/fenton/editor-design` at
`7caf8008b5887c15f74303a50db2e54a9778375f`, `docs/plans/2026-10-21_q195-pane-and-pin.md`. Fenton's
separate work supplies the layout, durable thread pin and send-time context model. The following
reconciles it with Q196, Case 2 paths and this brief's explicit sharing rules. It is design input,
not Fenton's review approval of this combined document.
D25. On writing routes put the editor container, a divider and sibling `aside#chat-pane` under
`main`. Navigation replaces the document container only. Embedded chat takes an explicit thread ID
and container, never owns `location.hash`, and reuses the same renderer as full Chat. Keep the
existing 10-second polling, paused for hidden tabs and sign-out. No new WebSocket service. Chat
polling updates only its own DOM. O2, if selected later, would use the same outer pane with an
engine adapter rather than DOM selection access.
D26. Start at 62% document, 38% chat. The divider uses pointer capture and the WAI-ARIA separator
pattern, names both panes, reports current/min/max width, and supports arrows (2%), Shift+arrows
(10%), Home/End and Enter to collapse/restore. Double-click resets. For the recommended ribbon UI,
clamp document/chat widths to 44rem/18rem and clamp again on resize. Below 76rem show Document/Chat
tabs, preserve both drafts and show reply counts without switching focus. Store width/collapse only
in `mosaic-console-split`, with invalid values reset. No document text in browser storage. These are
design sizes, not benchmark results. The compact O1 toolbar alternative uses 28rem/18rem and tabs
below 60rem. For the ribbon UI, tabs deliberately start at 76rem, including the entire
62–76rem band where the two pane minimums alone would fit. That leaves room for navigation
and avoids another intermediate layout. This deliberately replaces Fenton's E6 2.5rem chat
rail in that band. Existing CSP stays unchanged for the recommended O3 headless-export mix.
D27. Unpinned chat “Follows you” as documents change. A pin binds a bus thread UUID that passes current ownership and FED R3 grant checks to a
document, never a harness run or session-file path. On A pinned to T1 and B pinned to T2, opening B
shows T2, keeping each thread's draft/read cursor. A document without a pin restores the follow-you
thread. Unpinning leaves the displayed thread following the user. Switching thread on a pinned
document offers “Pin this thread here instead?” and never changes the pin silently. A thread may
serve several documents, one pin per document/owner. Document navigation never launches a run. Reads
do not call `thread.open` or send messages.
D28. Recommend durable pins on the bus, following Fenton's S6. Add
`thread_pins(business,owner,project,path,thread,at)`, unique per business/owner/project/path, with
`POST /api/chat/pins` → `thread.pin`, `DELETE /api/chat/pins` → `thread.unpin`, and `GET
/api/chat/pins` → `thread.pins`, under the existing chat identity/write fences. Testers retain their
own isolated pins, never the human's. The pin belongs to its authenticated business/user.
`thread.pin` and `thread.pins` reuse `#grantedThread` after the existing thread ownership
check, as open/send/messages do (`packages/bus/src/broker.mjs:790-793,5766-5780`). Ownership
alone is insufficient. Pin operations never grant thread or document access. For library
documents validate the mapped path/scope through the library reader,
not row 148's narrow `documentPath`. Do not widen that existing function. Keep the thread's chat
project separate from the writing repository identity and validate their declared association. Poll
pin unread metadata separately from message bodies so background threads can get a badge without
rendering or waking them. Row 121's current API has no such metadata contract, so W3a must implement
and test it rather than assume it exists.
No automatic rename following. A missing path shows a stale pin and explicit Re-pin/Unpin controls.
A missing/forbidden thread or revoked/expired thread grant shows the stale-pin state,
offers Unpin only and exposes no former content or unread metadata. The owner may remove
that stale pin without regaining access to the thread. Two tabs update pins
with a revision precondition, so an old tab cannot silently replace a new pin. The follow-you thread
ID may live under an owner/business-scoped browser key and is cleared on sign-out. A pin confers no
document permission or context sharing.
D29. First agent context is saved material at send time. A visible chip, explicitly enabled by
Melanie and removable for each send, carries `{project,path,commit,lines:[from,to]}` and no text. It
appears only after her share action. Check commit is reachable in the allowed served history, path
is mapped, caller/thread can read that scope, and both lines exist in that exact commit. When the
draft is dirty or mapping is uncertain, omit selection lines and label “Saved version only. Save to
cite this selection.” Do not pretend draft offsets are saved line numbers. The agent uses existing
approved library lookup tools and C2-06 citations, not session files or an expanded generic `read`
root. `packages/project/src/paths.mjs:34-40` now refuses a library repository unless
`allowLibrary` is explicit. Use the library reader and do not widen `documentPath`.
Store structured context in `message_context` keyed to the message, kept apart from body and
inserted atomically. `thread.send` accepts this optional strict field, and message reads return it
separately. The broker validates the source rather than trusting the browser's chip. This first
saved-context route does not enable D15's unsaved snapshots. Both tables and verb changes need
Jason's explicit schema/API approval. They grant no prose authority.
D30. Fenton found C2-10b's file list names `bus.js`, while actual thread messages render in
`public/chat.js:68`. Sage must amend that row's ownership/acceptance to include `chat.js` or a
shared renderer before W3 starts. Otherwise cited JSON would reach the pane as plain text. C2-09a's
brief also cites older broker positions. Re-measure against `broker.mjs:5716` when building. No
edits to those briefs or queue are made here. M7 and M8 remain intact, and neither row 5 nor row 29
is a dependency.
D31. Fenton's Q196 update adds a host contract that works with each option: `mount`, `destroy`,
`dirty`, `focus`, `selection` and `resized`. For the recommended DOM surface these adapt the
editor's real state, not a second document model. F6/Shift+F6 cycle focus among editor, divider and
chat. Scope editing shortcuts to the focused pane, so chat paste and undo never mutate the document.
Before releasing any editor state, check `dirty`; a missing/failed response counts as dirty.
Preserve the state or offer the explicit Save/Copy/Discard controls in D7. A hash navigation alone
is not a browser `beforeunload` event and must run this check itself.
For O2, Fenton recommends an iframe so WordCraft's page-wide canvas and document/window listeners do
not capture chat input. A new browser bridge must check `event.origin`, `event.source`, message
schema, per-instance generation and request ID. It needs document load/export, dirty, selection,
focus-out, resize and a safe transaction operation for the thesaurus, not just a notification hook.
A failed dirty response after one second blocks unmounting. During divider drag capture the pointer,
prevent iframe interception, resize at most once per animation frame and send the final `resized`
notification. Test F6 escape, chat paste isolation, narrow ribbon behavior and stale messages after
document changes. A same-origin iframe is UI isolation, not a security boundary. Native file I/O and
network features still need the adapter restrictions in O2.
An iframe-route-only `wasm-unsafe-eval` exception would be narrower than changing the Console shell
policy, but it still changes the editor's CSP and requires Q167 D3's ruling. The recommended
headless-export O3 needs neither an iframe nor a browser WASM exception. No alternative-origin
hosting or broad `unsafe-eval` is authorized as a workaround. Fenton's conditional P5 brief at the
pinned source describes the iframe bridge. If O2 is chosen, revise W2/W6 into source-import/build
and browser-bridge rows before queue entry, with their exact owned source paths and the measured CSP
delta.
O2's DOCX selection cannot use Markdown line citations. A versioned document locator and C2
guard/renderer support would need a separate reviewed format-policy brief. A DOCX path is not made
legal by leaving `lines` out. Until that change is approved, rich-document agent context is
disabled. This deliberately replaces Fenton's J7 proposal to send DOCX context without
lines. Do not use paragraph indexes as if they were existing git lines.
## Build order and review gates
Order: W1 → W2 → W3 → W6 → W4 → W5. W3a can prepare after its Case 2 dependencies and must finish
before W3. W2 is a useful writing increment. W6 completes the recommended release's styled
production handoff. W4/W5 remain separately gated.
These are proposed briefs, not queue entries. Jason confirms the checklist, Mos routes the decision,
then Sage accepts and schedules each brief and creates its Gitea build issue. Every code row gets an
independent Opus 5.5 reviewer who did not author that row. Q177 allows building on a reviewed parent
candidate, with landings in order. It does not allow overlapping file owners or bypassed reviews.
Sage grants suite slots and the final gate. Rows owning `serve.mjs` also serialize behind row 198's
current ownership. That is a file handoff, not a dependency on the retired board.
Common gate G1: run each changed/touched test file independently, the affected package suites and
every `scripts/test-*.sh` on Sage's slot. Search tests for changed path/hash pins. For editor
changes use `MOSAIC_REQUIRE_EDITOR_BUILD=1` with pinned esbuild 0.28.2 and reproducible output
hashes. Browser checks use scratch roots and invented vaults only. No row activates a stack, changes
roles or pushes to protected branches by implication.
## W1 local thesaurus data and converter
### Problem
WordCraft's small built-in list cannot provide general writing coverage (D10). No Console thesaurus
dataset exists in the measured editor inputs.
### Owner and reviewer
- Owner: a T3 Codex gpt-6-luna seat, assigned by Sage (lower-level data import and converter).
- Reviewer: Fenton, Opus 5.5, independent of this implementation.
- After: Jason's first-slice approval. WordNet is the design's routine data-source choice.
May start independently of C2 shared-file work.
### Files owned
`packages/webui/third_party/thesaurus/{LICENSE,NOTICE,PROVENANCE.json,wordnet/}`,
`packages/webui/editor-data/{import-wordnet.mjs,build-thesaurus.mjs,BUILD.json}`,
`packages/webui/src/public/thesaurus-data/`, `packages/webui/tests/thesaurus-data.test.mjs`,
`packages/webui/README.md` (thesaurus section).
### What ships
Pinned WordNet originals and deterministic derived local data (D10-D11). Tests reproduce counts and
hashes, license preservation, invalid-input refusal and stable sorted output. No change inside
`editor/` or existing editor tests. Record actual output size.
### Out of scope
Menu, worker wiring, agent requests, editor source and Case 2 library writes.
### Gate
Fenton verifies source/license and two independent builds. Data tests pass and output stays within
D11's size budget, then G1. Sage accepts the manifest.
## W2 writing surface and local synonyms, first usable slice
### Problem
F3's redraw loses undo history and no selected-word menu exists. Books also need Case 2's library
read/write path (F4, D5).
### Owner and reviewer
- Owner: Gyro, Codex gpt-6.1-sol (editor lifecycle and selection handling).
- Reviewer: Fenton, Opus 5.5, independent of the code.
- After: W1, C2-11a row 214, C2-10b, and C2-11b's future accepted brief and the unimplemented human `library.file.save` verb,
with C2-02/C2-03b for usable book saves. No acceptance claim until all are reviewed.
### Files owned
`packages/webui/editor/{view.mjs,entry.mjs,editor.css,thesaurus.mjs}`,
`packages/webui/editor/BUILD.json`,
`packages/webui/src/public/{editor.js,editor.css,bus.js,bus.css,thesaurus-worker.js}`,
`packages/webui/tests/{editor.test.mjs,editor-browser.test.mjs,docs-browser.test.mjs,writing-browser.test.mjs,thesaurus-browser.test.mjs,editor-provenance.test.mjs}`,
`packages/webui/README.md` (writing section). `model.mjs` remains C2-11a's result.
### What ships
D4-D12, D31 and O1's familiar controls: stable per-document state, explicit save, writing controls,
navigation through the existing library tree, preserved front matter and local accessible synonyms.
Wire only reviewed self-hosted assets. Tests cover selection/focus, Unicode, stale lookup,
replacement undo, read-only, exact saved-selection source mapping, unsupported blocks, no external
requests and unchanged CSP. A fixture session edits two nested chapters with front matter/wikilinks,
saves, navigates back and checks bytes, selection and undo. Test `stale-base`, unknown result,
unpushed feedback, credential expiry preserving local text and explicit sign-out clearing it after
the unsaved warning. Measure D11's load/heap/lookup budgets and a 100,000-word fixture without lost
input during chat-style refresh events. Passing responsiveness means no typing task above 100 ms
after load in 95 of 100 measured edits, with the browser/host recorded.
### Out of scope
Split pane, pins, agent permissions, library writer/sync implementation and Word layout.
### Gate
Fenton repeats the fixture writing flow and reviews accessibility and byte preservation. `node
--test packages/webui/tests/` with the required build passes, then G1. Sage accepts.
## W3a durable pins and saved-message context
### Problem
Pins and structured message context do not exist (F5-F6). Browser-only pins would differ across
machines. D28-D29 require new durable records and verified source references.
### Owner and reviewer
- Owner: Gyro, Codex gpt-6.1-sol (schema, broker and channel changes).
- Reviewer: Fenton, Opus 5.5, independent of the implementation.
- After: C2-09a, C2-10b ownership correction, C2-05b, and Jason's schema/API decision.
### Files owned
`packages/bus/{schema.sql,src/store.mjs,src/broker.mjs,src/editor-pins.mjs}`,
`packages/bus/tests/{schema.test.mjs,editor-pins.test.mjs}`,
`packages/plugins/channel/webui/index.mjs`, `packages/plugins/tests/` (pin/context tests),
`packages/webui/src/{chat.mjs,chat-http.mjs,serve.mjs,cli.mjs}`,
`packages/webui/tests/{chat.test.mjs,editor-context.test.mjs}`, `packages/webui/README.md`,
`packages/bus/README.md`. The next migration identifier is allocated by Sage's schema window before
build.
### What ships
D28-D29, an idempotent versioned migration in the repository's existing migration mechanism,
owned-pin revision checks, background unread metadata and atomic saved-message context. Tests refuse
another user's/thread's/business's context, wrong scope, unreachable commit, missing file and
out-of-bounds lines. Failed context insertion leaves no message. Pin tests cover tester separation,
stale paths, grant revocation/expiry and concurrent repin refusal. `thread.pin` refuses an
owned thread whose grant no longer covers it. `thread.pins` redacts thread content and
unread metadata after revocation while permitting stale-pin removal. No document text
is stored in the new context table.
Migration/restore-count and bus tool allowlist tests remain green.
### Out of scope
UI, raw session access, unsaved context, new role authority and replacement prose.
### Gate
Fenton reviews schema/ownership and failure cases. Bus, plugins and WebUI suites pass plus G1. Sage
accepts within the allocated schema window. No live migration is part of this row.
## W3 split pane and pinned conversations
### Problem
F5's page-level routing prevents a persistent conversation beside navigation. No pin exists. D24-D30
supply the behavior to implement.
### Owner and reviewer
- Owner: a T3 Haiku 5.5 high seat, assigned by Sage (mid-level pane and state wiring),
separate from C2-10b's reviewer.
- Reviewer: Fenton, Opus 5.5, independent of the implementation.
- After: W2, W3a, C2-10b, C2-09a and row 220 C2-10a. Shared files wait behind those owners.
### Files owned
`packages/webui/src/public/{index.html,bus.js,bus.css,chat.js,chat.css,writing-pane.js}`,
`packages/webui/src/{serve.mjs,chat.mjs,chat-http.mjs}`,
`packages/webui/tests/{chat.test.mjs,chat-browser.test.mjs,writing-pane.test.mjs,writing-browser.test.mjs}`,
`packages/webui/README.md` (pane section). Persistent pin storage is W3a, not a hidden schema change
here.
### What ships
D24-D31: a resizable accessible split pane that reuses the existing grant-accessible chat thread and C2-10b
cited-item rendering, retains editor selection/history during polling and follows the pin rules in
D27-D29. Tests cover pin/unpin, two files, two tabs, removed/inaccessible thread, revoked/expired grant, keyboard divider,
narrow viewport, reconnect/sign-out and navigation during delayed replies. No implicit `thread.open`
on read or navigation. Existing chat tests remain green. The saved context chip uses W2's exact
source mapping, and dirty drafts omit selection lines.
### Out of scope
7331 board route, row 5, row 29, raw sessions, fresh agent permissions and auto-launched runs.
### Gate
Fenton verifies the pane/pin scenarios against the folded design. WebUI suite and G1 pass. Sage
accepts the manifest after W3a's migration and API review.
## W6 DOCX production export and DesignCraft fixture handoff
### Problem
DesignCraft's `.md` import is plain text (D21). A Word-like writing surface still needs a verified
styled book-production handoff, without creating two editable masters.
### Owner and reviewer
- Owner: Gyro, Codex gpt-6.1-sol (isolated headless format adapter and semantic checks).
- Reviewer: Fenton, Opus 5.5, independent of the implementation.
- After: W3, Louie's evaluated engine revision, and Jason's O3/storage choice.
This serializes shared `serve.mjs` and `writing-pane.js` ownership.
### Files owned
`packages/writing-export/` (new, source, tests, pinned source manifest and build records),
`packages/webui/src/{editor-export.mjs,serve.mjs,cli.mjs}`,
`packages/webui/src/public/writing-pane.js`, `packages/webui/tests/writing-export.test.mjs`,
`packages/webui/README.md`. No change to WordCraft/DesignCraft checkouts. Any copied source retains
full applicable license notices. Export-commit authorization must be covered by a separate accepted
writing-project path/policy brief before a repository write is enabled.
### What ships
D20-D23's bounded Markdown-to-DOCX export from a pinned source commit, style map, warning report and
manifest, plus a reproducible source build of the needed WordCraft headless component. Review the
pinned Cargo.lock and dependency licenses, preserve required notices and bundle any required fonts
locally. Hard starting caps: 1 MiB Markdown input, 16 MiB DOCX output, 30-second conversion deadline
and one conversion per user at a time. Record actual fixture results and revise these limits only in
the accepted brief when measurements justify it. Refuse filesystem/network escape, external
linked-resource fetches, scripts/macros, malformed archives, decompression bombs and unsupported
semantic loss. Run the conversion without network or credential access. Fixture tests place the
result through DesignCraft's headless MCP path and compare body, styles, Unicode, paragraphs and
warnings. Conversion is an explicit action, not every Save. The initial UI can download the export
until the reviewed git-output path is authorized.
### Out of scope
Legacy .doc/.qxd import, new rich-document editing, live DesignCraft control and layout work on
Melanie's actual content. No guessed “full fidelity” claim.
### Gate
Fenton independently runs the export/place fixture using the pinned two source revisions and
inspects the loss report. New export tests and affected WebUI suite pass plus G1. Sage accepts only
if the target chapter's tested supported features place without loss.
## W4 scoped context API and typed reads
### Problem
The broker knows saved library material, not a tab's open document or selection. Exposing unsaved
drafts without explicit consent would cross D15-D16's new boundary.
### Owner and reviewer
- Owner: Gyro, Codex gpt-6.1-sol (broker identity and capability integration).
- Reviewer: Fenton, Opus 5.5, independent of the implementation.
- After: W6, C2-05b, C2-06, row 220, and Jason's explicit context-access decision.
### Files owned
`packages/webui/src/{serve.mjs,cli.mjs,editor-http.mjs}`,
`packages/webui/src/public/writing-pane.js`, `packages/bus/src/{broker.mjs,editor-context.mjs}`,
`packages/harness/src/tools.mjs`, `packages/{webui,bus,harness}/tests/` (editor-context test files
only), `packages/webui/README.md`, `packages/harness/README.md`. Any capability/role schema change
needs a separately accepted policy brief, not this allowlist.
### What ships
D14-D16's publish/revoke/read operations with strict schemas, expiry, bounded paging and explicit
sharing UI. Pi and Claude use the same broker checks. Tests refuse cross-user, thread, business,
universe, tab, guessed context, stale revision, revoked shares, expired heartbeat and absolute
expiry, whole-document read on selection-only access and attempts to use raw session endpoints.
Confirm raw-session `conversation-not-listed` and human-only `library.file` still refuse.
### Out of scope
Replacement proposals, save authority, new roles and bypass of M3-M5.
### Gate
Fenton approves boundary tests and confirms no tools are exposed without the approved capability.
WebUI, bus and harness suites pass with G1. Sage accepts.
## W5 guarded proposals and optional sentence options
### Problem
Q195's requested editing API needs a human-controlled proposal flow, but Q176 currently prohibits
unrestricted prose. An unsaved draft is not a valid existing git citation (D19).
### Owner and reviewer
- Owner: Gyro, Codex gpt-6.1-sol (revision checks and undoable proposal application).
- Reviewer: Fenton, Opus 5.5, independent of the implementation.
- After: W4 and an explicit Jason ruling on proposal kinds and unsaved-draft citations.
With no ruling this row does not enter the queue.
### Files owned
`packages/bus/src/{broker.mjs,editor-context.mjs}`, `packages/harness/src/tools.mjs`,
`packages/webui/src/editor-http.mjs`, `packages/webui/src/public/writing-pane.js`,
`packages/webui/editor/{view.mjs,thesaurus.mjs}`, `packages/webui/editor/BUILD.json`,
`packages/webui/src/public/{editor.js,editor.css}`, `packages/{webui,bus,harness}/tests/`
(editor-suggestion test files only), `packages/webui/README.md`. Any
`packages/library/src/guard.mjs` or role-policy change is excluded until its own reviewed policy
brief names the approved change and tests.
### What ships
D13 and D17-D19, restricted to the confirmed policy. Tests cover old/new text rendering,
accept/reject, undo, stale and expired context, changed base, no autosave, retry deduplication,
invalid UTF-16 boundary, hostile markup and guard failures on both RESULT and tool paths. Negative
tests prove an agent cannot accept, apply or save, and no proposal widens C2 scope.
### Out of scope
Direct agent edits, prose permission by inference, arbitrary execute and automatic sending.
### Gate
Fenton verifies that the recorded ruling matches the allowed proposal shapes and source citations.
WebUI, bus, harness and library guard regression suites pass with required editor rebuild and G1.
Sage accepts. No deploy or activation authority follows.
## Design verification receipt
Checked 2026-10-21T16:22:36Z (from `date -u`). Both companion branches were fetched and their
report commits pinned above. Seven briefs have every BRIEF-TEMPLATE field, unique anchors
and an independent implementation reviewer. The initial checklist had 12 lines before fenton-0004. Only this design
and append-only BUILD-LOG/SESSIONS records change. No product or queue files change.
Fresh editor/provenance checks: 10 passed, zero failed, one rebuild skip because pinned
esbuild 0.28.2 is absent. This is not a claim that O3's exporter, new UI, live Console or
DesignCraft handoff has shipped or passed acceptance. Those tests belong to the build rows.
The push queue guard ran as `MOSAIC_GIT_IDENTITY=gyro` for `gyro/editor-design`, purpose
`push`, timeout/interval zero. It could not resolve the per-seat credential and recorded
`CANNOT_ASSERT credential-unresolvable` with its documented push `degraded-pass` (exit 0),
audit at `/mnt/storage/scratch/gyro-q195-ci-queue-audit.jsonl`. No identity fallback or guard
bypass was used. CI status was not asserted. Fenton reviews the combined draft through Mos.
## Review-fix verification receipt
Checked 2026-10-22T16:06:34Z (from `date -u`) against `aca16404c`. Addressed fenton-0004 F1-F8:
grant-aware pins and revoked-access redaction, refreshed source references, model-only
seat allocation, the unbriefed save dependency, deliberate tabs below 76rem, the explicit
J7 replacement, seat-name wording and six owner-choice questions. Additional reference
refresh: `public/bus.js` moved by 24 lines, and the Visual editor README heading is at 572.
The code behavior cited there is unchanged. The O3 recommendation and companion report
measurements are unchanged. Fenton re-checks this revision before approval.
Validation is static: current-source anchors, seven complete brief templates, six checklist
lines, documentation-only diff and append-only logs. No build, product suite, gate-slot
reservation or live action is part of this review fix. The earlier 10-pass/one-skip result
above is the original draft's test receipt, not a new test run on `aca16404c`.
## Jason's checklist
- Q1. Choose O3 with Markdown source, generated DOCX, and footnotes/comments/tracked changes/page layout deferred? Recommend yes.
- Q2. Ship writing controls, safe chapter saves and local WordNet synonyms first, after the Case 2 dependencies? Recommend yes.
- Q3. Store pins and saved-source message context on the bus through the reviewed schema/API change? Recommend yes.
- Q4. Share saved context only when Melanie enables the visible chip for that send? Recommend yes.
- Q5. Keep Q176, with no agent prose and no unsaved-draft reads? Recommend yes. Any later relaxation requires Accept/Reject and a separate Save.
- Q6 (O2 only, if Q1 is declined). Require a Q167 D3 CSP exception and rich-document citation design before O2? Recommend yes.
+54
View File
@@ -0,0 +1,54 @@
# PRD 1.16 brief: library.write routes under REQ-LIB-6
Sage, 2026-10-22, for mos-1328. This follows `docs/plans/BRIEF-TEMPLATE.md`. Gitea #1796.
## PRD 1.16 library.write routes under REQ-LIB-6 (mos-1328)
### Problem
Row 285 (C2-08b, #1790) adds the action `library.write` and grants it within-role to the six creative
roles. REQ-WAIT-3 makes a new action a hard wait until a PRD version says it isn't, and REQ-WAIT-2 refuses
a hard wait in a role file (`packages/business/src/vocabulary.mjs:68` ROUTED, `role.mjs`). PRD 1.15
(row 282, #1787) lets creative agents write supporting docs but never routes the action. Scrooge found
it (scrooge-285-routing-q1), and Webby confirmed it (webby-0164 B2). Mos ruled in mos-1328 that the
routing changes the hard-waits list, so it needs a PRD version whose text Jason approves. Q223 and Q226
don't cover it.
### Owner and reviewer
- Owner: Gosalyn.
- Reviewer: Webby.
- After: row 282 (PRD 1.15).
### Files owned
`docs/prd/mosaic-stack.md`, `packages/tasks/requirements.json` (the version only) and
`packages/tasks/tests/requirements.test.mjs` (the version pin only).
### What ships
- A REQ-WAIT-3 note in the existing style: `library.write` was added as a hard wait and routes under
REQ-LIB-6 since version 1.16.
- The bounds are in the text: creative roles only, within-role; Markdown under supporting folders a human
marked; never a story file; the writing agent's own live Gitea grant on the library project; one
commit per write by its own account, with the trailer, the notice and revert.
- REQ-LIB-6 names the action. Status, the header, a 1.16 source line and a 1.16 note.
- Nothing else changes. 1.15's text stays as Mos accepted it.
### Out of scope
The ROUTED entry and the role files (row 285). The write verbs (row 288, C2-02). The grant executor
(row 287, C2-21).
### Gate
Webby approves the text. Jason says yes to the exact text, which Mos puts to him as one question
(mos-1328 step 3), and the row doesn't land before that. `requirements.test.mjs` and every
`scripts/test-*.sh` that reads the PRD or `requirements.json` are green on Sage's gate. Mos gets the
landing commit.
### Handoffs
Every seat hands off directly (DECISIONS Q210, mos-1221). When the owner pushes a round or the reviewer
posts a verdict, that seat messages the next seat's T3 thread and Sage's with `mcp__t3__t3_send_message`.
Copy Mos's thread when Mos has a step. Mos has one here, the question to Jason.
+33
View File
@@ -478,6 +478,8 @@ at every gate. Started 2026-09-12 during the control board MVP.
not that the event's `actor_run` is the refused run. Binds row 269 (264b,
#1774), or the next row that changes `sessions.test.mjs`: pin `actor_run`
in `refusedOnce`. (2026-10-22)
Closed by row 269 (264b, #1774): `refusedOnce` selects `actor_run` and
expects the refused run's name (mos-1292). (2026-10-22)
- **editor-provenance skips its rebuild check on esbuild 0.28.1.** (Duckworth
N2 on the row 264a manifest round, duckworth-0146.) The webui test
@@ -487,6 +489,37 @@ at every gate. Started 2026-09-12 during the control board MVP.
touches the webui build: install the pinned esbuild in the gate checkout
so the check runs, or record why it can't. (2026-10-22)
- **The case supervisor reports a fast child as an inner-session failure.**
(Row 253 N1, mos-1297 item 5.) A child that exits before the supervisor
verifies its session is reaped first, so the supervisor exits 85
(inner-session) instead of the child's own code. Mos measured rc85 in 39
of 40 fast-child bash runs. It fails closed, so it is not a 10-27 defect;
row 253 fixed only its own fixture (PYTHON_STUB execs python3). The
product fix is Vikunja 532, after 10-27. (2026-10-22)
- **boardSend /api/seen returns 400 for a scan failure after writeMark.**
(Webby N2 on row 198, ruled sage-2867.) The mark is written, then the
rescan fails, and the client gets 400 as if its request were bad. Webby
suggests 500. It does not hold the 198 gate; binds the next row that
touches serve.mjs. (2026-10-22)
- **Stale "row 203" text in the access command.** (Quackerjack, row 203
BUILD.md follow-up.) packages/cli/src/access.mjs lines 26-27, its help
string at line 36 and line 44, and packages/cli/README.md line 1107 say
peer homes stay unread "until row 203" adds the federation key. Row 203
changes only scripts/mosaic-config.mjs and test-config.sh, so once it
lands those lines point at a row that did not do the work. Binds the next
row that edits access.mjs. (2026-10-22)
- **A remote outage can make the first library lookup outlast the tool
timeout.** (Row 224 residual, sage-2819.) In each 60 s window the first
lookup still runs an inline fetch, and that fetch can take longer than the
60 s tool timeout. Bound the inline fetch in a later row. (2026-10-22)
- **A sharee's session.list stays refused.** (Row 206, sage-2818, Q7.)
Whichever of row 193's session wiring or the share wiring lands second
wires it. (2026-10-22)
## Queue
Moved to `docs/plans/QUEUE.md` on 2026-09-13. This file holds only gaps.
+29 -19
View File
@@ -33,7 +33,7 @@ Gaps found while working go to `docs/plans/DEFERRED.md`, not here.
<!-- mosaic-queue:begin -->
Generated from `docs/plans/queue.json` revision 3216 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
Generated from `docs/plans/queue.json` revision 3324 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|---|---|---|---|---|---|---|---|---|
@@ -209,8 +209,8 @@ Generated from `docs/plans/queue.json` revision 3216 by `queue render`. Do not e
| 170 | A tester message to a role nobody runs is refused tester-target-not-running before it is stored; talk --tester exits 3 (P19 380, mos-0845 D2) | dewey; reviewers filbert | #1667 | done | — | Filbert approves the candidate manifest; bus and cli suites and every scripts/test-*.sh green on Sage's gate (sage) | `docs/plans/2026-10-19_tester-no-holder-refusal.md` § Brief: a tester message to a role nobody runs is refused, not queued | Rebased for landing onto 228aeb503 as cb43dcb45 (Sage, mos-0857 item 3). git range-diff 000730949..0b6ff260f 228aeb503..cb43dcb45 prints '1: 0b6ff260f = 1: cb43dcb45', and the six paths' +/- lines hash the same before and after. The candidate Filbert approved is unchanged. |
| 171 | A thread project named like a business-file repo: project-name-is-repo refusal unless --repo matches, clearer CLI refusal (P19 379, mos-0854) | rocko; reviewers dewey | #1668 | done | — | Dewey approves the candidate manifest; bus, cli, webui and plugins suites and every scripts/test-*.sh green on Sage's gate (sage) | `docs/plans/2026-10-19_project-names-and-repos.md` § Brief: a thread project named like a business-file repo | Landed as 078518e4e (Sage). Rebuilt on d58c9c5cd as 9a29febb6, gate GATE_PASSED there, then cherry-picked onto ed045ac84+b2d3b6c47: git range-diff prints '9a29febb6 = 078518e4e'. All 11 paths match candidate manifest c498ca89. Pushed d58c9c5cd..078518e4e with 4d9114ab4, ed045ac84, b2d3b6c47 (doc and SESSIONS commits). |
| 172 | Board-held sign-out wait times out under gate load: find and fix the cause, not the timeout (P19 366, mos-0858) | sage; reviewers darkwing | #1670 | done | 171 done | Darkwing approves the candidate manifest; the cause and trace are in the packet; webui suite 10 runs green under gate load and full gate green on Sage's gate (sage) | `docs/plans/2026-10-19_board-held-signout-flake.md` § Brief: the board-held sign-out wait times out under gate load | Landed be10e0c83 on 7f38a2f54, a cherry-pick of 15c3d8ca1 (base 164e1f97f). range-diff 164e1f97f..15c3d8ca1 vs 7f38a2f54..be10e0c83: 1 = 1. patch-id --stable fb6a65e7ec1d on both. packages/ tree equals 15c3d8ca1, whose full gate passed (sage-r172 gate-run1.txt); the commits between are packet, queue and SESSIONS only. Darkwing approve 29207, rev 2018. D1/D2 fixed in 7f38a2f54. Live only after a re-pin. |
| 173 | A Seen click that waited out a Board read posts the intent the person clicked, not a toggle of the fresh row (#1671, mos-0861 A1) | sage; reviewers darkwing | #1671 | queued | 66 done | darkwing approves on #1671 naming the candidate manifest; webui and every test-*.sh green on Sage's gate rerun (sage) | `docs/plans/2026-10-19_seen-click-intent.md` § Brief: a Seen click that waited out a Board read can undo another viewer's mark | — |
| 174 | Skill reads keep a leading BOM and report fs errors inside a skill as skill-refused (#1672, row 64 N2/N3, mos-0863 D4) | sage; reviewers darkwing | #1672 | queued | 173 done | darkwing approves on #1672 naming the candidate manifest; harness, cli and every test-*.sh green on Sage's gate rerun (sage) | `docs/plans/2026-10-19_skill-read-bom-errors.md` § Brief: skill reads strip a leading BOM and report some fs errors as launch-failed | — |
| 173 | A Seen click that waited out a Board read posts the intent the person clicked, not a toggle of the fresh row (#1671, mos-0861 A1) | dewey; reviewers darkwing | #1671 | in-progress | 66 done | darkwing approves on #1671 naming the candidate manifest; webui and every test-*.sh green on Sage's gate rerun (sage) | `docs/plans/2026-10-19_seen-click-intent.md` § Brief: a Seen click that waited out a Board read can undo another viewer's mark | — |
| 174 | Skill reads keep a leading BOM and report fs errors inside a skill as skill-refused (#1672, row 64 N2/N3, mos-0863 D4) | louie; reviewers darkwing | #1672 | briefed | 173 done | darkwing approves on #1672 naming the candidate manifest; harness, cli and every test-*.sh green on Sage's gate rerun (sage) | `docs/plans/2026-10-19_skill-read-bom-errors.md` § Brief: skill reads strip a leading BOM and report some fs errors as launch-failed | — |
| 175 | Tester reads and writes for the test system: seven reads, seven writes recorded as tester (plugins, instances, routes set, name, talk without the prefix, project create, project init in the data root's project area); nine items stay human-only (#1673, Q173, mos-0870 N2, mos-0872) | dewey; reviewers filbert | #1673 | done | — | Filbert approves the candidate manifest; bus, cli, plugins, project, business and webui suites and every scripts/test-*.sh green on Sage's gate (sage) | `docs/plans/2026-10-19_tester-read-checks.md` § Brief: tester reads and writes for the test system | Landed as 98d29769e on 1d81c54ff (temp index from build.patch c9e545e5, manifest 2e30f2a1 25/25 on the tree; patch-id c1cf0ed66006 equals 9e74925c2..989699f7c). Sage gate on 716ddcf1e plus the patch: 15 package suites 2592 pass, 10 scripts/test-*.sh 422 pass, 0 fail. Filbert r3 approve 29279. F1 (TOOLS.md:452 'Until row 175 lands') is a doc-only follow-up. |
| 176 | Console tester listener on 127.0.0.1 (--tester-port, 7333 in the window), TESTER_CLI bus calls, only the row 175 tester writes; 7332 refuses x-mosaic-tester for every Host; tester reads credentials, routes, project.files, project.file (#1674, Q173 N3, mos-0872, mos-0874) | rocko; reviewers dewey | #1674 | done | 169 done, 175 done | Dewey approves the candidate manifest; bus, cli, webui and business suites and every scripts/test-*.sh green on Sage's gate (sage) | `docs/plans/2026-10-19_console-tester-listener.md` § Brief: a loopback-only Console listener for the tester | Sage gate run 2 on 473258a51 plus the patch: 15 package suites 2455 pass, 0 fail, 1 skip (editor rebuild wants esbuild 0.28.2); 10 scripts/test-*.sh 422 selftests plus 156 queue tests, 0 fail. Run 1 failed T34 with capture-failed: activate and pm-on name runs by the second and collided. A row 169 base race (the base fails the same with the id pinned), Mos agreed (mos-0902); follow-up Vikunja P19 391. |
| 177 | Tester threads: create, list, open, send and read tester-owned threads; owner and from_role tester, replies to tester, never the human's inbox; design first, Sage approves before code (#1675, Q173) | dewey; reviewers filbert | #1675 | done | 176 done | Filbert approves the candidate manifest; bus, webui and conversation suites and every scripts/test-*.sh green on Sage's gate (sage) | `docs/plans/2026-10-19_tester-threads.md` § Brief: tester threads (scoping) | Landed on refactor as 1f994257a (row 177) and 9d9d95485 (round 2), cherry-picked onto a2eebf997. range-diff vs gate candidate 1d5347670/5cb2905c7: both '='. Combined patch-id 15f9c7bba807, same as Filbert's approve (29351) and Sage's gate. Gate on c0d6bc7d5+2: GATE_PASSED, 25 steps rc=0, node 2627 pass 0 fail, scripts 422 pass 0 fail, network removed, 0 containers. |
@@ -242,26 +242,26 @@ Generated from `docs/plans/queue.json` revision 3216 by `queue render`. Do not e
| 203 | FED R2: the federation key in the system config (#414) | quackerjack; reviewers webby | #1701 | briefed | — | Webby approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R2, the federation key in the system config | Chained on row 192's candidate (Q177): starts on 192's candidate, lands after 192 (Sage) |
| 204 | 193a: access level check widened to viewer (#1691) | morgana; reviewers duckworth | #1702 | done | — | Duckworth approves the manifest; business suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193a, the access level check widened to viewer | Landed d0bbc5c1c on refactor (base c9f374284). Patch-id 92c18aea matches git diff 44e36fc4c..f869fd062 on the 3 manifest paths. Manifest d38de8c9, Duckworth approve 29595. Gate 2789/0 on 4385b9ec1, mutants 7/7. Unpushed. |
| 205 | 193b1a: the message filter (#1703; FED R3 and 193b1b build on it) | morgana; reviewers duckworth | #1703 | done | 196 done | Duckworth approves the manifest; bus suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b1a, the message filter | Landed ce6a96ad6 fast-forward on 0fa7bf421 (3 commits, 4 paths, manifest 858463b1). Duckworth APPROVE r1 c30375 (signed :124 test diff, mos-1262). Gate: Morgana slot205 10 files, bus 652/0, M1 M11-M14 killed, 0 skips; Sage test-*.sh 10/10, 3 by-design skips. N1/N3 to 205b or R3, N2 to 193b2 brief, N4 = #1782 before any user thread. Rebase next: 268 r4, 194, 236, 253, 274. |
| 206 | 193b2: thread shares, schema 3k-3l after the #436b build (#1691; live needs a --migrate window with Jason) | morgana; reviewers duckworth | #1704 | queued | 205 done, 234 done, 236 done, 275 done | Duckworth approves the manifest; bus suite, cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b2, thread shares (schema 3l) | — |
| 207 | 193c: mosaic access user verbs and --level viewer (#1691) | morgana; reviewers duckworth | #1705 | queued | 202 done | Duckworth approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193c, mosaic access user verbs and --level viewer | — |
| 206 | 193b2: thread shares, schema 3k-3l after the #436b build (#1691; live needs a --migrate window with Jason) | launchpad; reviewers duckworth | #1704 | done | 205 done, 234 done, 236 done, 275 done | Duckworth approves the manifest; bus suite, cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b2, thread shares (schema 3l) | Landed 0cbe2e9b3 fast-forward on 17f415792 (10 commits, 22 paths), pushed 19:47Z. Duckworth APPROVE rev 3322 c30795 on 0cbe2e9b3; request rev 3320/3321 c30793 posted as jarvis. Gate (Launchpad, 9538e0fc8, tip differs in BUILD.md only): bus 850/0, cli 869/0, ten scripts/test-*.sh rc=0, touched files whole green, M2-M4 M7-M9 and Q2 killed. One placed fixture red (mos-1272). Schema 3l to 3m; live --migrate is Mos's go under Q173. |
| 207 | 193c: mosaic access user verbs and --level viewer (#1691) | megavolt; reviewers duckworth | #1705 | in-progress | 202 done | Duckworth approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193c, mosaic access user verbs and --level viewer | — |
| 208 | 193d: Console shared list, share controls and viewer read-only (#1691; also after FED R6 when filed) | morgana; reviewers duckworth | #1706 | queued | 192 done, 206 done | Duckworth approves the manifest; webui suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193d, the Console's shared list, share controls and viewer read-only | — |
| 209 | FED R8: the hub window on 10.1.1.54, a recorded acceptance (#414, r4.11 b93f098f0; live window run by Jason and Mos, after the Case 2 install #416, Bean and Chet live and both Hermes gateways stopped) | sage; reviewers duckworth | — | queued | — | Every entry check's output is in the R8 record with who ran it; Jason signs in to the hub and sends one message, Melanie revokes, his next request is refused; Duckworth checks the record (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R8, the hub window on 10.1.1.54 | Stays queued until its entry gates near: Case 2 install on .54 (#416, by 2026-10-27), Bean and Chet live, both Hermes gateways on .54 stopped, R7, #412 phase 2, #419 answer. Filed per mos-1073 after duckworth-0034 (Sage) |
| 210 | Agent host access with sudo through the credential store: grant per agent and host, the stack does ssh and sudo, the agent never sees the key (#1707, Vikunja #424, Q183/Q184, due 10-27; chained on row 199's candidate) | gizmoduck; reviewers duckworth | #1707 | done | — | Duckworth approves DESIGN.md, then the manifest; bus, business, cli suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_host-credentials.md` § Brief: agent host access with sudo through the credential store (#424) | Landed f5753c0e8 on refactor (base 9d165be8c), pushed. Patch-id 723b1c83 matches git diff ad95c888c..0485384e9 on the 23 manifest paths; landed files pass r5 manifest 688d38b9. Duckworth approve r2 rev 2592, r3 29851. Mutants 52/53, M22b equivalent (rev 2584). Gate on ad95c888c+patch: packages 2796/0, scripts 429/0; landed blobs equal the gate tree. |
| 211 | Force-push guard: scripts/git-hooks/pre-push refuses non-fast-forward and delete pushes from every worktree, and scripts/slot-busy.sh exits 1 on BUSY (#1709, Vikunja #426, mos-1078/1083) | rocko; reviewers dewey | #1709 | briefed | — | Dewey approves the manifest; the new or changed scripts/test-*.sh and every other scripts/test-*.sh green on Sage's gate; the mutants killed; hook installed in the canonical checkout with a scripted refused force-push (sage) | `docs/plans/2026-10-20_force-push-guard.md` § Brief: a pre-push hook that refuses force-pushes, and a busy check that fails on BUSY (#426) | — |
| 211 | Force-push guard: scripts/git-hooks/pre-push refuses non-fast-forward and delete pushes from every worktree, and scripts/slot-busy.sh exits 1 on BUSY (#1709, Vikunja #426, mos-1078/1083) | darkwing; reviewers dewey | #1709 | in-progress | — | Dewey approves the manifest; the new or changed scripts/test-*.sh and every other scripts/test-*.sh green on Sage's gate; the mutants killed; hook installed in the canonical checkout with a scripted refused force-push (sage) | `docs/plans/2026-10-20_force-push-guard.md` § Brief: a pre-push hook that refuses force-pushes, and a busy check that fails on BUSY (#426) | — |
| 212 | Case 2 C2-01 packages/library: mapping, scope, reader, resolver, R-rules, renderItems, survey | gosalyn; reviewers webby | #1713 | done | — | Webby approves the manifest. The library tests and the cli suite are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-01 packages/library: mapping, scope, reader, resolver, R-rules, renderItems, survey | Landed ee7b2f137 (pushed 09:13Z) on f6c70f944, alone per mos-1162: the 21 manifest paths from a2c72ac95. Patch-id ea84d3e35470 equals the candidate's; blobs and modes match; verify-commit ok (21 paths). No 212 path changed after c771a36f4. Gate: Sage's landing gate 08:51-09:03Z on 4ced5e63d plus 212's and 197's paths, GATE_PASSED, node 981/0, selftests 429/0. Gosalyn r3 slot 451/0, test-queue 28/0. Row 197 waits for r6 (lane proof stopped, sage-1881). |
| 213 | Case 2 C2-08 creative roles, persona file and block, M1, M2, M6, M10, prompt passed by file | morgana; reviewers webby | #1714 | done | — | Webby approves the manifest. The business, bus and harness suites, `scripts/test-config.sh` and every `scripts/test-*.sh` are green on Sage's gate, and the `models` mutant is killed. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-08 creative roles, persona file and block, M1, M2, M6, M10, prompt passed by file | LANDED 361ba2387 (39 paths incl. 4 packet files). Patch-id f313e69b6f7e = c771a36f4..2b3e67e36; verify-commit ok, 35 manifest paths. Same combined gate as 195: business 200/0 bus 542/0 harness 140/0 cli 663/0, test-config 31/0 and all 10 scripts green, models mutant L1 killed. Webby N2 is row 235 acceptance (mos-1163). |
| 214 | Case 2 C2-11a editor wikilink node, after measuring whether [[ round-trips | rocko; reviewers filbert | #1715 | briefed | — | Filbert approves the manifest. The webui suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-11a editor wikilink node, after measuring whether [[ round-trips | — |
| 214 | Case 2 C2-11a editor wikilink node, after measuring whether [[ round-trips | herb; reviewers filbert | #1715 | in-progress | — | Filbert approves the manifest. The webui suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-11a editor wikilink node, after measuring whether [[ round-trips | — |
| 215 | Case 2 C2-04 the library entry in the business file, consumer skips, M9, mirror, library.file and library.status | herb; reviewers filbert | #1716 | done | — | Filbert approves the manifest. The business, seat, cli, project, bus and webui suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-04 the library entry in the business file, consumer skips, M9, mirror, library.file and library.status | LANDED e54e05463 on 6096b051b (sage-2094). Patch-id 2f68188b6c9d equals a36523713..dde3445ab; 118 paths, blobs and modes equal; tree 5b1613245 equals merge-tree. No path overlap with rows 66/243; no hash pins either way. Gate: Filbert APPROVE rev 2924; herb-0095 grant sage-2071 2634 pass 0 fail, 1 webui skip (editor rebuild needs esbuild 0.28.2, env, not a 215 path), accepted. |
| 216 | Case 2 C2-05a lookup index and queries | researcher; reviewers filbert | #1717 | briefed | 268 done | Filbert approves the manifest. The library suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-05a lookup index and queries | Rulings on phase A: agents/sage/work/queue-216/RULINGS.md (da9555ca2). r2 rebases on row 268's landing and adds the four PRD 1.14 playground tests. |
| 217 | Case 2 C2-06 the cited-reply guard, the page store and the three notices | launchpad; reviewers webby | #1718 | briefed | 268 done | Webby approves the manifest. The library, bus and harness suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-06 the cited-reply guard, the page store and the three notices | — |
| 217 | Case 2 C2-06 the citation guard, the page store, the runner texts and the retry | launchpad; reviewers webby | #1718 | briefed | 268 done | Webby approves the manifest. The library, bus and harness suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-06 the citation guard, the page store, the runner texts and the retry | — |
| 218 | Case 2 C2-12 the setup sheet for Melanie and the survey run sheet | sage; reviewers webby | #1719 | done | — | Doc only. Webby approves it against C2-01's real flags. Before landing, grep `packages/*/tests` and `scripts/test-*.sh` to confirm no test reads either file. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-12 the setup sheet for Melanie and the survey run sheet | LANDED f8276c5eb (pushed, on Darkwing sessions b65f3a0e8). Webby APPROVE r1-r3 (revs 2848, 2856, 2860; r3 candidate 1a38baf5af2a = 89ba79d94). Patch-id 18ba1e40ad29 seat 5c6f73ed7..89ba79d94 equals landed b65f3a0e8..f8276c5eb; three blobs equal 89ba79d94. Gate: doc only, 0 test readers (control 3). Melanie handover waits on C2-06 and Jason (#419). |
| 219 | Case 2 C2-09a one thread per creative instance | rocko; reviewers dewey | #1720 | briefed | — | Dewey approves the manifest. The bus, cli and webui suites are green on Sage's gate, along with every `scripts/test-*.sh`. Watch for hash pins on `bus.js` and `broker.mjs` (`packages/bus/tests/channel-proof.test.mjs` pins a webui file). (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-09a one thread per creative instance | — |
| 220 | Case 2 C2-10a no raw session view (M7) and no raw Discord engine (M8) | researcher; reviewers filbert | #1721 | briefed | — | Filbert approves the manifest. The conversation and discord suites and `scripts/test-discord.sh` are green on Sage's gate, along with every `scripts/test-*.sh`. No test reaches a Discord server. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-10a no raw session view (M7) and no raw Discord engine (M8) | Rulings on phase A: agents/sage/work/queue-220/RULINGS.md (da9555ca2). Scope adds packages/business/src/library-seats.mjs (imported by path, no index export), its test, and control-board serve.mjs. r2 building as a Haiku subagent. |
| 221 | Case 2 C2-20 Bean and Chet persona import, with a count-only dry run | gosalyn; reviewers webby | #1722 | done | — | Webby approves the manifest. The test file runs green on its own and the cli suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-20 Bean and Chet persona import, with a count-only dry run | LANDED 300766450, pushed. Cand 8f403d0d4, manifest 20f05e3a6ad5, 22 paths, verify-commit ok, patch-id dfbb754c6fb4 equals 62ddc96cc..8f403d0d4. Gate 11:07:50-11:19Z on b1fece6f0: test file alone 11/0, harness 140/0, cli 677/0, all scripts green. Brief Gate met: webby-0073 approve, test file alone, cli, scripts. No live run in the Gate; the import itself waits on Jason. N1 N2 N4 go to C2-20b, N3 to DEFERRED. |
| 222 | Case 2 C2-03a fetch-only sync | megavolt; reviewers dewey | #1723 | done | — | Dewey approves the manifest. The library and bus suites are green on Sage's gate, along with every `scripts/test-*.sh`, and the mutant is killed. A search of the test run's output for the fixture token finds nothing. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-03a fetch-only sync | sage-2024 on megavolt-0089: core.createObject=rename in the fetch argv is accepted as a brief departure, since C2-04 inspectMirror refuses nlink 2. Dewey checks it. The askpass env keys, export SAFE in project/src/git.mjs, bus/tests/library-sync.test.mjs and the SURVEY-RUN user line are accepted. The 5 s client timeout residual goes in BUILD.md and Dewey rules fix or defer. Slot after 197 proof 3 and the 249 gate. |
| 223 | Case 2 C2-07 runner retry on a guard refusal, and the runner's notices | researcher; reviewers filbert | #1724 | briefed | — | Filbert approves the manifest. The harness suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-07 runner retry on a guard refusal, and the runner's notices | — |
| 224 | Case 2 C2-05b broker lookup verbs and typed tools | rocko; reviewers dewey | #1725 | briefed | — | Dewey approves the manifest. The bus and harness suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-05b broker lookup verbs and typed tools | — |
| 225 | Case 2 C2-10b Console chat: cited items and the status line | researcher; reviewers dewey | #1726 | briefed | — | Dewey approves the manifest. The webui suite is green on Sage's gate, along with every `scripts/test-*.sh`. Grep for hash pins on `bus.js` and `serve.mjs` before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-10b Console chat: cited items and the status line | — |
| 223 | Case 2 C2-07 runner retry on a guard refusal, and the runner's notices | researcher; reviewers filbert | #1724 | blocked (from briefed): Q226: C2-07 folded into C2-06 (row 217). No work here; Jason parks it. | — | Filbert approves the manifest. The harness suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-07 folded into C2-06 | — |
| 224 | Case 2 C2-05b broker lookup verbs and typed tools | della; reviewers dewey | #1725 | in-progress | — | Dewey approves the manifest. The bus and harness suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-05b broker lookup verbs and typed tools | — |
| 225 | Case 2 C2-10b Console chat: replies, citations, write notices and the status line | researcher; reviewers dewey | #1726 | briefed | — | Dewey approves the manifest. The webui suite is green on Sage's gate, along with every `scripts/test-*.sh`. Grep for hash pins on `bus.js` and `serve.mjs` before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-10b Console chat: replies, citations, write notices and the status line | — |
| 226 | Case 2 C2-13 install script and precheck for the host | gosalyn; reviewers webby | #1727 | in-progress | 215 done, 221 done, 222 done, 230 done | Webby approves the manifest. The test file and the cli suite are green on Sage's gate, along with every `scripts/test-*.sh`. The run on the host is a separate window with Jason's go. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-13b install script and library precheck | — |
| 227 | Case 2 C2-14 end-to-end acceptance on a scratch root | launchpad; reviewers dewey | #1728 | briefed | — | Dewey runs the script and approves. It is green on Sage's gate with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-14 end-to-end acceptance on a scratch root | — |
| 228 | Row A: ZAI_API_KEY passes through ENV_ALLOW to sessions, README line, pass-through test (XS, Haiku high) | researcher; reviewers webby | #1729 | done | — | Webby approves the manifest. The mutant is killed. The seat suite and the cli suite are green on Sage's gate with every scripts/test-*.sh. (sage) | `docs/plans/2026-10-20_case2-rows.md` § Brief: row A, ZAI_API_KEY in the session env allowlist | Landed 86249ac55 (pushed 07:25Z) on 59f817c32: the 3 manifest paths from cf18c42b9. Patch-id b6ffe31feb0d equals the candidate's; modes and blobs match; verify-commit ok (3 paths). Sage's combined 228+238 gate on c515273db plus both rows' 6 paths, 07:11:30Z to 07:23:34Z: seat 165/0, cli 640/0, all ten scripts/test-*.sh 585/0. |
@@ -275,7 +275,7 @@ Generated from `docs/plans/queue.json` revision 3216 by `queue render`. Do not e
| 236 | #436b Gitea grant path build: seat accounts and tokens through an approved grant, schema 3j-3k (design r5 1be535aca, due 10-27 at risk; live waits for Jason's section 8 approval and bootstrap step) | megavolt; reviewers dewey | #1742 | done | 210 done, 234 done | Dewey approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. Section 9's mutants are killed, N9's restart test included. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: #436b Gitea grant path, build (schema 3j-3k) | Landed bd5f11ded on refactor, a fast-forward on be2b008cc. It is 08840f3c1 rebased past 194 (commit 1 unions only, 2-7 =) plus 2 stale-pin fixes and a gitea-not-configured raise test (sage-2559). Dewey approve on manifest 0b00269b. Megavolt gate under Sage CLEAR: 2c green, bus 765, business 228, cli 832, ten scripts rc=0, mutants 40/40; skips named, env-only. Overlap 13/257 clean. Schema 3j-3k. #1742 closed. |
| 237 | Row 199 follow-up N1: one decision publishes once (publish-busy or #spent recheck) | megavolt; reviewers duckworth | #1743 | done | 199 done | Duckworth approves the manifest. The bus suite and every scripts/test-*.sh are green on Sage's gate, and the mutant is killed. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: row 199 follow-up N1, one decision publishes once | Landed d7d943199 on refactor (local, push pending), patch-id c5f4beb4 = e42415935, manifest bb777cb0 blobs checked. Sage gate 01:31-01:44Z on 816d48b03+4 paths: M1 killed (bus 1/13, cli 1/8), every package suite and scripts/test-*.sh green (bus 478, cli 564). |
| 238 | row 200 follow-up N1: the sso-config hint carries --instance NAME (#1744) | launchpad; reviewers webby | #1744 | done | 200 done | Webby approves the manifest; stack-release.test.mjs whole, cli suite and every scripts/test-*.sh green on Sage's gate; the mutant killed (sage) | `docs/plans/2026-10-20_stack-release-sso.md` § Brief: row 200 follow-up N1, the sso-config hint names the instance | Landed d49b1f2bd (pushed 07:25Z) on 86249ac55: the 3 manifest paths from 4c90099ea. Patch-id b9d0bb0ee3c7 equals the candidate's; modes and blobs match; verify-commit ok (3 paths). Sage's combined 228+238 gate on c515273db plus both rows' 6 paths, 07:11:30Z to 07:23:34Z: seat 165/0, cli 640/0, all ten scripts/test-*.sh 585/0. Mutant (sso-config drops --instance) killed, survived none. Round 1 N1 stays on the #1744 carry list. |
| 239 | Row 194 follow-up: the credential revoke runs under the writer's lock, refusals after a revoke say so, and a stale-revision fire test (duckworth-0074, mos-1158) | honker; reviewers duckworth | #1745 | queued | 194 done | Duckworth approves the manifest. The bus, business, cli and webui suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) (sage) | `docs/plans/2026-10-20_agent-lifecycle.md` § Brief: row 194 follow-up, the revoke under the writer's lock and a fire test | — |
| 239 | Row 194 follow-up: the credential revoke runs under the writer's lock, refusals after a revoke say so, and a stale-revision fire test (duckworth-0074, mos-1158) | honker; reviewers duckworth | #1745 | in-progress | 194 done | Duckworth approves the manifest. The bus, business, cli and webui suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) (sage) | `docs/plans/2026-10-20_agent-lifecycle.md` § Brief: row 194 follow-up, the revoke under the writer's lock and a fire test | — |
| 240 | Installer I4: hostcheck, the bwrap profile check, pins and boot unit, from r6 B2 (MVP, Q187, Vikunja #432; split from I3) | darkwing; reviewers filbert | #1746 | done | 233 done | Filbert approves the manifest. The test file, the cli suite and every scripts/test-*.sh are green on Sage's gate, with the mutants killed. (sage) (sage) | `docs/plans/2026-10-20_installer-rows.md` § I4 hostcheck: the bwrap profile check, pins and boot unit | Landed c515273db (pushed 06:57Z) on da3051ccc: the 9 manifest paths from ad1b23ab2. Patch-id 48e376efe8a4 equals the candidate's; modes and blobs match; verify-commit ok (9 paths). Sage's gate on 060a17b54 plus the 9 paths, 06:45:05Z to 06:56:21Z: cli 639/0, all ten scripts/test-*.sh 585/0. Darkwing's r2: singles 37/0, M1-M8 8/8. Filbert N1 and N2 in DEFERRED (da3051ccc); N3 by design. |
| 241 | Row 237 follow-up: one decision lands once, a two-ticket broker land test and three mutants on the landing guards (megavolt-0069, sage-1651, P19 441) | megavolt; reviewers duckworth | #1747 | done | 237 done | Duckworth approves the manifest. The bus, seat and cli suites and every scripts/test-*.sh are green on Sage's gate, and the three mutants are killed. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: row 237 follow-up, one decision lands once, with tests at the broker | Landed 5d4a61776 on refactor, pushed (base e65a3ab4d). Blobs from 3e3ed303e on 2 paths; patch-id a1649f80 equals git diff f5753c0e8..3e3ed303e on those paths; ls-tree modes equal; manifest e8266864 verifies on the landed files. Sage slot on bbf3b1ee8: M1-M3 killed; gate 15 packages 2797/0, 10 script suites 429/0. Duckworth approve 29870 rev 2628. |
| 242 | Row 237 follow-up N1: a retry after publish-timeout pushes once; hold the decision or kill and wait for the push, a slow-push retry test and a mutant (duckworth-0069, P19 440) | megavolt; reviewers duckworth | #1748 | done | 237 done | Duckworth approves the manifest. The bus and cli suites and every scripts/test-*.sh are green on Sage's gate, and the mutant is killed. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: row 237 follow-up N1, a retry after publish-timeout pushes once | Landed 5fb736b92 on refactor, pushed (base 96bac588f). Blobs from 635c10924 on 3 paths; patch-id ceb741ee equals git diff 8fede5fc6..635c10924 on those paths; ls-tree modes equal; manifest 1fbfab6c verifies on the landed files. Megavolt M1/M2 killed on Sage's slot; Sage gate 15 packages 2826/0, 10 script suites 429/0. Duckworth approve 29889 rev 2639. |
@@ -288,13 +288,13 @@ Generated from `docs/plans/queue.json` revision 3216 by `queue render`. Do not e
| 249 | row 235 follow-up: the grant undo records hostLineLeft when install never returned, since the remote add can outlive an abort or timeout (mos-1166) | gizmoduck; reviewers duckworth | #1751 | done | — | Duckworth approves the manifest. On Sage's gate the bus suite and the whole touched test files are green, and a mutant that drops the new catch branch is killed. (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row b follow-up: the undo can't prove the remote add has ended | LANDED 9de5a57ab on refactor, pushed with 7b9737953 (DEFERRED N1). Range-diff: 8a04f4aa4..05595794b vs 8ca064158..9de5a57ab, same 3 paths, patch-id 99551b122e66 on both. Gate sage-2026 14:53Z: grant-executor whole 39/0, bus 598/0, no stub left, C1 (drop :1025) killed by G8 and both G23. Duckworth approve c30070. |
| 250 | K12 (cohort.test.mjs) reads cgroup.events after cgroup.kill under load: print the shim's reason, then fix the cause at the source (Vikunja #450) | gizmoduck; reviewers filbert | #1755 | done | 197 done | reviewer approves the manifest; conversation and control-board suites whole on Sage's slot (sage) | `docs/plans/2026-10-21_k12-cohort-events.md` § Brief: K12 reads cgroup.events after cgroup.kill under load | LANDED 1b6158ce2 on refactor 19:42Z: candidate c1a858975 on 3b0aef995, manifest eca0b8e2, Filbert APPROVE rev 2957 (#1755 c30130). patch-id 29786caa = candidate; blob diff empty; tree 59f1e4781 = merge-tree. Gate on Sage slot sage-2109 (gizmoduck-0150): P1/P2/M1 as predicted, conversation whole 3x 183/183 plus concurrent pair, control-board 124/124. No drift on paths since base. |
| 251 | T27 (host-run.test.mjs:453) lists no identities right after ssh-add -t 1: print timing and agent state, then fix the cause at the source (Vikunja #453) | darkwing; reviewers dewey | #1756 | done | 197 done | reviewer approves the manifest; bus suite whole on Sage's slot (sage) | `docs/plans/2026-10-21_t27-ssh-add-lifetime.md` § Brief: T27 lists no identities right after ssh-add -t 1 | LANDED d37210288 on b79ad5f2c: candidate 27f01ef63 manifest f2bdd684, patch-id 440d2442c976 equal, blob diff empty, tree matches merge-tree. Dewey r2 approve rev 2996 (c30168). Gate: bus suite whole in a clean worktree, 627/627 at 21:30:33Z on Sage's slot. Overlap check vs 194/198/244/253/254 branches: none. DEFERRED sleep-orphan entry follows as a doc commit. |
| 252 | Console inbox Approve and Deny for grant requests (#435, J2) | gander; reviewers webby | — | briefed | — | Webby approves DESIGN.md then manifest; webui, bus, cli suites and every test-*.sh green on Sage's gate; DESIGN mutants killed; REQ-WEB-2 PRD amendment landed first (sage) | `docs/plans/2026-10-21_console-inbox-approve.md` § Gate | mos-1193. Chained on row 196's candidate (Q177): design starts now on Gander's row 196 build; code rebases onto 196's landing. Vikunja #435. |
| 253 | F27: case2-unit.sh and case1-accept.sh move to --tester-socket and refuse --tester-port with stack-release's migration wording; no .53/.54 copy with 196 until this lands (mos-1198 item 4) | gander; reviewers duckworth | #1758 | in-progress | 196 done | reviewer approves DESIGN.md then the manifest; cli and webui suites whole and every scripts/test-*.sh on Sage's slot; mutants per DESIGN.md; static check covers scripts and tests (sage) | `docs/plans/2026-10-21_tester-socket-scripts.md` § Brief: case2-unit.sh and case1-accept.sh move to --tester-socket (F27) | — |
| 252 | Console inbox Approve and Deny for grant requests (#435, J2) | scrooge; reviewers webby | — | in-progress | — | Webby approves DESIGN.md then manifest; webui, bus, cli suites and every test-*.sh green on Sage's gate; DESIGN mutants killed; REQ-WEB-2 PRD amendment landed first (sage) | `docs/plans/2026-10-21_console-inbox-approve.md` § Gate | mos-1193. Chained on row 196's candidate (Q177): design starts now on Gander's row 196 build; code rebases onto 196's landing. Vikunja #435. |
| 253 | F27: case2-unit.sh and case1-accept.sh move to --tester-socket and refuse --tester-port with stack-release's migration wording; no .53/.54 copy with 196 until this lands (mos-1198 item 4) | gander; reviewers duckworth | #1758 | done | 196 done | reviewer approves DESIGN.md then the manifest; cli and webui suites whole and every scripts/test-*.sh on Sage's slot; mutants per DESIGN.md; static check covers scripts and tests (sage) | `docs/plans/2026-10-21_tester-socket-scripts.md` § Brief: case2-unit.sh and case1-accept.sh move to --tester-socket (F27) | Landed 48e450ce8 on refactor (fast-forward from aca16404c, 22 commits; landed sha = candidate, so range-diff and patch-id are identical by construction). Manifest 591020a1 (7 paths) rechecked against the commit: 0 mismatches. Gate: run 5 whole, cli 55 files, webui 58 files, all 10 scripts/test-*.sh green; M1-M40 killed 40/40. Duckworth APPROVE round 1, Gitea #1758 comment 30696. |
| 254 | Library sync follow-up: client timeout, STATUS codes, README (222 deferrals, #1759) | megavolt; reviewers dewey | #1759 | done | 196 done | Dewey approves the manifest; bus, library and webui suites whole and every scripts/test-*.sh green on Sage's slot; the timeout and STATUS mutants killed (sage) | `docs/plans/2026-10-21_library-sync-followup.md` § Gate | Landed 331597709 (candidate b31300d79 blobs and modes on 41b0cf755; patch-id 96216216 equal, range-diff differs only in the message) plus doc eb9672c91 (Dewey N1 README sentence closed; N2 four 502 codes to DEFERRED, binding the Console library routes row). Merged-tree rerun on Sage's slot 23:44-23:55Z: library 77/0, bus 628/0, webui 305/0 with 1 skip, cli 749/0. Megavolt's gate megavolt-0102 covers scripts and both mutants. |
| 255 | Bundled Pocket ID for users with no OIDC provider, design (Q203, #1760) | launchpad; reviewers webby | — | queued | 252 done | Webby approves DESIGN.md; design only, build rows follow after Sage checks it against Q203, O6, REQ-WEB-2 and REQ-INST-1 (sage) | `docs/plans/2026-10-21_bundled-pocket-id.md` § Gate | Vikunja 458, Gitea #1760, mos-1202 item 2. REQ-INST-2 lands in PRD 1.10 as post-v1 (mos-1204). Design only: no install, no download, no .53/.54, dragon-lin keeps Authentik. |
| 256 | the human deletes a creative instance; any other actor refused, fire unchanged (REQ-COS-3, before the v1 tag) | honker; reviewers webby | #1761 | done | 194 done | Webby approves the manifest; touched test files whole plus business, bus, cli and webui suites and every scripts/test-*.sh green on Sage's slot; both mutants killed (sage) | `docs/plans/2026-10-21_creative-delete-human.md` § Brief: the human deletes a creative instance (REQ-COS-3, after row 194) | LANDED f12441d4f ff from 0165be266 (4 Honker commits, patch-id b28e6374). Webby APPROVE manifest-r3 01eb75aa (10 paths), request c30518. Gate 10:33-10:52Z: 4 touched files 31/0, 18 packages 3757/0 (bus 788, business 231, cli 832, webui 335), 10 scripts rc=0, mutants M1-M4b killed; skips named, env only (webui esbuild pin, test-queue root, test-task live x2). Overlap 13/257 clean. Lands before the 10-27 v1 tag. |
| 257 | Q198 S1: pressure parser and pure rules evaluator (post-v1) | morgana; reviewers filbert | #1762 | in-review, round 2, request posted | — | Reviewer approves the manifest; touched test files whole plus the package suite green on Sage's slot; both brief mutants killed (post-v1, MVP keeps slot priority) (sage) | `docs/plans/2026-10-21_post-v1-row-briefs.md` § Brief: Q198 S1 pressure parser and pure rules evaluator | — |
| 258 | Email E01: mail PRD addendum draft and schemas (post-v1) | unassigned | #1763 | queued | — | Reviewer approves the manifest; the row's named tests whole plus the mail suite and touched suites green on Sage's slot; reviewer-named mutants killed (post-v1, MVP keeps slot priority) (sage) | `docs/plans/2026-10-21_post-v1-row-briefs.md` § Brief: E01 mail PRD addendum draft and schemas | — |
| 258 | Email E01: mail PRD addendum draft and schemas (post-v1) | ludwig; reviewers della | #1763 | in-progress | — | Reviewer approves the manifest; the row's named tests whole plus the mail suite and touched suites green on Sage's slot; reviewer-named mutants killed (post-v1, MVP keeps slot priority) (sage) | `docs/plans/2026-10-21_post-v1-row-briefs.md` § Brief: E01 mail PRD addendum draft and schemas | — |
| 259 | Email E03: mail ledger, state and quotas (post-v1) | unassigned | #1764 | queued | 258 done | Reviewer approves the manifest; the row's named tests whole plus the mail suite and touched suites green on Sage's slot; reviewer-named mutants killed (post-v1, MVP keeps slot priority) (sage) | `docs/plans/2026-10-21_post-v1-row-briefs.md` § Brief: E03 mail ledger, state and quotas | — |
| 260 | Email E04: bounded MIME parsing and quarantine (post-v1) | unassigned | #1765 | queued | 258 done | Reviewer approves the manifest; the row's named tests whole plus the mail suite and touched suites green on Sage's slot; reviewer-named mutants killed (post-v1, MVP keeps slot priority) (sage) | `docs/plans/2026-10-21_post-v1-row-briefs.md` § Brief: E04 bounded MIME parsing and quarantine | — |
| 261 | Email E05: Graph source module against a test-local registry (post-v1) | unassigned | #1766 | queued | 259 done, 260 done | Reviewer approves the manifest; the row's named tests whole plus the mail suite and touched suites green on Sage's slot; reviewer-named mutants killed (post-v1, MVP keeps slot priority) (sage) | `docs/plans/2026-10-21_post-v1-row-briefs.md` § Brief: E05 Graph source module against a test-local registry | — |
@@ -305,19 +305,29 @@ Generated from `docs/plans/queue.json` revision 3216 by `queue render`. Do not e
| 266 | Case 2 C2-10c Console room view: merged timeline and @name completion | researcher; reviewers dewey | #1771 | queued | 225 done, 265 done | Dewey approves the manifest. The webui suite is green on Sage's gate, along with every touched suite. Grep for hash pins on changed files before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-10c Console room view: merged timeline and @name completion | Haiku high subagent under decision 129. |
| 267 | Case 2 C2-15 hybrid universes: exclusions, pins and the end-to-end crossover | morgana; reviewers filbert | #1772 | queued | 216 done, 217 done, 224 done | Filbert approves the manifest. The brief's tests and the end-to-end crossover run whole plus every touched suite on Sage's slot. Grep for hash pins on changed files before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-15 hybrid universes: exclusions, pins and the end-to-end crossover | R8 accepted (sage-2218). Webby's R8 noise note (gosalyn-0127) is for the builder. |
| 268 | Case 2 C2-01c playground levels: universe-level prefixes and draft universes | gizmoduck; reviewers webby | #1773 | done | — | Webby approves the manifest. The brief's tests run whole plus the library suite and every touched suite on Sage's slot; the brief's gate grep over packages and docs/case2 finds no library-playground-outside-book. Grep for hash pins on changed files before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-01c playground levels: universe-level prefixes and draft universes | Landed c27207f6e fast-forward on ce6a96ad6 (1 commit, 9 paths, digest caa0fa4e). r4 is a carry of r3 aca53c4f0: range-diff =, patch-id 2942acc8 equal, blobs equal. Webby APPROVE round 2 on ecd9b35eb stands; carries webby-0112 (r3) and webby-0113 (r4, notes 34e1ab2bf). Gate: Gizmoduck r4 rerun brief 14/14, library 83/83, business 209/209, 0 skips; gate grep empty. 194 rebases next (business.test.mjs). |
| 269 | 264b T1 item 7: per-session native store, workspace and single-writer fence | gizmoduck; reviewers duckworth | #1774 | briefed | 264 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Tests T1 run half, T2 workspace and path mutants; whichever of 191 and 264b lands second wires 191's admission checks (Q5). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | — |
| 269 | 264b T1 item 7: per-session native store, workspace and single-writer fence | gizmoduck; reviewers duckworth | #1774 | done | 264 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Tests T1 run half, T2 workspace and path mutants; whichever of 191 and 264b lands second wires 191's admission checks (Q5). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | Landed efcc9ea8d fast-forward on 94c33a43c (13 commits, 12 paths, manifest 455c12ee). r3 is a carry of r2 aafcc857c, range-diff = (Duckworth 0194, c30756, rev 3307). Gate r3 18:37-18:50Z all green, 0 skips: bus 834, seat 172, cli 869, case2-unit 43, singles 38. M1-M12 kills carry. Re-pin needs the Console restart by Jason or Mos right after the host. A 264a rollback refuses a v2 sessions.json, see BUILD.md Rollback. |
| 270 | 264c T1 item 7: compaction count, exact for Pi | gizmoduck; reviewers duckworth | #1775 | briefed | 269 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Test T3 Pi half; Claude shows unknown with the Vikunja 466 reason (Q6). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | — |
| 271 | 264d T1 item 7: clear context through row 194's lifecycle stop | gizmoduck; reviewers duckworth | #1776 | briefed | 194 done, 269 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Tests T4 and T6; rebased onto 194's landing with no 194 code copied (Q4). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | — |
| 272 | 264e T1 item 7: mosaic session CLI and Console chooser | gizmoduck; reviewers duckworth | #1777 | briefed | 264 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Tests T5 legacy single human and T7 Console half; controls for unlanded verbs refuse with a reason (Q1, Q7). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | — |
| 272 | 264e T1 item 7: mosaic session CLI and Console chooser | huey; reviewers scrooge | #1777 | briefed | 264 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Tests T5 legacy single human and T7 Console half; controls for unlanded verbs refuse with a reason (Q1, Q7). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | — |
| 273 | Q175 phase 2: stack-release.sh activate --send and --no-sso (F39, mos-1246) | darkwing; reviewers filbert | #1778 | done | — | Filbert approves the manifest. stack-release.test.mjs and stack-release-external.test.mjs run whole plus scripts/test-release.sh on Sage's slot, and the brief's mutants are killed. (sage) | `docs/plans/2026-10-22_activate-send-nosso.md` § Brief: activate --send and --no-sso | landed 0bbf0f6d1 03:35:55Z, FF of 378a76b85; rebase of cac7d0f67, 6 range-diff pairs =, patch-ids equal, manifest e1031023 unchanged. Gate by Darkwing on 0bbf0f6d1: stack-release.test 218/218, external 25/25, test-release.sh rc 0, T58 c0 pass c1 fail, M1-M7 killed. Filbert APPROVE rev 3111 c30308, carry c30319. Docs 5365adaef (TOOLS.md N1 + BUILD-LOG). #1778. |
| 274 | FED R3: broker user principal (#414) | morgana; reviewers dewey | #1779 | done | — | Dewey approves the manifest. The bus and harness suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_federation-r3.md` § Brief: FED R3, the broker user principal | LANDED 4c485c63b ff from 47a35bf43 (6 Morgana commits). Dewey APPROVE c30487 on 4c485c63b (manifest 00047b52, 13 paths). Gate 10:09-10:22Z: 23 touched files 0 fail, bus 787, harness 151, business 228, cli 832, seat 167, 10 scripts rc=0 (skips test-queue 1, test-task 2), 22/22 mutants. M3x wall code not-holder (kit comment said human-required). r5 f58cfc361 red, void. Overlap 13/257 clean. |
| 275 | 193b1b: viewer verbs and board refusals (also after FED R4 when filed) | morgana; reviewers duckworth | #1780 | briefed | 205 done, 274 done | Duckworth approves the manifest. The bus suite and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b1b, viewer verbs and board refusals | — |
| 276 | Row 253 F5: supervise the legacy AS_USER talk and diagnostics in the case scripts | gander; reviewers duckworth | #1781 | briefed | 253 done | Duckworth approves the manifest. The touched test files run whole, every scripts/test-*.sh is green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_case-sudo-legacy-followup.md` § Brief: supervised legacy AS_USER calls | — |
| 275 | 193b1b: viewer verbs and board refusals (also after FED R4 when filed) | morgana; reviewers duckworth | #1780 | done | 205 done, 274 done | Duckworth approves the manifest. The bus suite and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b1b, viewer verbs and board refusals | Landed 3410df7c3 on refactor (fast-forward from 48e450ce8, 3 commits; landed sha = candidate, so range-diff and patch-id are identical by construction). Manifest 39847b15 (4 paths) blobs match the commit. Gate: whole files 7/22/6, bus 834, cli 861, harness 151, business 231, seat 167, 0 fail; 10 scripts/test-*.sh rc 0 with 3 named skips; M6 and M10 killed. Duckworth APPROVE round 1, Gitea #1780 comment 30717. |
| 276 | Row 253 F5: supervise the legacy AS_USER talk and diagnostics in the case scripts | gander; reviewers duckworth | #1781 | in-progress | 253 done | Duckworth approves the manifest. The touched test files run whole, every scripts/test-*.sh is green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_case-sudo-legacy-followup.md` § Brief: supervised legacy AS_USER calls | — |
| 277 | The owner sees a thread agent's claims: an owner or reader read lists held thread claims (10-27, mos-1262; no user thread live before it) | morgana; reviewers duckworth | #1782 | done | 205 done | Duckworth approves the manifest. The touched test files run whole, the bus and cli suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_owner-thread-claims.md` § Brief: the owner sees thread claims | LANDED 62f53b1e0 ff from 46dedf925 (7 Morgana commits, range patch-id 7f0533520bd2). Duckworth APPROVE manifest e281aa2c (10 paths), comment 30539. Gate 11:01-11:14Z: 7 touched files 0 fail, bus 793, cli 836, harness 151, business 231, seat 167, 10 scripts rc=0, M1-M9 killed; skips named, env only (test-queue root, test-task live x2). Overlap 13/257 clean. N3 per-session claims carry to 264a r7. |
| 278 | The Console Agents view shows thread claims (follow-up to row 277; no user thread live before it) | gander; reviewers webby | #1783 | done | 277 done | Webby approves the manifest. The touched test files run whole, the webui and bus suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_owner-thread-claims.md` § Brief: the Console shows thread claims | LANDED 385a7194c ff on 6af85df4b (9 commits, manifest 8fa859a0). Run 3 green: bus 793/0/0, webui 345/0/1, ten shells 425/0 under SHELL-LIMITS 3x, M1-M6 killed. Runs 1-2 void (fixture fixes 964b6b184, 9696d1fcb per mos-1275). Skips named (Webby N1): webui esbuild 0.28.1/0.28.2 rebuild; test-queue canonical-root check; test-task two live-provider cases (row 244). Webby APPROVE c30588. #1783 closes. |
| 279 | stopRun reads a live run's status at the deadline: the deadline branch is the only time exit (191 follow-up, MVP, mos-1269; lands before 194 b16) | rocko; reviewers dewey | #1784 | done | — | Dewey approves the manifest. The processing-control package's tests and every other touched test file run whole, the business and cli suites are green on Sage's gate, and M1 is killed. (sage) | `docs/plans/2026-10-22_stoprun-deadline.md` § Brief: stopRun's deadline branch is the only time exit | LANDED ceadad993 (carry of cad927137 onto 343b6dd76: range-diff 1:= 2:=, patch-ids 6d8ae7f4/e484c297 equal, Dewey APPROVE dewey-0809 c30409 + carry). Fix: stopRun loop is for(;;), deadline branch is the only time exit. Sage gate green: business 209/0/0, cli 794/0/0, discord processing+stop 9/0/0; Rocko stop-run 13/13, processing-control 62/62, M1 killed. Overlap sweep clean (13, 257). Next: 194 b16 rebases onto it (mos-1269). |
| 280 | Bus tests: the scratch sshd proves it owns its port (194 gate follow-up, MVP, mos-1270) | rocko; reviewers duckworth | #1785 | done | — | Duckworth approves the manifest. Both files run whole, the bus suite is green on Sage's gate, the 30-run repeat has no timeout, and M1 is killed. Row 194 b17's full gate then runs on top. Any red goes to Mos (mos-1270). (sage) | `docs/plans/2026-10-22_sshd-port-race.md` § Brief: the scratch sshd's port is ours before a test uses it | Landed b0fd28b6f 07:57:30Z, ff from da5d3651d: b765f5cb8 (Duckworth approve rev 3153, manifest 27806c05) + slim packet. Sage gate bus 656/0/0, ge 41, hr 26 (c30431). Repeat 2010/2010 under cli 1588/1588, no timeout; M1/M2 killed 4/4. Full evidence d6c924764 on rocko/row280-da5d-20261022. N2-N4 carry in BUILD.md. 194 b17 rebases on b0fd28b6f. |
| 281 | A fire or a delete revokes the instance's host and Gitea grants (194/236 follow-up, MVP, mos-1271) | megavolt; reviewers dewey | #1786 | done | 236 done | Dewey approves the design, then the manifest. Bus, business, cli and every touched test file run whole, plus the Docker gitea-grant test and every scripts/test-*.sh, green on Sage's gate. M1-M4 killed. Lands before .53 Case 1 hires a granted instance. Red goes to Sage, then Mos. (sage) | `docs/plans/2026-10-22_fire-revokes-grants.md` § Brief: no grant outlives its instance | LANDED e479521d3 ff on 385a7194c (17 commits, manifest b73d4c81). Slot on 7979fb41e green: bus 819, business 231, cli 837, dewey-bus 76 with gitea-grant-docker run, shells green under 3x limits, M1-M22 killed. M1 hang fixed in b4a (mos-1273). Skips: test-task row 244 live-provider pair, test-queue canonical root. DEFERRED: N1 host-unknown hint verb, N2 fire tool text, to the follow-up row. Dewey APPROVE c30614. |
| 282 | PRD REQ-LIB-6 rewritten to Q223 and Q226 (mos-1320 item 8) | gosalyn; reviewers webby | #1787 | done | — | Webby approves against the ruling. requirements.test.mjs and every scripts/test-*.sh that reads the PRD or requirements.json are green on Sage's gate. Jason gets a report. (sage) | `docs/plans/2026-10-20_case2-rows.md` § PRD REQ-LIB-6 rewritten to Q223 and Q226 (mos-1320 item 8) | Landed 647424442 on 86a665bdf: 1841f7d4e and 6b5b06819 rebased, patch-ids equal (4e50b4b6, 16a7a02f), range-diff =, tree 4f30ed10a = Gosalyn's gated trial tree. Webby APPROVE rev 3308 c30764 on 6b5b06819. Gate 19:06Z on the tip tree: bus+tasks requirements and tasks verbs 37/0/0, 0 skips; no scripts/test-*.sh reads the PRD (control ok). PRD 1.15. 1.16 is row 291, waits for Jason. |
| 283 | Case 2 C2-01d supporting folders, the supporting scope, the editor field and supportingPath (M12) | honker; reviewers webby | #1788 | briefed | 268 done, 282 done | Webby approves the manifest. The library and business suites are green on Sage's gate, along with every scripts/test-*.sh. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-01d supporting folders, the supporting scope, the editor field and supportingPath (M12) | — |
| 284 | Case 2 C2-05c the supporting scope and blob in the lookups | della; reviewers dewey | #1789 | briefed | 224 done, 283 done | Dewey approves the manifest. Library, bus and harness suites and every scripts/test-*.sh green on Sage's gate. Grep packages/*/tests for the scope enum pin and ['canon', 'playground'] before in-review; update each. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-05c the supporting scope and blob in the lookups (sage-2833) | — |
| 285 | Case 2 C2-08b role changes for agent writes and the chief's read grant | scrooge; reviewers webby | #1790 | briefed | 213 done, 291 done | Webby approves the manifest. Business suite, test-config.sh and every scripts/test-*.sh green on Sage's gate. Grep for CREATIVE_KINDS and 64-hex pins of changed role files. Sage checks it against mos-1309 item 5. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-08b role changes for agent writes and the chief's read grant (mos-1309 item 5) | — |
| 286 | Case 2 C2-03b push and per-seat credentials for the library mirror (with R4, mos-1323) | ludwig; reviewers dewey | #1791 | briefed | 222 done | Dewey approves the manifest. Library and bus suites and every scripts/test-*.sh green on Sage's gate, mutant killed. A search of the run output for the fixture token finds nothing. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-03b push and per-seat credentials for the library mirror | — |
| 287 | Case 2 C2-21 the grant executor for a library project | megavolt; reviewers dewey | #1792 | briefed | 226 done, 285 done | Dewey approves the manifest. Bus suite, Gitea Docker test, install test and every scripts/test-*.sh green on Sage's gate. A search of the run output for the fixture admin and seat tokens finds nothing. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-21 the grant executor for a library project | — |
| 288 | Case 2 C2-02 agent writes, the notice, revert and her Console saves | honker; reviewers webby | #1793 | briefed | 215 done, 282 done, 283 done, 284 done, 285 done, 286 done | Webby approves the manifest. Library, bus and harness suites and every scripts/test-*.sh green on Sage's gate, mutants killed. A search of the run output for the fixture tokens finds nothing. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-02 agent writes, the notice, revert and her Console saves | — |
| 289 | Case 2 C2-11b Console library pages and her saves (Haiku subagent, decision 129) | researcher; reviewers filbert | #1794 | briefed | 214 done, 288 done | Filbert approves the manifest. The webui suite and every scripts/test-*.sh are green on Sage's gate. Grep for hash pins on bus.js and serve.mjs before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-11b Console library pages and her saves | — |
| 290 | Case 2 C2-12b the setup sheet rewrite for free chat, supporting folders and saves | louie; reviewers webby | #1795 | briefed | 217 done, 218 done, 285 done, 291 done | Doc only. Webby approves it against the ruling. Before landing, grep packages/*/tests and scripts/test-*.sh to confirm no test reads the file. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-12b the setup sheet rewrite for free chat, supporting folders and saves | — |
| 291 | PRD 1.16 library.write routes under REQ-LIB-6 (mos-1328) | gosalyn; reviewers webby | #1796 | briefed | 282 done | Webby approves the text. Jason says yes to the exact text (Mos asks him, mos-1328 step 3); no landing before that. requirements.test.mjs and every scripts/test-*.sh that reads the PRD or requirements.json are green on Sage's gate. Mos gets the landing commit. (sage) | `docs/plans/2026-10-22_prd-1-16-brief.md` § PRD 1.16 library.write routes under REQ-LIB-6 (mos-1328) | — |
<!-- mosaic-queue:end -->
+3776 -105
View File
File diff suppressed because it is too large. Load diff
+104 -32
View File
@@ -1,6 +1,6 @@
# PRD: Mosaic Stack
- Status: approved, version 1.14. Jason approved draft 0.4 unchanged as
- Status: approved, version 1.15. Jason approved draft 0.4 unchanged as
1.0 on 2026-10-05 (lead decision 61). They approved version 1.1, the
Organization requirements, in decision 03130d08-ddce-480d-a601-e59f64744fc3
on 2026-10-14. They approved version 1.2, the chief of staff, the
@@ -41,7 +41,21 @@
takes the same path as 1.11 (mos-1231, vikunja:19/463). Version 1.14
adds the playground levels to REQ-LIB-2 and a draft universe to
REQ-LIB-3, from the approved playground ruling. It takes the same path
as 1.11 (mos-1233, mos-1238, mos-1241, vikunja:19/464).
as 1.11 (mos-1233, mos-1238, mos-1241, vikunja:19/464). Version 1.15
rewrites REQ-LIB-6 to Jason's Q223 and Q226 rulings and adds supporting
folders to REQ-LIB-1 and REQ-LIB-2. Mos ordered it in mos-1320 item 8,
and Jason gets a report.
- Version 1.15 source: Jason's Q223, answering Q29: "The agents are not
allowed to write the prose into the writing projects. Agents are
allowed to CRUD character sheets, info summaries, cross-link info, and
supporting documentation. They are NOT allowed to actually write any of
the story." and "They are permitted to freely converse with the writer
via chat interface." His Q226: "Q30: yes." to Mos's reading that a
folder nobody has marked counts as story, and "F43: citations should be
requireable. Tunable setting." Mos's reading of F43: "require-citation
is a setting changed by a human only. Mos's default is off." The text
is drafted from the Case 2 design's ruling after r11, "agents never
write story", b68d79663 on `gosalyn/case2-design`.
- Version 1.14 source: Jason's Q176 lines TARGET.md:92 "Brainstorm ideas
in a "playground" where new universes can be created", :93 "Brainstorm
ideas in a "playground" for books in a universe" and :94 "This means
@@ -1168,12 +1182,14 @@ library is the user's own writing repository.
- **REQ-LIB-1.** A library project maps the user's writing repository
onto universes, books, entity types such as character and place, the
manuscript, and playgrounds. The mapping sits in the organization file
and fits the user's own folder layout. Only the human changes it, and
an agent's organization write that changes it is refused. So
manuscript, playgrounds and supporting folders. The mapping sits in the
organization file and fits the user's own folder layout. Only the
human changes it, and an agent's organization write that changes it
is refused. So
`universe.book.{character,place,...}` reads as universe, book folder,
type folder. (Design 1.1 and M9. Q176 Case 2 "Data is stored in
little-endian organizational scheme".)
little-endian organizational scheme". Version 1.15: supporting
folders.)
- **REQ-LIB-2.** Playgrounds are never confused with canon. A playground
has one of three levels, set by where its prefix sits. A book-level
prefix lies inside one of its universe's book folders, and its files
@@ -1186,15 +1202,20 @@ library is the user's own writing repository.
mapping is refused when a prefix lies inside or holds another
universe's book folder, when a prefix equals or holds a book folder of
its own universe, when two prefixes overlap, when a draft has books or
no prefix, and when a hybrid names a draft as a source. A file's scope
comes from its path alone, prefixes first: a Markdown file under a
playground prefix at any level is playground, any other Markdown file
in a book folder is canon, and everything else is unmapped and never
returned. Lookups return canon unless the caller asks for playground.
An agent can't cite a playground file as a fact, a finding or a
correction. The Console shows each cite's scope. (Design 1.2, 5.2 and
7.4, and the playground ruling, 51fdfb7e2. Q176 Case 2, the playground
items, TARGET.md:92-94. Version 1.14: the levels and the draft.)
no prefix, and when a hybrid names a draft as a source. Supporting
prefixes, the folders agents may write in (REQ-LIB-6), follow the same
placement rules. A file's scope comes from its path alone, prefixes
first: a Markdown file under a supporting prefix is supporting, one
under a playground prefix at any level is playground, any other
Markdown file in a book folder is canon, and everything else is
unmapped and never returned. A folder in both lists is supporting.
Lookups return canon unless the caller asks for playground or
supporting. A citation marked as canon must name a canon file, so an
agent can't cite a playground or supporting file as canon. The Console
shows each cite's scope. (Design 1.2, 5.2 and 7.4, the playground
ruling, 51fdfb7e2, and the ruling after r11. Q176 Case 2, the
playground items, TARGET.md:92-94. Version 1.14: the levels and the
draft. Version 1.15: supporting.)
- **REQ-LIB-3.** Universes stay separate. Every lookup names one universe,
and its results never leave it. A draft universe (REQ-LIB-2) counts as
a universe here. It has no canon, so a lookup in a draft asks for
@@ -1229,18 +1250,27 @@ library is the user's own writing repository.
item and "Connections are always visible".)
- **REQ-LIB-6.** The creative roles are ideas, research, proofreader,
fact-checker and continuity, plus a companion role that the user's own
named agents run as. No creative agent can write to the user's
repository. Its tools are the lookups and a message to the human, and
research and companion also fetch web pages through the broker. Every
message an agent sends the user is a short list of items. Each item
cites a line of the repository at a commit, or a page the agent
fetched, and the broker checks every cite before it stores the message.
The one exception is a message with no items and one of three fixed
notices: no cited answer, no answer, or the turn failed. The Console
shows each notice as a fixed line, with no text from the model.
The user sees only stored messages, on every channel. (Design 4, 5.1,
5.2 and 5.3; Q180 for Bean and Chet. Q176 Case 2, the agent list and
"User sets strict guardrails for agent capabilities".)
named agents run as. Agents never write the story. A creative agent may
create, edit and delete Markdown files only under the supporting
folders a human marks (REQ-LIB-1), such as character sheets, summaries,
cross-link notes and research notes. Every other path in the user's
repository is read-only to it, chapters and unmarked folders included,
and the broker refuses such a write before any git call. Each agent
write is one commit by that agent's own Gitea account, with a trailer
naming the agent. The user sees a notice for it and can revert it. Its
tools are the lookups, a message to the human and the two write tools,
and research and companion also fetch web pages through the broker.
Agents talk with the user in free text. A reply may cite lines of the
repository at a commit or pages the agent fetched, and the broker
checks every citation before it stores the message. A setting,
`requireCitation`, makes every reply carry a citation, except the
runner's fixed notices for no answer, a failed turn, no answer passing
the citation check and an answer too long to send. Only the human
changes it, and it is off by default. The user sees only stored
messages, on every channel. (Design 4, 5.2 and the ruling after r11.
Q223 and Q226. Q180 for Bean and Chet. Q176 Case 2, the agent list,
"User sets strict guardrails for agent capabilities" and TARGET.md:101
"Agents NEVER write prose for the user". Version 1.15.)
- **REQ-LIB-7.** The user talks to several creative agents in one chat. A
room holds one thread per creative agent in a project. A message goes
to every agent in the room, or only to the agents named by @tags at its
@@ -1591,11 +1621,14 @@ latest-by date and a hard target (Q176).
- REQ-PROJ-1's "no remote" covers project record repositories. A
library project is a separate kind with no record repository, and its
mirror fetches from the remote (design 2.1, 7.2).
- REQ-LIB-6 has a stated residual: an `idea` item carries free text,
up to 5 items of 200 characters per reply, and code can't tell story
text from advice (design 5.1). Accepting it is Jason's or Melanie's
(design section 10, step 8). It goes to Jason through Mos
(vikunja:19/465).
- REQ-LIB-6 has a stated residual: a supporting doc can hold any text,
a drafted scene included, and code can't tell a scene from a note.
Every agent write shows as a notice and a commit trailer, and the user
can revert it. Nothing an agent writes lands in a story path. The
agent's Gitea token can write any path of the repository, and the
bus's path check is the only path limit, which holds while the bus
code does (design, ruling after r11). (Version 1.15 replaced the
item-list residual. The old text is in the Version 1.15 note.)
- Where each id is built: REQ-USER-3 and REQ-USER-4 are rows 193a to
193d (#1691), with FED R1 to R6. REQ-LIFE-1 is row 194 (#1692) and
its follow-up row 239. REQ-KILL-1 is row 191 (#1689). The REQ-LIB ids
@@ -1678,6 +1711,45 @@ latest-by date and a hard target (Q176).
candidates if they have started. C2-12's setup sheet gets one line
per level (design section 8). Sage files it under Vikunja 464. This
amendment adds the requirements only.
- Version 1.15:
- The 1.14 text of REQ-LIB-6 was: "The creative roles are ideas,
research, proofreader, fact-checker and continuity, plus a companion
role that the user's own named agents run as. No creative agent can
write to the user's repository. Its tools are the lookups and a
message to the human, and research and companion also fetch web pages
through the broker. Every message an agent sends the user is a short
list of items. Each item cites a line of the repository at a commit,
or a page the agent fetched, and the broker checks every cite before
it stores the message. The one exception is a message with no items
and one of three fixed notices: no cited answer, no answer, or the
turn failed. The Console shows each notice as a fixed line, with no
text from the model. The user sees only stored messages, on every
channel. (Design 4, 5.1, 5.2 and 5.3; Q180 for Bean and Chet. Q176
Case 2, the agent list and "User sets strict guardrails for agent
capabilities".)"
- The 1.12 note's REQ-LIB-6 residual was: "an `idea` item carries free
text, up to 5 items of 200 characters per reply, and code can't tell
story text from advice (design 5.1). Accepting it is Jason's or
Melanie's (design section 10, step 8). It goes to Jason through Mos
(vikunja:19/465)." Q223 removed the item list, and the residual went
with it. The note now states the new one.
- REQ-LIB-1 gains "and supporting folders" in its first sentence and a
version tag. REQ-LIB-2 gains the supporting scope, its placement rule
and the canon-cite rule, which replaces "An agent can't cite a
playground file as a fact, a finding or a correction." The rest of
both is unchanged.
- REQ-LIB-3, REQ-LIB-4, REQ-LIB-5 and REQ-LIB-7 are unchanged. REQ-LIB-4
already says bus writes and pushes come in later rows (C2-02,
C2-03b), which covers agent writes and the user's Console saves. Its
fetch still uses a read-only token, now the chief of staff's read
grant.
- The titles of REQ-LIB-1, REQ-LIB-2 and REQ-LIB-6 in
`packages/tasks/requirements.json` change with their text. The id
count stays 85.
- The new refusal codes, settings and row order are in the design's
ruling after r11 and in the Q226 briefs of
`docs/plans/2026-10-20_case2-rows.md`. Sage files the rows under
Vikunja 416. This amendment adds the requirements only.
## Risks and open questions
+34 -7
View File
@@ -240,10 +240,12 @@ a receipt with empty `changes`. A refusal is `action.refused` with its code alon
| `project.create` | `{name,repo?}`; human sessions only (row 121, #1615). `repo` must name one of the business's `projects` (`unknown-repo`); a second project with the same name refuses `project-exists`. Returns `{id}`. |
| `project.list` | No args; human sessions only. The business's projects, oldest first. |
| `thread.create` | `{project,name}`; human sessions only. The thread belongs to the caller and to the business's one thread instance (Projects and threads, below). Returns `{id,instance}`. Refuses `project-not-found`, `thread-exists`, `thread-instance-missing` or `thread-instance-ambiguous`. |
| `thread.list` | `{project?}`; human sessions only. The caller's own threads, oldest first. |
| `thread.list` | `{project?,shared?}`; human sessions only. The caller's own threads, oldest first. With `shared: true` (row 206), the threads others own that the caller may read (Thread shares, below), each row adding `owner`. |
| `thread.open` | `{thread,session?}`; human sessions only. Records a `thread_opens` row, which asks the launcher to start the thread's session. Returns `{opened}`. |
| `thread.send` | `{thread,session?,body,class?}`; human sessions only. A message to the thread's instance, in that thread, with its `human.input` event as `request`. Returns `{id,request}`. |
| `thread.messages` | `{thread,session?,after?,limit?}`; human sessions only. The session's messages oldest first, after the message `after` names (`message-not-found` when it isn't in the session), at most `limit` (default 50, 1 to 200). Writes nothing. |
| `thread.messages` | `{thread,session?,after?,limit?}`; human sessions only. The session's messages oldest first, after the message `after` names (`message-not-found` when it isn't in the session), at most `limit` (default 50, 1 to 200). A thread shared with the caller reads too, any session of it (row 206). Writes nothing. |
| `thread.share`, `thread.unshare` | `{thread,to}`; the thread's owner only (row 206, #1704). `to` is `user:<id>`, the business human's name or `*`; yourself refuses `invalid-request`. A share refuses `share-exists` when one is in force, and `share-not-covered` when a `user:` target has no live grant covering the thread. An unshare refuses `share-not-found` when none is. Each writes one `thread_shares` row. Returns `{thread,to,op}`. |
| `thread.shares` | `{thread}`; the thread's owner only. The shares in force, `[{to,at,by}]`, oldest first. Writes nothing. |
| `session.list` | `{thread}`; human and tester sessions (row 264a, #1769). The thread's sessions oldest first, `[{id,at,name,predecessor,default}]`. Writes nothing. |
| `session.create` | `{thread,name,op}`; human and tester sessions. A new session of the thread, metadata only. A retry with the same `op` and name returns the first row; another name under that `op` refuses `session-op-conflict`, and a taken name `session-exists`. Returns `{id,thread,name}`. |
@@ -283,7 +285,32 @@ any run but the thread's current holder.
A user reaches only their own threads (REQ-USER-2): `thread.open`, `thread.send` and
`thread.messages` refuse `thread-not-found` for an unknown id and `thread-not-owned` for
another user's thread.
another user's thread, unless it is shared with them (Thread shares, below).
### Thread shares (row 206, #1704)
Schema `3m` adds `thread_shares`, append-only: one row per `share` or `unshare`, and the latest
row per thread and target is the share's state. A thread's owner shares it read-only with a
user (`user:<id>`), the business human or everyone (`*`). The 3l→3m step only adds the table,
so every existing thread stays private to its owner.
A session may read a thread it doesn't own only when all four hold (row 193 design 5.4):
1. a share is in force to the caller's owner string or to `*`;
2. a user caller has a live grant, of any level, covering the thread's project and instance;
3. a thread owned by a user is still covered by that user's grant;
4. neither the caller nor the owner is the tester.
Only a human principal matches a share: the business human, a reader session as that human, or
a user. An agent session matches none, `*` included, even when it is bound to the thread.
A reader session reads as the business human. A sharee can't call `session.list` on the
thread; it stays owner-only until row 193's share wiring. The check runs on every request against the
access document the broker reads then, so a removed, expired or unreadable grant or an
unshare ends reads on the next request. Only `thread.messages` admits a sharee; `thread.open`,
`thread.send` and the share verbs refuse it `thread-not-owned`. `trail` and `replies` show a
user's thread messages to the owner and readers while the check holds. The tester can't call
the share verbs (`tester-refused`), and a viewer is refused them `access-refused`; a viewer
reads a thread shared with it. Tests: `tests/thread-shares.test.mjs`.
`threadsState(business)` is a trusted launcher API, like `bindLaunch`, not a socket verb.
Per thread it returns `{thread,instance,pending,unread,lastMessage,lastOpen,lastClaim,holder}`:
@@ -766,10 +793,10 @@ scope probes and rotation/expiry notifications; S2 never probes real services.
## Schema and verification
`schema.sql` is v3l, SHA-256
`cd68512212ce8bf8f5631138b2e63a340fa7b940088430649bb7784af7df162a`.
It has 22 tables, 87 triggers and five views. Source digest and a trusted full
sqlite_master digest are checked at open, alongside schema version `3l`, WAL,
`schema.sql` is v3m, SHA-256
`d5ca9bb9268994b175ac79d38939be4bb7f2e80bc0d9ea273eedaae50c29b8a9`.
It has 23 tables, 90 triggers and five views. Source digest and a trusted full
sqlite_master digest are checked at open, alongside schema version `3m`, WAL,
quick_check and foreign-key integrity. Missing metadata refuses; it never
reinitializes an existing file. Full synchronous WAL transactions either commit
or roll back. SQL UPDATE, DELETE and INSERT OR REPLACE refuse on evidence tables.
+15
View File
@@ -478,3 +478,18 @@ CREATE TABLE gitea_grants (
PRIMARY KEY (business, agent),
FOREIGN KEY (business, agent) REFERENCES gitea_users(business, agent)
) STRICT;
-- Row 206 (193b2, #1704, row 193 design 5.2): a thread owner's read-only shares. The latest row per
-- (thread, target) is the share's state. Append-only.
CREATE TABLE thread_shares (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
op TEXT NOT NULL CHECK (op IN ('share','unshare')),
target TEXT NOT NULL,
by TEXT NOT NULL
) STRICT;
CREATE INDEX thread_shares_thread ON thread_shares (thread, target, seq);
CREATE TRIGGER thread_shares_no_update BEFORE UPDATE ON thread_shares BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
CREATE TRIGGER thread_shares_no_delete BEFORE DELETE ON thread_shares BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
CREATE TRIGGER thread_shares_no_replace BEFORE INSERT ON thread_shares WHEN EXISTS (SELECT 1 FROM thread_shares WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
+130 -26
View File
@@ -131,7 +131,10 @@ export const TESTER_VERBS = Object.freeze(['message.send', 'message.receive', 'm
// FED R3 (#414, federation design 3.5): the collaborator level is this exact list. Every other verb
// refuses a user access-refused before its own checks: owner verbs, board reads, message.*,
// decision.*, project files, spec.read, assistant.stop, launch.*, name.set and usage.*.
export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'thread.list', 'thread.open', 'thread.send', 'thread.messages', 'names']);
// Row 206 (193b2, row 193 design 4.3): and the share verbs on a user's own threads, which need a
// collaborator grant, so a viewer gets access-refused on each.
export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'thread.list', 'thread.open', 'thread.send', 'thread.messages', 'names',
'thread.share', 'thread.unshare', 'thread.shares']);
// Row 275 (193b1b, #1780, row 193 design 4.3 and 9): the user verbs a viewer grant reaches. They
// read, so a live grant at any level covers them. thread.create, thread.open and thread.send need a
// collaborator grant on the project and instance, so a viewer gets access-refused on each.
@@ -473,6 +476,7 @@ export class Broker {
'session_defaults',
'thread_claims',
'thread_opens',
'thread_shares',
]) {
const at = store.get(`SELECT max(at) at FROM ${table}`).at;
if (at) {
@@ -518,7 +522,7 @@ export class Broker {
if (s.human || s.reader || s.tester) return;
if (!['inbox', 'agents', 'tasks', 'trail', 'decisions', 'replies', 'messages', 'state', 'usage',
'requirements', 'spec.read', 'project.list', 'project.files', 'project.file', 'library.file', 'library.status',
'thread.list', 'thread.messages', 'names', 'instances', 'routes', 'hosts.status', 'grants.status',
'thread.list', 'thread.messages', 'thread.shares', 'names', 'instances', 'routes', 'hosts.status', 'grants.status',
'processing.status', 'role.release', 'usage.end'].includes(verb)) this.#processingWork();
}
reconcileProcessing() { this.#processingMirror(this.#processingState()); }
@@ -760,12 +764,22 @@ export class Broker {
return thread;
}
#thread(s, id) {
const t = this.#store.get('SELECT * FROM threads WHERE id=? AND business=?', identifier(id), s.business);
if (!t) fail('thread-not-found');
const t = this.#threadRow(s, id);
// REQ-USER-2: a user reaches only their own threads.
if (t.owner !== this.#owner(s)) fail('thread-not-owned');
return t;
}
#threadRow(s, id) {
return this.#store.get('SELECT * FROM threads WHERE id=? AND business=?', identifier(id), s.business) ?? fail('thread-not-found');
}
// Row 206 (193b2, design 5.4): the thread a read verb names, the caller's own or one #readable
// admits. Only thread.messages takes it; every write verb keeps #thread's owner check.
#threadRead(s, id) {
const t = this.#threadRow(s, id);
if (t.owner === this.#owner(s)) return this.#grantedThread(s, 'thread.messages', t);
if (!this.#readable(s, t)) fail('thread-not-owned');
return t;
}
// Row 177 (#1675): who owns the threads a session makes. A tester's are `tester`'s, never the human's.
#owner(s) {
return s.tester ? 'tester' : s.user ? 'user:' + s.user : s.human;
@@ -811,10 +825,62 @@ export class Broker {
);
return Boolean(t) && t.owner.startsWith('user:') && t.owner !== this.#owner(s) && !this.#readable(s, t);
}
// Row 193 design 5.4. Rule 1 needs a share in force, and there is no thread_shares table before
// 193b2 (schema 3j), so nobody reads another's thread yet. 193b2 adds the share rule and rules 2-4.
// Row 206 (193b2, design 5.4): may a session read thread `t` it doesn't own? Rule 4 first: a
// tester's thread is never shared, and a tester is never a share's target and holds no grant. A
// reader session is the owner's board view, so it reads what is shared with the owner. Only a
// human principal (the business human, a reader as that human, a user) matches a share: an agent
// session, even one bound to the thread, matches none, `*` included (sage-2818 Q2).
#readable(s, t) {
return false;
if (s.tester || t.owner === 'tester') return false;
if (!s.human && !s.reader && !s.user) return false;
const me = s.reader ? this.#business(s.business).human : this.#owner(s);
// Rule 1: a share in force to the caller or to everyone.
if (!this.#shared(t.id, me) && !this.#shared(t.id, '*')) return false;
// Rule 2: a share never widens a grant. The owner needs none.
if (s.user && !this.#covered(s.user, s.business, t)) return false;
// Rule 3: a thread whose user owner has lost coverage is hidden from its sharees too.
if (t.owner.startsWith('user:') && !this.#covered(t.owner.slice(5), s.business, t)) return false;
return true;
}
// A share's state is its latest row for the thread and target (design 5.2, Duckworth N12).
#shared(thread, target) {
return this.#store.get('SELECT op FROM thread_shares WHERE thread=? AND target=? ORDER BY seq DESC LIMIT 1', thread, target)?.op === 'share';
}
// Does a live grant of any level cover thread `t` for `user`? One read of the access file, as
// #userGrants does; any failure to read covers nothing (design 5.6, H3).
#covered(user, business, t) {
let doc = null;
try {
doc = this.#access?.() ?? null;
} catch {
doc = null;
}
const now = new Date();
return (doc?.grants ?? []).some((g) => g.user === user &&
covers(g, { business, project: t.project, instance: t.instance }, now, { level: 'viewer' }));
}
// The threads another principal owns that the session may read, oldest first.
#readableThreads(s, project) {
const me = s.reader ? this.#business(s.business).human : this.#owner(s);
return this.#store.all(
"SELECT t.* FROM threads t WHERE t.business=? AND t.owner IS NOT ? AND (? IS NULL OR t.project=?) AND EXISTS(SELECT 1 FROM thread_shares x WHERE x.thread=t.id AND x.target IN (?,'*')) ORDER BY t.seq",
s.business,
this.#owner(s) || null,
project ?? null,
project ?? null,
me || null,
).filter((t) => this.#readable(s, t));
}
// Row 206 (design 5.3): a share's target, an owner string: `user:<id>`, the business human's
// name or `*`. A share with yourself is invalid.
#shareTarget(s, to) {
const b = this.#business(s.business);
if (typeof to !== 'string') fail('invalid-request');
const user = to.startsWith('user:') ? to.slice(5) : null;
if (!(to === '*' || to === b.human || (user !== null && ID_PATTERN.test(user) && !['human', 'tester', 'user', b.human].includes(user))))
fail('invalid-request');
if (to === this.#owner(s)) fail('invalid-request');
return to;
}
#agent(s) {
if (s.tester) fail('tester-refused');
@@ -846,7 +912,7 @@ export class Broker {
return cap;
}
// Row 121 (#1615): what the launcher needs to start, stop and recover thread sessions.
// Trusted launcher API, not a socket verb. Per thread: messages to the instance that no
// Trusted launcher API, not a socket verb. Per session of each thread (row 269, 264b): messages to the instance that no
// run has been delivered yet, messages delivered to the current holder and not yet read
// (a turn in progress), and the latest message, open and claim times. A message delivered
// to an earlier run and never read (it crashed mid-turn) isn't the holder's turn, so it
@@ -866,30 +932,31 @@ export class Broker {
const session = this.#runs.get(key) ?? fail('unknown-run');
if (this.#ended.has(key)) fail('run-ended');
// A run holds at most one session, so this also refuses another thread and a non-thread run.
// Row 264a: the row is the thread's default session's, so a run of another session is refused too.
const [t] = this.#threadRows('t.business=? AND t.id=?', record.business, identifier(record.thread));
// Row 269 (264b): the row is the session the run was bound to, so a run is idled against its own.
const [t] = this.#threadRows('t.business=? AND t.id=? AND se.id=?', record.business, identifier(record.thread), session.session);
if (t?.holder !== record.run) fail('not-holder');
if (t.pending || t.unread) return { released: false, pending: t.pending, unread: t.unread };
this.#store.transaction(() => this.#claimEnd(session, this.#own(session), 'release', null));
return { released: true };
}
// Row 264a (#1769): each row is the thread's default session's. Until 264b the launcher starts
// default sessions only, so another session's messages wait.
// Row 269 (264b, #1774): one row per session of each thread, oldest thread first, then oldest
// session. `legacy` is a session the 3k-3l migration made (op legacy-3l), whose store and
// in-sandbox workspace path are the thread's old ones.
#threadRows(where, ...params) {
return this.#store
.all(
`SELECT t.id, t.instance,
(SELECT count(*) FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=ds.session AND m.to_role=t.instance
`SELECT t.id, t.instance, se.id AS session, se.op AS session_op,
(SELECT count(*) FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=se.id AND m.to_role=t.instance
AND NOT EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op IN ('delivered','read'))) AS pending,
(SELECT count(*) FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=ds.session AND m.to_role=t.instance
(SELECT count(*) FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=se.id AND m.to_role=t.instance
AND c.op='claim' AND EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op='delivered' AND d.holder_run=c.holder_run)
AND NOT EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op='read')) AS unread,
(SELECT max(m.at) FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=ds.session) AS last_message,
(SELECT max(at) FROM thread_opens WHERE session=ds.session) AS last_open,
(SELECT max(m.at) FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=se.id) AS last_message,
(SELECT max(at) FROM thread_opens WHERE session=se.id) AS last_open,
c.at AS claim_at, c.op AS claim_op, c.holder_run AS holder_run
FROM threads t JOIN session_defaults ds ON ds.seq=(SELECT max(seq) FROM session_defaults WHERE thread=t.id)
LEFT JOIN thread_claims c ON c.seq=(SELECT max(seq) FROM thread_claims WHERE session=ds.session)
WHERE ${where} ORDER BY t.seq`,
FROM threads t JOIN sessions se ON se.thread=t.id
LEFT JOIN thread_claims c ON c.seq=(SELECT max(seq) FROM thread_claims WHERE session=se.id)
WHERE ${where} ORDER BY t.seq, se.seq`,
...params,
)
.map((r) => ({
@@ -901,6 +968,8 @@ export class Broker {
lastOpen: r.last_open,
lastClaim: r.claim_at,
holder: r.claim_op === 'claim' ? r.holder_run : null,
session: r.session,
legacy: r.session_op === 'legacy-3l',
}));
}
// Trusted launcher API, deliberately NOT a socket verb. S6 supplies immutable launch records.
@@ -5759,8 +5828,13 @@ export class Broker {
// and the tester's lists only its own (row 175 listed it the business human's).
case 'thread.list': {
if (!s.tester && !s.user) this.#human(s);
keys(a, ['project']);
keys(a, ['project', 'shared']);
if (a.project !== undefined) identifier(a.project);
if (a.shared !== undefined && a.shared !== true) fail('invalid-request');
// Row 206 (193b2, design 4.3): another's threads shared with the caller, each naming its
// owner. The plain list keeps its shape.
if (a.shared)
return this.#readableThreads(s, a.project).map((t) => ({ id: t.id, at: t.at, project: t.project, instance: t.instance, name: t.name, owner: t.owner }));
// FED R3 (design 3.5): a user's own threads that its grants still cover.
return this.#store.all(
'SELECT id,at,project,instance,name FROM threads WHERE business=? AND owner=?' +
@@ -5811,11 +5885,12 @@ export class Broker {
// A user's message, like a tester's, is never the owner's authority evidence (design 3.5).
return { id: mid, request: s.tester || s.user ? null : this.#event(s, 'human.input', { kind: 'instruction', message: mid }, mid) };
}
// The thread's history, oldest first. Reading writes nothing.
// The thread's history, oldest first. Reading writes nothing. Row 206 (193b2): a thread shared
// with the caller reads too, every session of it, since a share is the whole thread's.
case 'thread.messages': {
if (!s.tester && !s.user) this.#human(s);
keys(a, ['thread', 'session', 'after', 'limit'], ['thread']);
const t = this.#grantedThread(s, 'thread.messages', this.#thread(s, a.thread));
const t = this.#threadRead(s, a.thread);
const session = this.#threadSession(t, a.session);
const limit = a.limit ?? THREAD_PAGE;
if (!Number.isInteger(limit) || limit < 1 || limit > THREAD_PAGE_MAX) fail('invalid-request');
@@ -5834,6 +5909,34 @@ export class Broker {
after,
);
}
// Row 206 (193b2, #1704, design 5.3): the owner of a thread shares it read-only, or ends a share.
// A user's collaborator grant must still cover the thread, and a user target needs a live grant
// of any level that covers it. Each writes one row; the latest row per target is the state.
case 'thread.share':
case 'thread.unshare': {
if (!s.user) this.#human(s);
keys(a, ['thread', 'to'], ['thread', 'to']);
const t = this.#grantedThread(s, verb, this.#thread(s, a.thread));
const to = this.#shareTarget(s, a.to);
const op = verb === 'thread.share' ? 'share' : 'unshare';
if (op === 'unshare' && !this.#shared(t.id, to)) fail('share-not-found');
if (op === 'share') {
if (to.startsWith('user:') && !this.#covered(to.slice(5), s.business, t)) fail('share-not-covered');
if (this.#shared(t.id, to)) fail('share-exists');
}
this.#store.run('INSERT INTO thread_shares(at,business,thread,op,target,by) VALUES(?,?,?,?,?,?)', this.#now(), s.business, t.id, op, to, this.#owner(s));
return { thread: t.id, to, op };
}
// The shares in force on the caller's own thread, oldest first.
case 'thread.shares': {
if (!s.user) this.#human(s);
keys(a, ['thread'], ['thread']);
const t = this.#grantedThread(s, 'thread.shares', this.#thread(s, a.thread));
return this.#store.all(
"SELECT target AS \"to\",at,by FROM thread_shares x WHERE thread=? AND op='share' AND seq=(SELECT max(seq) FROM thread_shares WHERE thread=x.thread AND target=x.target) ORDER BY seq",
t.id,
);
}
// Row 264a (#1769, T1 item 7): a thread's sessions, oldest first, and which one is the default.
// Each user reaches only their own threads' sessions (REQ-USER-2); there are no shares (Q7).
case 'session.list': {
@@ -5869,14 +5972,15 @@ export class Broker {
if (!this.#store.get('SELECT id FROM messages WHERE id=? AND business=?', a.id, s.business))
fail('message-not-found');
// Row 205 (design 5.7): a hidden id fails as an unknown one does. Hidden replies are left out
// in the query, so a page of them doesn't come back short; #readable is false until 193b2,
// which adds the share exception to this clause too.
// in the query, so a page of them doesn't come back short. Row 206 (193b2): the threads
// #readable admits are the share exception.
if (this.#hiddenMessage(s, a.id)) fail('message-not-found');
return this.#store.all(
`SELECT * FROM messages m WHERE business=? AND in_reply_to=? AND NOT EXISTS(SELECT 1 FROM message_threads mt JOIN threads t ON t.id=mt.thread WHERE mt.message=m.id AND substr(t.owner,1,5)='user:' AND t.owner IS NOT ?) ORDER BY seq LIMIT ${REPLIES}`,
`SELECT * FROM messages m WHERE business=? AND in_reply_to=? AND NOT EXISTS(SELECT 1 FROM message_threads mt JOIN threads t ON t.id=mt.thread WHERE mt.message=m.id AND substr(t.owner,1,5)='user:' AND t.owner IS NOT ? AND t.id NOT IN (SELECT value FROM json_each(?))) ORDER BY seq LIMIT ${REPLIES}`,
s.business,
a.id,
this.#owner(s) || null,
JSON.stringify(this.#readableThreads(s).map((t) => t.id)),
);
}
default:
+15
View File
@@ -0,0 +1,15 @@
-- Row 206 (193b2, #1704, row 193 design 5.2): thread shares. Only a new table, its index and its
-- append-only triggers; no existing row changes, so every thread stays private to its owner.
CREATE TABLE thread_shares (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
op TEXT NOT NULL CHECK (op IN ('share','unshare')),
target TEXT NOT NULL,
by TEXT NOT NULL
) STRICT;
CREATE INDEX thread_shares_thread ON thread_shares (thread, target, seq);
CREATE TRIGGER thread_shares_no_update BEFORE UPDATE ON thread_shares BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
CREATE TRIGGER thread_shares_no_delete BEFORE DELETE ON thread_shares BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
CREATE TRIGGER thread_shares_no_replace BEFORE INSERT ON thread_shares WHEN EXISTS (SELECT 1 FROM thread_shares WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
+8 -2
View File
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
import { createHash } from 'node:crypto';
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
export const SCHEMA_SHA256 = 'cd68512212ce8bf8f5631138b2e63a340fa7b940088430649bb7784af7df162a';
export const SCHEMA_VERSION = '3l';
export const SCHEMA_SHA256 = 'd5ca9bb9268994b175ac79d38939be4bb7f2e80bc0d9ea273eedaae50c29b8a9';
export const SCHEMA_VERSION = '3m';
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned.
const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
@@ -72,6 +72,11 @@ const migrations = [{
from: '3k',
to: '3l',
sql: readFileSync(new URL('./migrations/3k-3l.sql', import.meta.url), 'utf8'),
}, {
// Row 206 (193b2, #1704): thread shares. Only a new table; no existing row changes.
from: '3l',
to: '3m',
sql: readFileSync(new URL('./migrations/3l-3m.sql', import.meta.url), 'utf8'),
}];
const timedTables = [
'events',
@@ -87,6 +92,7 @@ const timedTables = [
'session_defaults',
'thread_claims',
'thread_opens',
'thread_shares',
'instance_names',
'usage_records',
];
@@ -17,7 +17,8 @@ const ddl3h = readFileSync(new URL('./fixtures/schema-3h.sql', import.meta.url),
const here = (p) => new URL(p, import.meta.url);
// Row 264a (#1769): what the 3k→3l step rebuilds or adds. sessions.test.mjs tests those objects.
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
const later3l = (r) => SESSIONS_3L.includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function source3h(t) {
const root = mkdtempSync(join(tmpdir(), 'account-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
@@ -84,6 +85,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/3i-3j.sql'), join(moduleRoot, 'src/migrations/3i-3j.sql'));
copyFileSync(here('../src/migrations/3j-3k.sql'), join(moduleRoot, 'src/migrations/3j-3k.sql'));
copyFileSync(here('../src/migrations/3k-3l.sql'), join(moduleRoot, 'src/migrations/3k-3l.sql'));
copyFileSync(here('../src/migrations/3l-3m.sql'), join(moduleRoot, 'src/migrations/3l-3m.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
@@ -91,7 +93,7 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence
const { root, path } = source3h(t), before = snapshot(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3l');
assert.equal(version(path), '3m');
// Every table's rows as they were, usage_records with its seq values and sqlite_sequence with 50.
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
assert.deepEqual(before.rows.usage_records.map(r => r.seq), [1, 2, 3]);
@@ -141,7 +143,7 @@ test('REQ-LIMIT-4 a 3h→3i step that fails partway leaves the store at 3h with
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
// Still a whole 3h store: the real step migrates it.
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3l');
assert.equal(version(path), '3m');
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
});
@@ -153,14 +155,14 @@ test('REQ-LIMIT-4 the 3h engine refuses a migrated store, migrating or not, and
schema: ddl3h,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3h).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3l';", "export const SCHEMA_VERSION = '3h';")
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3h';")
.replace(/\}, \{\n {2}\/\/ Row 126 \(#1620\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3h/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3h';") && !pinned.includes('3h-3i'), 'the 3h engine has no 3h→3i step');
assert.throws(() => new Store3h(root), /schema-mismatch/);
assert.throws(() => new Store3h(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3l');
assert.equal(version(path), '3m');
assert.deepEqual(master(path), was);
assert.deepEqual(snapshot(path, after.columns).rows, after.rows);
});
+2 -2
View File
@@ -614,7 +614,7 @@ async function database3c(t) {
let source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3l';", "export const SCHEMA_VERSION = '3c';")
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3c';")
.replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.notEqual(pinned, source);
assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step');
@@ -638,7 +638,7 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get("SELECT holder_run FROM role_claims WHERE role='coder'").holder_run, 'coder-run');
const b = new Broker({ store, businesses: demo(), organization: null });
@@ -33,7 +33,7 @@ test('explicit 3b copy migration preserves evidence and opens as 3g; normal star
const original = readFileSync(source);
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value;
@@ -75,15 +75,15 @@ test('named migration registry chains 3b through 3g to a fixture-only future ver
const sha = createHash('sha256').update(futureDDL).digest('hex');
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8');
source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`)
.replace("export const SCHEMA_VERSION = '3l';", "export const SCHEMA_VERSION = '3m';")
.replace('}];\nconst timedTables', `}, { from: '3l', to: '3m', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3n';")
.replace('}];\nconst timedTables', `}, { from: '3m', to: '3n', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL);
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql'])
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql'])
copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name));
const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
const future = new FutureStore(root, { migrateSchema: true });
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3n');
assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture');
assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0);
assert.equal(future.get("SELECT id FROM events").id, 'preserved');
@@ -109,7 +109,7 @@ test('offline command migrates only the supplied fixture root and reports usage
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr);
assert.match(migrated.stdout, /bus schema 3l ready/);
assert.match(migrated.stdout, /bus schema 3m ready/);
new Store(root).close();
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
assert.equal(usage.status, 2);
+1 -1
View File
@@ -191,7 +191,7 @@ test('broker process: launch ops authorize role.launch, record refusals and end
assert.deepEqual((await ask(child, { op: 'credentialStatus', business: 'demo', role: 'pm' })).result, []);
// The launcher's thread view, through the same child.
assert.deepEqual((await ask(child, { op: 'threadsState', business: 'demo' })).result, [
{ thread: 'th-1', instance: 'coder', pending: 0, unread: 0, lastMessage: null, lastOpen: '2026-10-15T00:00:01.000Z', lastClaim: null, holder: null },
{ thread: 'th-1', instance: 'coder', pending: 0, unread: 0, lastMessage: null, lastOpen: '2026-10-15T00:00:01.000Z', lastClaim: null, holder: null, session: 'th-1', legacy: true },
]);
// And its idle release (Dewey's F3, 28591): the op reaches the broker, which refuses a run
// that doesn't hold the thread.
+480
View File
@@ -0,0 +1,480 @@
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
CREATE TABLE events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN (
'session.launched',
'session.ended',
'action.allowed',
'action.refused',
'task.created',
'task.assigned',
'task.state',
'task.closed',
'task.changed.external',
'task.conflict',
'task.missing',
'review.requested',
'review.verdict',
'human.input',
'config.refused',
'credential.expiring',
'credential.expired',
'credential.changed',
'launch.revoked',
'launch.restored',
'digest.sent')),
actor_role TEXT, actor_run TEXT,
subject TEXT,
corrects TEXT REFERENCES events(id),
body TEXT NOT NULL CHECK (json_valid(body))
) STRICT;
CREATE TABLE role_claims (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL, role TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
holder_run TEXT NOT NULL,
harness TEXT NOT NULL, address TEXT,
by TEXT NOT NULL, reason TEXT,
decision TEXT
) STRICT;
CREATE TABLE decisions (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL, project TEXT,
raised_by_role TEXT NOT NULL, raised_by_run TEXT NOT NULL,
class TEXT NOT NULL CHECK (class IN ('routine','within-role','cross-role','gated')),
action TEXT NOT NULL,
route_to TEXT NOT NULL,
question TEXT NOT NULL,
options TEXT NOT NULL CHECK (json_valid(options) AND json_array_length(options) BETWEEN 2 AND 9),
recommendation TEXT NOT NULL,
task_ref TEXT CHECK (task_ref IS NULL OR (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*')), requirement_ref TEXT,
blocking INTEGER NOT NULL CHECK (blocking IN (0,1)),
supersedes TEXT REFERENCES decisions(id)
, spec_path TEXT, spec_commit TEXT) STRICT;
CREATE TABLE decision_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
decision TEXT NOT NULL REFERENCES decisions(id),
at TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('seen','resolved','withdrawn','expired')),
by TEXT NOT NULL,
choice TEXT, note TEXT, via TEXT
) STRICT;
CREATE TABLE messages (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
from_role TEXT NOT NULL, from_run TEXT NOT NULL,
to_role TEXT NOT NULL,
class TEXT NOT NULL,
in_reply_to TEXT REFERENCES messages(id),
decision TEXT REFERENCES decisions(id),
corrects TEXT REFERENCES messages(id),
body TEXT NOT NULL
, to_name TEXT) STRICT;
CREATE TABLE deliveries (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
message TEXT NOT NULL REFERENCES messages(id),
at TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('routed','delivered','failed','read')),
holder_run TEXT, transport TEXT, address TEXT, detail TEXT
) STRICT;
CREATE TABLE task_snapshots (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
task_ref TEXT NOT NULL CHECK (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*'),
updated TEXT NOT NULL,
etag TEXT,
digest TEXT NOT NULL CHECK (length(digest) = 64 AND NOT digest GLOB '*[^0-9a-f]*'),
fields TEXT NOT NULL CHECK (json_valid(fields)),
source TEXT NOT NULL CHECK (source IN ('self','poll')),
via TEXT CHECK (via IN ('board','cursor','task','reconcile')),
read_at TEXT,
role TEXT, run TEXT,
CHECK ((source = 'self') = (role IS NOT NULL AND run IS NOT NULL)),
CHECK ((source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)),
CHECK (json_type(fields, '$.bucket') IS 'integer'
OR (source IS 'poll' AND via IS 'task' AND json_type(fields, '$.gone') IS 'text'))
) STRICT;
CREATE INDEX task_snapshots_ref ON task_snapshots (business, task_ref, seq);
CREATE TRIGGER decisions_resolve_once BEFORE INSERT ON decision_events
WHEN NEW.op IN ('resolved','withdrawn','expired') AND EXISTS (
SELECT 1 FROM decision_events WHERE decision = NEW.decision AND op IN ('resolved','withdrawn','expired'))
BEGIN SELECT RAISE(ABORT, 'decision already closed'); END;
CREATE TRIGGER decisions_resolved_choice BEFORE INSERT ON decision_events
WHEN NEW.op = 'resolved' AND (NEW.choice IS NULL OR NOT EXISTS (
SELECT 1 FROM decisions d, json_each(d.options) o WHERE d.id = NEW.decision AND json_extract(o.value,'$.key') = NEW.choice))
BEGIN SELECT RAISE(ABORT, 'resolution must name one of the options'); END;
CREATE TRIGGER role_one_holder BEFORE INSERT ON role_claims
WHEN NEW.op = 'claim' AND (SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) = 'claim'
BEGIN SELECT RAISE(ABORT, 'role already held'); END;
CREATE TRIGGER role_release_by_holder BEFORE INSERT ON role_claims
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1),'') <> 'claim'
BEGIN SELECT RAISE(ABORT, 'role is not held'); END;
CREATE TRIGGER role_release_same_run BEFORE INSERT ON role_claims
WHEN NEW.op = 'release' AND (SELECT holder_run FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
CREATE TRIGGER role_revoke_needs_decision BEFORE INSERT ON role_claims
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
CREATE TRIGGER meta_no_update BEFORE UPDATE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER meta_no_delete BEFORE DELETE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER events_no_update BEFORE UPDATE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER events_no_delete BEFORE DELETE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER role_claims_no_update BEFORE UPDATE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER role_claims_no_delete BEFORE DELETE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER decisions_no_update BEFORE UPDATE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decisions_no_delete BEFORE DELETE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decision_events_no_update BEFORE UPDATE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER decision_events_no_delete BEFORE DELETE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER messages_no_update BEFORE UPDATE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER messages_no_delete BEFORE DELETE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER deliveries_no_update BEFORE UPDATE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER deliveries_no_delete BEFORE DELETE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER meta_no_replace BEFORE INSERT ON meta WHEN EXISTS (SELECT 1 FROM meta WHERE key = NEW.key) BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER events_no_replace BEFORE INSERT ON events WHEN EXISTS (SELECT 1 FROM events WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER role_claims_no_replace BEFORE INSERT ON role_claims WHEN EXISTS (SELECT 1 FROM role_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER decisions_no_replace BEFORE INSERT ON decisions WHEN EXISTS (SELECT 1 FROM decisions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decision_events_no_replace BEFORE INSERT ON decision_events WHEN EXISTS (SELECT 1 FROM decision_events WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER messages_no_replace BEFORE INSERT ON messages WHEN EXISTS (SELECT 1 FROM messages WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER deliveries_no_replace BEFORE INSERT ON deliveries WHEN EXISTS (SELECT 1 FROM deliveries WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER task_snapshots_no_update BEFORE UPDATE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER task_snapshots_no_delete BEFORE DELETE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER task_snapshots_no_replace BEFORE INSERT ON task_snapshots WHEN EXISTS (SELECT 1 FROM task_snapshots WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER decisions_blocking_needs_task BEFORE INSERT ON decisions
WHEN NEW.blocking = 1 AND NEW.task_ref IS NULL
BEGIN SELECT RAISE(ABORT, 'a blocking decision cites the task it blocks'); END;
CREATE TRIGGER events_launch_by_human BEFORE INSERT ON events
WHEN NEW.kind IN ('launch.revoked','launch.restored') AND (NEW.actor_role IS NOT NULL OR NEW.actor_run IS NOT NULL)
BEGIN SELECT RAISE(ABORT, 'only the human revokes or restores launching'); END;
CREATE TRIGGER events_credential_body BEFORE INSERT ON events
WHEN NEW.kind GLOB 'credential.*' AND (
json_extract(NEW.body, '$.service') IS NULL OR json_extract(NEW.body, '$.service') NOT IN ('gitea','vikunja')
OR json_extract(NEW.body, '$.instance') IS NULL)
BEGIN SELECT RAISE(ABORT, 'credential events name a service and a role instance'); END;
CREATE TRIGGER events_task_missing_body BEFORE INSERT ON events
WHEN NEW.kind = 'task.missing' AND (
json_extract(NEW.body, '$.reason') IS NULL OR json_extract(NEW.body, '$.reason') NOT IN ('moved','not-found','no-access')
OR (json_extract(NEW.body, '$.reason') = 'moved' AND json_type(NEW.body, '$.project') IS NOT 'integer'))
BEGIN SELECT RAISE(ABORT, 'task.missing carries a reason, and the new project when moved'); END;
CREATE TRIGGER events_task_subject BEFORE INSERT ON events
WHEN NEW.kind GLOB 'task.*' AND (NEW.subject IS NULL OR NOT (NEW.subject GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(NEW.subject, 9) GLOB '*[^0-9/]*' AND NOT substr(NEW.subject, 9) GLOB '*/*/*'))
BEGIN SELECT RAISE(ABORT, 'a task event names its task in subject'); END;
CREATE TRIGGER events_task_created_body BEFORE INSERT ON events
WHEN NEW.kind = 'task.created' AND (
json_type(NEW.body, '$.request') IS NOT 'text'
OR NOT EXISTS (SELECT 1 FROM events WHERE id = json_extract(NEW.body, '$.request')
AND kind = 'human.input' AND business = NEW.business)
OR json_type(NEW.body, '$.requirement') IS NOT 'text'
OR NOT json_extract(NEW.body, '$.requirement') GLOB 'REQ-[A-Z]*-[1-9]*'
OR json_extract(NEW.body, '$.requirement') GLOB 'REQ-*[^A-Z0-9-]*')
BEGIN SELECT RAISE(ABORT, 'task.created cites the human.input that asked for it and a requirement id'); END;
CREATE VIEW launch_state AS
SELECT business, CASE kind WHEN 'launch.revoked' THEN 'revoked' ELSE 'allowed' END AS state, at
FROM events e WHERE kind IN ('launch.revoked','launch.restored')
AND seq = (SELECT max(seq) FROM events WHERE business = e.business AND kind IN ('launch.revoked','launch.restored'));
CREATE VIEW task_external_changes AS
SELECT p.business, p.task_ref, p.seq, p.via, p.updated, p.digest, ls.digest AS self_digest
FROM task_snapshots p
LEFT JOIN task_snapshots ls ON ls.seq = (SELECT max(seq) FROM task_snapshots
WHERE business = p.business AND task_ref = p.task_ref AND source = 'self')
WHERE p.source = 'poll'
AND p.seq = (SELECT max(seq) FROM task_snapshots WHERE business = p.business AND task_ref = p.task_ref)
AND (ls.seq IS NULL OR (p.updated >= ls.updated AND p.read_at > ls.at AND p.digest <> ls.digest));
CREATE VIEW task_current AS
SELECT s.seq, s.at, s.business, s.task_ref, s.updated, s.etag, s.digest, s.fields, s.source, s.via, s.read_at, s.role, s.run
FROM task_snapshots s
WHERE s.seq = (SELECT max(c.seq) FROM task_snapshots c
WHERE c.business = s.business AND c.task_ref = s.task_ref
AND (c.source = 'self'
OR c.read_at > coalesce((SELECT ls.at FROM task_snapshots ls
WHERE ls.business = c.business AND ls.task_ref = c.task_ref AND ls.source = 'self'
ORDER BY ls.seq DESC LIMIT 1), '')));
CREATE VIEW tasks_open AS
SELECT s.business, s.task_ref, json_extract(s.fields, '$.bucket') AS bucket, s.seq
FROM task_current s
WHERE json_type(s.fields, '$.gone') IS NULL
AND json_extract(s.fields, '$.done') = 0;
CREATE VIEW urgent_inbox AS
SELECT d.id, d.business, d.task_ref, d.question, d.at
FROM decisions d
WHERE d.class = 'gated' AND d.blocking = 1
AND NOT EXISTS (SELECT 1 FROM decision_events x WHERE x.decision = d.id AND x.op IN ('resolved','withdrawn','expired'));
CREATE TABLE task_comment_cursors (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
business TEXT NOT NULL,
task_id INTEGER NOT NULL CHECK (task_id > 0),
last_comment INTEGER NOT NULL CHECK (last_comment > 0)
) STRICT;
CREATE INDEX task_comment_cursors_latest ON task_comment_cursors (business, task_id, seq);
CREATE TRIGGER task_comment_cursors_no_update BEFORE UPDATE ON task_comment_cursors
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_no_delete BEFORE DELETE ON task_comment_cursors
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_no_replace BEFORE INSERT ON task_comment_cursors
WHEN EXISTS (SELECT 1 FROM task_comment_cursors WHERE seq = NEW.seq)
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_forward BEFORE INSERT ON task_comment_cursors
WHEN NEW.last_comment <= COALESCE((SELECT last_comment FROM task_comment_cursors
WHERE business = NEW.business AND task_id = NEW.task_id ORDER BY seq DESC LIMIT 1), 0)
BEGIN SELECT RAISE(ABORT, 'comment cursor must advance'); END;
CREATE TABLE projects (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
name TEXT NOT NULL,
repo TEXT,
created_by TEXT NOT NULL,
UNIQUE (business, name)
) STRICT;
CREATE TABLE threads (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
project TEXT NOT NULL REFERENCES projects(id),
instance TEXT NOT NULL,
owner TEXT NOT NULL,
name TEXT NOT NULL,
UNIQUE (project, name)
) STRICT;
CREATE TABLE sessions (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
instance TEXT NOT NULL,
owner TEXT NOT NULL,
name TEXT NOT NULL,
op TEXT NOT NULL,
predecessor TEXT REFERENCES sessions(id),
UNIQUE (thread, name),
UNIQUE (thread, op)
) STRICT;
CREATE TABLE session_defaults (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id),
by TEXT NOT NULL
) STRICT;
CREATE TABLE thread_claims (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id),
generation INTEGER NOT NULL CHECK (generation >= 1),
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
holder_run TEXT NOT NULL,
harness TEXT NOT NULL, address TEXT,
by TEXT NOT NULL, reason TEXT,
decision TEXT
) STRICT;
CREATE TABLE message_threads (
message TEXT PRIMARY KEY REFERENCES messages(id),
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id)
) STRICT;
CREATE TABLE thread_opens (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id),
by TEXT NOT NULL
) STRICT;
CREATE INDEX thread_claims_thread ON thread_claims (thread, seq);
CREATE INDEX thread_claims_session ON thread_claims (session, seq);
CREATE INDEX message_threads_thread ON message_threads (thread);
CREATE INDEX message_threads_session ON message_threads (session);
CREATE INDEX session_defaults_thread ON session_defaults (thread, seq);
CREATE TRIGGER threads_project_business BEFORE INSERT ON threads
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a thread belongs to its project''s business'); END;
CREATE TRIGGER sessions_thread_match BEFORE INSERT ON sessions
WHEN NOT EXISTS (SELECT 1 FROM threads t WHERE t.id = NEW.thread AND t.business = NEW.business AND t.instance = NEW.instance AND t.owner = NEW.owner)
BEGIN SELECT RAISE(ABORT, 'a session has its thread''s business, instance and owner'); END;
CREATE TRIGGER sessions_predecessor BEFORE INSERT ON sessions
WHEN NEW.predecessor IS NOT NULL AND (SELECT thread FROM sessions WHERE id = NEW.predecessor) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'a session''s predecessor is in its thread'); END;
CREATE TRIGGER sessions_id_not_thread BEFORE INSERT ON sessions
WHEN NEW.id IS NOT NEW.thread AND EXISTS (SELECT 1 FROM threads WHERE id = NEW.id)
BEGIN SELECT RAISE(ABORT, 'a session id names another thread'); END;
CREATE TRIGGER threads_id_not_session BEFORE INSERT ON threads
WHEN EXISTS (SELECT 1 FROM sessions WHERE id = NEW.id)
BEGIN SELECT RAISE(ABORT, 'a thread id names a session'); END;
CREATE TRIGGER session_defaults_match BEFORE INSERT ON session_defaults
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'a default session is in its thread'); END;
CREATE TRIGGER thread_one_holder BEFORE INSERT ON thread_claims
WHEN NEW.op = 'claim' AND (SELECT op FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) = 'claim'
BEGIN SELECT RAISE(ABORT, 'session already held'); END;
CREATE TRIGGER thread_one_per_run BEFORE INSERT ON thread_claims
WHEN NEW.op = 'claim' AND EXISTS (SELECT 1 FROM thread_claims c WHERE c.holder_run = NEW.holder_run AND c.business = NEW.business
AND c.seq = (SELECT max(seq) FROM thread_claims WHERE session = c.session) AND c.op = 'claim')
BEGIN SELECT RAISE(ABORT, 'run already holds a session'); END;
CREATE TRIGGER thread_release_by_holder BEFORE INSERT ON thread_claims
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1),'') <> 'claim'
BEGIN SELECT RAISE(ABORT, 'session is not held'); END;
CREATE TRIGGER thread_release_same_run BEFORE INSERT ON thread_claims
WHEN NEW.op = 'release' AND (SELECT holder_run FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
CREATE TRIGGER thread_claim_generation BEFORE INSERT ON thread_claims
WHEN NEW.generation IS NOT (CASE WHEN NEW.op = 'claim'
THEN 1 + (SELECT count(*) FROM thread_claims WHERE session = NEW.session AND op = 'claim')
ELSE (SELECT generation FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) END)
BEGIN SELECT RAISE(ABORT, 'a claim row names its session''s generation'); END;
CREATE TRIGGER thread_revoke_needs_decision BEFORE INSERT ON thread_claims
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
CREATE TRIGGER thread_claims_business BEFORE INSERT ON thread_claims
WHEN (SELECT business FROM threads WHERE id = NEW.thread) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a thread claim names the thread''s business'); END;
CREATE TRIGGER thread_claims_session BEFORE INSERT ON thread_claims
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'a claim''s session is in its thread'); END;
CREATE TRIGGER message_threads_match BEFORE INSERT ON message_threads
WHEN NOT EXISTS (SELECT 1 FROM messages m JOIN threads t ON t.id = NEW.thread
WHERE m.id = NEW.message AND m.business = t.business AND t.instance IN (m.to_role, m.from_role))
BEGIN SELECT RAISE(ABORT, 'a threaded message is to or from the thread''s instance'); END;
CREATE TRIGGER message_threads_session BEFORE INSERT ON message_threads
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'a threaded message''s session is in its thread'); END;
CREATE TRIGGER thread_opens_session BEFORE INSERT ON thread_opens
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'an open''s session is in its thread'); END;
CREATE TRIGGER delivery_same_session BEFORE INSERT ON deliveries
WHEN NEW.op IN ('routed','delivered') AND (NEW.op = 'delivered' OR NEW.holder_run IS NOT NULL)
AND EXISTS (SELECT 1 FROM message_threads mt JOIN messages m ON m.id = mt.message JOIN threads t ON t.id = mt.thread
WHERE mt.message = NEW.message AND m.to_role = t.instance
AND NOT EXISTS (SELECT 1 FROM thread_claims c WHERE c.seq = (SELECT max(seq) FROM thread_claims WHERE session = mt.session)
AND c.op = 'claim' AND c.holder_run IS NEW.holder_run))
BEGIN SELECT RAISE(ABORT, 'a threaded message goes only to its session''s holder'); END;
CREATE TRIGGER projects_no_update BEFORE UPDATE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER projects_no_delete BEFORE DELETE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER projects_no_replace BEFORE INSERT ON projects WHEN EXISTS (SELECT 1 FROM projects WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER threads_no_update BEFORE UPDATE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER threads_no_delete BEFORE DELETE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER threads_no_replace BEFORE INSERT ON threads WHEN EXISTS (SELECT 1 FROM threads WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER sessions_no_update BEFORE UPDATE ON sessions BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
CREATE TRIGGER sessions_no_delete BEFORE DELETE ON sessions BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
CREATE TRIGGER sessions_no_replace BEFORE INSERT ON sessions WHEN EXISTS (SELECT 1 FROM sessions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
CREATE TRIGGER session_defaults_no_update BEFORE UPDATE ON session_defaults BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
CREATE TRIGGER session_defaults_no_delete BEFORE DELETE ON session_defaults BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
CREATE TRIGGER session_defaults_no_replace BEFORE INSERT ON session_defaults WHEN EXISTS (SELECT 1 FROM session_defaults WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
CREATE TRIGGER thread_claims_no_update BEFORE UPDATE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER thread_claims_no_delete BEFORE DELETE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER thread_claims_no_replace BEFORE INSERT ON thread_claims WHEN EXISTS (SELECT 1 FROM thread_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER message_threads_no_update BEFORE UPDATE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER message_threads_no_delete BEFORE DELETE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER message_threads_no_replace BEFORE INSERT ON message_threads WHEN EXISTS (SELECT 1 FROM message_threads WHERE message = NEW.message) BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER thread_opens_no_update BEFORE UPDATE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TRIGGER thread_opens_no_delete BEFORE DELETE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TRIGGER thread_opens_no_replace BEFORE INSERT ON thread_opens WHEN EXISTS (SELECT 1 FROM thread_opens WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TABLE instance_names (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
instance TEXT NOT NULL,
name TEXT NOT NULL,
by TEXT NOT NULL
) STRICT;
CREATE TRIGGER instance_names_unique BEFORE INSERT ON instance_names
WHEN EXISTS (SELECT 1 FROM instance_names n WHERE n.business = NEW.business AND n.name = NEW.name
AND n.instance <> NEW.instance
AND n.seq = (SELECT max(seq) FROM instance_names WHERE business = n.business AND instance = n.instance))
BEGIN SELECT RAISE(ABORT, 'an instance name is unique in its business'); END;
CREATE TRIGGER instance_names_no_update BEFORE UPDATE ON instance_names BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
CREATE TRIGGER instance_names_no_delete BEFORE DELETE ON instance_names BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
CREATE TRIGGER instance_names_no_replace BEFORE INSERT ON instance_names WHEN EXISTS (SELECT 1 FROM instance_names WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
-- Row 124 (#1618): immutable source snapshots and turn outcomes, never transcript text.
CREATE TABLE usage_records (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
run TEXT NOT NULL,
instance TEXT NOT NULL,
provider TEXT,
model TEXT,
account TEXT,
turn TEXT NOT NULL,
source TEXT NOT NULL,
phase TEXT NOT NULL CHECK (phase IN ('begin','end')),
status TEXT NOT NULL CHECK (status IN ('known','unknown')),
code TEXT,
cursor TEXT CHECK (cursor IS NULL OR json_valid(cursor)),
input INTEGER,
output INTEGER,
cache_write INTEGER,
cache_read INTEGER,
UNIQUE (business,run,turn,source,phase),
CHECK ((status='unknown' AND input IS NULL AND output IS NULL AND cache_write IS NULL AND cache_read IS NULL)
OR (status='known' AND input >= 0 AND output >= 0 AND cache_write >= 0 AND cache_read >= 0
AND input IS NOT NULL AND output IS NOT NULL AND cache_write IS NOT NULL AND cache_read IS NOT NULL))
) STRICT;
CREATE TRIGGER usage_records_no_update BEFORE UPDATE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
CREATE TRIGGER usage_records_no_delete BEFORE DELETE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
CREATE TRIGGER usage_records_no_replace BEFORE INSERT ON usage_records WHEN EXISTS (SELECT 1 FROM usage_records WHERE seq=NEW.seq OR (business=NEW.business AND run=NEW.run AND turn=NEW.turn AND source=NEW.source AND phase=NEW.phase)) BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
CREATE TABLE hosts (
business TEXT NOT NULL,
host TEXT NOT NULL,
address TEXT NOT NULL,
port INTEGER NOT NULL CHECK (port BETWEEN 1 AND 65535),
user TEXT NOT NULL,
sudo INTEGER NOT NULL CHECK (sudo IN (0,1)),
host_keys TEXT NOT NULL CHECK (json_valid(host_keys) AND json_type(host_keys) = 'array' AND json_array_length(host_keys) > 0),
installer_fingerprint TEXT,
state TEXT NOT NULL CHECK (state IN ('pending-install','ready')),
decision TEXT NOT NULL REFERENCES decisions(id),
checked_at TEXT,
PRIMARY KEY (business, host),
CHECK (state = 'pending-install' OR (installer_fingerprint IS NOT NULL AND checked_at IS NOT NULL))
) STRICT;
CREATE TABLE host_grants (
business TEXT NOT NULL,
agent TEXT NOT NULL,
host TEXT NOT NULL,
commands TEXT NOT NULL CHECK (json_valid(commands) AND json_type(commands) = 'array' AND json_array_length(commands) > 0),
rotate_by TEXT NOT NULL,
tag TEXT NOT NULL CHECK (length(tag) = 39 AND tag GLOB 'mosaic-*' AND NOT substr(tag, 8) GLOB '*[^0-9a-f]*'),
decision TEXT NOT NULL REFERENCES decisions(id),
PRIMARY KEY (business, agent, host),
FOREIGN KEY (business, host) REFERENCES hosts(business, host)
) STRICT;
CREATE TRIGGER events_grant_actor BEFORE INSERT ON events
WHEN NEW.kind = 'action.allowed'
AND (json_extract(NEW.body, '$.action') GLOB 'grant.*' OR json_extract(NEW.body, '$.operation') GLOB 'grant.*')
AND NOT (NEW.actor_role IS NULL AND NEW.actor_run IS 'grant')
BEGIN SELECT RAISE(ABORT, 'only the grant writer records grant events'); END;
CREATE TABLE gitea_users (
business TEXT NOT NULL,
agent TEXT NOT NULL,
user TEXT NOT NULL,
id INTEGER NOT NULL CHECK (id > 0),
nonce TEXT NOT NULL CHECK (length(nonce) = 16 AND NOT nonce GLOB '*[^0-9a-f]*'),
decision TEXT NOT NULL REFERENCES decisions(id),
PRIMARY KEY (business, agent)
) STRICT;
CREATE TABLE gitea_grants (
business TEXT NOT NULL,
agent TEXT NOT NULL,
user TEXT NOT NULL,
token_id INTEGER NOT NULL CHECK (token_id > 0),
token_name TEXT NOT NULL,
projects TEXT NOT NULL CHECK (json_valid(projects) AND json_type(projects) = 'array' AND json_array_length(projects) > 0),
rotate_by TEXT NOT NULL,
decision TEXT NOT NULL REFERENCES decisions(id),
PRIMARY KEY (business, agent),
FOREIGN KEY (business, agent) REFERENCES gitea_users(business, agent)
) STRICT;
+8 -6
View File
@@ -34,11 +34,12 @@ function source3j(t) {
}
// Row 264a (#1769): what the 3k→3l step rebuilds or adds. sessions.test.mjs tests those objects.
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
const later3l = (r) => SESSIONS_3L.includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function rows(path) {
const db = new DatabaseSync(path, { readOnly: true });
try {
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults')").all()
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults','thread_shares')").all()
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
} finally { db.close(); }
@@ -74,6 +75,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
writeFileSync(join(moduleRoot, 'src/migrations/3j-3k.sql'), step(readFileSync(here('../src/migrations/3j-3k.sql'), 'utf8')));
copyFileSync(here('../src/migrations/3k-3l.sql'), join(moduleRoot, 'src/migrations/3k-3l.sql'));
copyFileSync(here('../src/migrations/3l-3m.sql'), join(moduleRoot, 'src/migrations/3l-3m.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
function fresh(t) {
@@ -95,7 +97,7 @@ test('3j copy migrates to 3k: every row kept, and sqlite_master gains only the t
const { root, path } = source3j(t), before = rows(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3l');
assert.equal(version(path), '3m');
assert.deepEqual(migrated3l(rows(path)), before);
const now = master(path);
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
@@ -121,7 +123,7 @@ test('a 3j→3k step that fails partway leaves the store at 3j with every row an
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3l');
assert.equal(version(path), '3m');
});
test('the 3j engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
@@ -132,14 +134,14 @@ test('the 3j engine refuses a migrated store, migrating or not, and leaves it as
schema: ddl3j,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3j).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3l';", "export const SCHEMA_VERSION = '3j';")
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3j';")
.replace(/\}, \{\n {2}\/\/ Row 236 \(#1742[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3j/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3j';") && !pinned.includes('3j-3k'), 'the 3j engine has no 3j→3k step');
assert.throws(() => new Store3j(root), /schema-mismatch/);
assert.throws(() => new Store3j(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3l');
assert.equal(version(path), '3m');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), after);
});
+8 -6
View File
@@ -20,7 +20,8 @@ const NEW = ['hosts', 'host_grants', 'events_grant_actor', 'sqlite_autoindex_hos
const LATER = ['gitea_users', 'gitea_grants', 'sqlite_autoindex_gitea_users_1', 'sqlite_autoindex_gitea_grants_1'];
// Row 264a (#1769): what the 3k→3l step rebuilds or adds. sessions.test.mjs tests those objects.
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
const later3l = (r) => SESSIONS_3L.includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function source3i(t) {
const root = mkdtempSync(join(tmpdir(), 'grant-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
@@ -40,7 +41,7 @@ function source3i(t) {
function rows(path) {
const db = new DatabaseSync(path, { readOnly: true });
try {
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults')").all()
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults','thread_shares')").all()
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
} finally { db.close(); }
@@ -77,6 +78,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
writeFileSync(join(moduleRoot, 'src/migrations/3i-3j.sql'), step(readFileSync(here('../src/migrations/3i-3j.sql'), 'utf8')));
copyFileSync(here('../src/migrations/3j-3k.sql'), join(moduleRoot, 'src/migrations/3j-3k.sql'));
copyFileSync(here('../src/migrations/3k-3l.sql'), join(moduleRoot, 'src/migrations/3k-3l.sql'));
copyFileSync(here('../src/migrations/3l-3m.sql'), join(moduleRoot, 'src/migrations/3l-3m.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
function fresh(t) {
@@ -100,7 +102,7 @@ test('3i copy migrates to 3j: every row kept, and sqlite_master gains only the t
const { root, path } = source3i(t), before = rows(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3l');
assert.equal(version(path), '3m');
assert.deepEqual(migrated3l(rows(path)), before);
const now = master(path).filter(r => !LATER.includes(r.name));
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
@@ -125,7 +127,7 @@ test('a 3i→3j step that fails partway leaves the store at 3i with every row an
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3l');
assert.equal(version(path), '3m');
});
test('the 3i engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
@@ -136,14 +138,14 @@ test('the 3i engine refuses a migrated store, migrating or not, and leaves it as
schema: ddl3i,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3i).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3l';", "export const SCHEMA_VERSION = '3i';")
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3i';")
.replace(/\}, \{\n {2}\/\/ Row 234 \(#1740\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3i/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3i';") && !pinned.includes('3i-3j'), 'the 3i engine has no 3i→3j step');
assert.throws(() => new Store3i(root), /schema-mismatch/);
assert.throws(() => new Store3i(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3l');
assert.equal(version(path), '3m');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), after);
});
+1 -1
View File
@@ -65,7 +65,7 @@ test('3f copy migrates to 3g preserving every old column/row, with an empty inst
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
assert.equal(store.get('SELECT count(*) AS n FROM instance_names').n, 0);
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: 'docs/spec.md', spec_commit: 'c'.repeat(40) });
+3 -2
View File
@@ -522,7 +522,8 @@ test('v3b prototype refusals, views and append-only mutations', (t) => {
// Row 234 (#1740) adds hosts, host_grants and the events_grant_actor guard; grant-migration.test.mjs tests them.
// Row 236 (#1742) adds gitea_users and gitea_grants; gitea-migration.test.mjs tests them.
// Row 264a (#1769) adds sessions, session_defaults and 15 triggers; sessions.test.mjs tests them.
assert.equal(count('table') - 1, 22);
assert.equal(count('trigger'), 86);
// Row 206 (#1704) adds thread_shares and its 3 append-only triggers; thread-shares.test.mjs tests them.
assert.equal(count('table') - 1, 23);
assert.equal(count('trigger'), 89);
assert.equal(count('view'), 5);
});
+10 -10
View File
@@ -199,21 +199,21 @@ test('T2 sage-2257: a released or revoked run\'s retired generation refuses its
// Each refusal leaves its one action.refused event (request()'s catch, #refused); nothing else is
// written: no approval, delivery, read or claim state (mos-1277).
const lastSeq = () => store.get('SELECT max(seq) AS s FROM events').s;
const refusedOnce = (fn, label) => {
const refusedOnce = (fn, label, run) => {
const from = lastSeq();
assert.throws(fn, { code: 'not-holder' }, label);
const added = store.all('SELECT kind, body FROM events WHERE seq > ? ORDER BY seq', from);
const added = store.all('SELECT kind, actor_run, body FROM events WHERE seq > ? ORDER BY seq', from);
assert.deepEqual(
added.map((e) => [e.kind, JSON.parse(e.body)]),
[['action.refused', { code: 'not-holder' }]],
added.map((e) => [e.kind, e.actor_run, JSON.parse(e.body)]),
[['action.refused', run, { code: 'not-holder' }]],
label,
);
};
const refused = (name, { d, taken }) => {
const { events, ...before } = counts();
refusedOnce(() => call(name, 'decision.withdraw', { id: d.id }), 'approval');
refusedOnce(() => call(name, 'message.receive'), 'delivery');
refusedOnce(() => call(name, 'message.read', { id: taken.id }), 'read');
refusedOnce(() => call(name, 'decision.withdraw', { id: d.id }), 'approval', `${name}-run`);
refusedOnce(() => call(name, 'message.receive'), 'delivery', `${name}-run`);
refusedOnce(() => call(name, 'message.read', { id: taken.id }), 'read', `${name}-run`);
const { events: after, ...rest } = counts();
assert.deepEqual(rest, before);
assert.equal(after, events + 3);
@@ -374,7 +374,7 @@ test('T7: a 3k store migrates to 3l, each thread\'s default session has the thre
const { root, path } = source3k(t);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3l');
assert.equal(version(path), '3m');
// The migrated schema is the fresh one, object for object.
const freshRoot = mkdtempSync(join(tmpdir(), 'sessions-fresh-'));
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
@@ -430,7 +430,7 @@ async function engine3k(root) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3k).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3l';", "export const SCHEMA_VERSION = '3k';")
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3k';")
.replace(/\}, \{\n {2}\/\/ Row 264a \(#1769\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3k';") && edited.includes('3j-3k') && !edited.includes('3k-3l'), 'the 3k engine has no 3k→3l step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
@@ -447,7 +447,7 @@ test('T7 D9: the 3k engine refuses a 3l store, migrating or not, and leaves it a
const Store3k = await engine3k(root);
assert.throws(() => new Store3k(root), /schema-mismatch/);
assert.throws(() => new Store3k(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3l');
assert.equal(version(path), '3m');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
// The same engine opens a fresh 3k store, so the refusal is the version's and not a broken copy.
+1 -1
View File
@@ -63,7 +63,7 @@ test('3e copy migrates through 3f to 3g preserving every old column/row and addi
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: null, spec_commit: null });
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
+3 -3
View File
@@ -49,7 +49,7 @@ async function database3d(t) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3d).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3l';", "export const SCHEMA_VERSION = '3d';")
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3d';")
.replace(/\}, \{\n {2}from: '3d',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(!pinned.includes('3d-3e'), 'the 3d engine has no 3d→3e step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), pinned);
@@ -101,7 +101,7 @@ test('REQ-THREAD-1 REQ-ORG-2: the 3d→3e migration adds the thread tables and l
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
for (const table of ['projects', 'threads', 'thread_claims', 'message_threads', 'thread_opens'])
assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0, table);
// Parked: the undelivered ones are counted per business and role, and none is changed or given a thread.
@@ -161,5 +161,5 @@ test('REQ-THREAD-1: the offline command prints the kept-message counts before th
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr);
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3l ready', ''].join('\n'));
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3m ready', ''].join('\n'));
});
+460
View File
@@ -0,0 +1,460 @@
// Row 206 (193b2, #1704, row 193 design 5.2-5.6): a thread owner's read-only shares. The owner shares
// a thread with a user, the business human or everyone; #readable lets a sharee read it while the
// share is in force, the sharee's grant still covers it and a user owner's grant does too. Every
// write verb keeps the owner check. Mutants 2, 3, 4, 7, 8 and 9 of design section 8 die here.
import test from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash, randomUUID } from 'node:crypto';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { Store, schemaDigest } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
const shipped = (name) =>
JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
const here = (p) => new URL(p, import.meta.url);
const grant = (over = {}) => ({
id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator', ...over,
});
// As user-principal.test.mjs: the access document is a function each test edits between requests.
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-thread-shares-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const access = { doc: { accessVersion: 1, revision: 1, users: [], grants: [] }, fail: false };
const b = new Broker({
store,
businesses: {
demo: {
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
roles: {
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
},
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } },
},
},
access: () => {
if (access.fail) throw new Error('access file refused');
return access.doc;
},
});
const caps = {
human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
reader: b.bindReader({ business: 'demo' }),
tester: b.bindTester({ business: 'demo' }),
};
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
const code = (who, verb, args = {}) => {
try {
call(who, verb, args);
} catch (e) {
return e.code;
}
return null;
};
const p1 = call('human', 'project.create', { name: 'shared' }).id;
const p2 = call('human', 'project.create', { name: 'private' }).id;
// alice and bob hold collaborator grants on p1, carol on p2 only.
const base = () => [grant({ projects: [p1] }), grant({ id: 'g-2', user: 'bob', projects: [p1] }), grant({ id: 'g-3', user: 'carol', projects: [p2] })];
access.doc.grants = base();
for (const name of ['alice', 'bob', 'carol']) caps[name] = b.bindUser({ business: 'demo', user: name });
const A = call('alice', 'thread.create', { project: p1, name: 'alice work' }).id;
const sent = call('alice', 'thread.send', { thread: A, body: 'alice: hello' }).id;
const H = call('human', 'thread.create', { project: p1, name: 'owner work' }).id;
call('human', 'thread.send', { thread: H, body: 'jason: hello' });
const rows = () => store.get('SELECT count(*) n FROM thread_shares').n;
const read = (who, thread) => call(who, 'thread.messages', { thread }).map((m) => m.body);
return { b, store, access, caps, call, code, p1, p2, base, A, sent, H, rows, read };
}
test('a share lets the sharee read the thread and nothing else: every write verb stays the owner\'s (mutants 7, 9)', (t) => {
const { call, code, A, p1, rows, read } = setup(t);
assert.equal(code('bob', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.deepEqual(call('bob', 'thread.list', { shared: true }), []);
assert.deepEqual(call('alice', 'thread.share', { thread: A, to: 'user:bob' }), { thread: A, to: 'user:bob', op: 'share' });
assert.deepEqual(read('bob', A), ['alice: hello']);
const [listed] = call('bob', 'thread.list', { shared: true });
assert.deepEqual(Object.keys(listed), ['id', 'at', 'project', 'instance', 'name', 'owner']);
assert.deepEqual({ ...listed, at: null }, { id: A, at: null, project: p1, instance: 'assistant', name: 'alice work', owner: 'user:alice' });
// The plain list keeps its shape and holds only bob's own threads.
assert.deepEqual(call('bob', 'thread.list'), []);
assert.deepEqual(Object.keys(call('alice', 'thread.list')[0]), ['id', 'at', 'project', 'instance', 'name']);
// Mutant 7 (#threadRead for thread.send) and mutant 9 (no owner check on thread.share) die here.
for (const [verb, args] of [
['thread.send', { body: 'bob was here' }],
['thread.open', {}],
['thread.share', { to: '*' }],
['thread.share', { to: 'jason' }],
['thread.unshare', { to: 'user:bob' }],
['thread.shares', {}],
]) assert.equal(code('bob', verb, { thread: A, ...args }), 'thread-not-owned', verb);
assert.equal(rows(), 1);
assert.deepEqual(read('alice', A), ['alice: hello']);
// The share is bob's alone: the owner and carol still can't read.
assert.equal(code('human', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.deepEqual(call('human', 'thread.list', { shared: true }), []);
assert.equal(code('carol', 'thread.messages', { thread: A }), 'thread-not-owned');
});
test('the business human shares too, and a `*` share reaches every covered principal but the tester', (t) => {
const { call, code, A, H, rows, read } = setup(t);
assert.deepEqual(call('human', 'thread.share', { thread: H, to: 'user:alice' }), { thread: H, to: 'user:alice', op: 'share' });
assert.deepEqual(read('alice', H), ['jason: hello']);
assert.equal(code('bob', 'thread.messages', { thread: H }), 'thread-not-owned');
assert.deepEqual(call('alice', 'thread.share', { thread: A, to: '*' }), { thread: A, to: '*', op: 'share' });
assert.deepEqual(read('bob', A), ['alice: hello']);
assert.deepEqual(read('human', A), ['alice: hello']);
assert.deepEqual(call('human', 'thread.list', { shared: true }).map((x) => x.id), [A]);
// Rule 2: carol's grant doesn't cover p1, so `*` doesn't reach her. Rule 4: nor the tester.
assert.equal(code('carol', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.deepEqual(call('carol', 'thread.list', { shared: true }), []);
assert.equal(code('tester', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.deepEqual(call('tester', 'thread.list', { shared: true }), []);
assert.deepEqual(call('alice', 'thread.shares', { thread: A }).map((x) => [x.to, x.by]), [['*', 'user:alice']]);
assert.deepEqual(call('human', 'thread.shares', { thread: H }).map((x) => [x.to, x.by]), [['user:alice', 'jason']]);
assert.equal(rows(), 2);
});
test('mutant 2: a share never widens a grant; when the sharee\'s grant stops covering the thread, its reads end', (t) => {
const { access, call, code, base, A, p2, read } = setup(t);
call('alice', 'thread.share', { thread: A, to: 'user:bob' });
assert.deepEqual(read('bob', A), ['alice: hello']);
// bob keeps a collaborator grant, so the user gate passes, but it now covers p2 only.
access.doc.grants = base().map((g) => (g.user === 'bob' ? { ...g, projects: [p2] } : g));
assert.equal(code('bob', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.deepEqual(call('bob', 'thread.list', { shared: true }), []);
// A grant on another instance is the same.
access.doc.grants = base().map((g) => (g.user === 'bob' ? { ...g, instances: ['chief'] } : g));
assert.equal(code('bob', 'thread.messages', { thread: A }), 'thread-not-owned');
access.doc.grants = base();
assert.deepEqual(read('bob', A), ['alice: hello']);
// Removed, expired or an unreadable file: the gate refuses bob's next request.
for (const [what, change] of [
['removed', () => { access.doc.grants = base().filter((g) => g.user !== 'bob'); }],
['expired', () => { access.doc.grants = base().map((g) => (g.user === 'bob' ? { ...g, expires: '2000-01-01T00:00:00Z' } : g)); }],
['unreadable', () => { access.fail = true; }],
]) {
change();
assert.equal(code('bob', 'thread.messages', { thread: A }), 'access-revoked', what);
access.doc.grants = base();
access.fail = false;
assert.deepEqual(read('bob', A), ['alice: hello'], what);
}
});
test('mutant 3: a thread whose user owner has lost coverage is hidden from its sharees, the owner\'s board included', (t) => {
const { access, call, code, base, A, sent, read } = setup(t);
call('alice', 'thread.share', { thread: A, to: 'jason' });
assert.deepEqual(read('human', A), ['alice: hello']);
assert.ok(call('human', 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent));
assert.ok(call('reader', 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent));
for (const [what, change] of [
['removed', () => { access.doc.grants = base().filter((g) => g.user !== 'alice'); }],
['expired', () => { access.doc.grants = base().map((g) => (g.user === 'alice' ? { ...g, expires: '2000-01-01T00:00:00Z' } : g)); }],
['out of scope', () => { access.doc.grants = base().map((g) => (g.user === 'alice' ? { ...g, projects: ['none'] } : g)); }],
['unreadable', () => { access.fail = true; }],
]) {
change();
assert.equal(code('human', 'thread.messages', { thread: A }), 'thread-not-owned', what);
assert.deepEqual(call('human', 'thread.list', { shared: true }), [], what);
for (const who of ['human', 'reader']) assert.deepEqual(call(who, 'trail', { subject: sent }), [], `${who} ${what}`);
access.doc.grants = base();
access.fail = false;
assert.deepEqual(read('human', A), ['alice: hello'], what);
}
// A viewer grant still covers: rule 3 asks for any level.
access.doc.grants = base().map((g) => (g.user === 'alice' ? { ...g, level: 'viewer' } : g));
assert.deepEqual(read('human', A), ['alice: hello']);
});
test('mutant 4: a share\'s state is its latest row, so an unshare ends it and a share after it starts it again', (t) => {
const { store, call, code, A, rows, read } = setup(t);
call('alice', 'thread.share', { thread: A, to: 'user:bob' });
assert.equal(code('alice', 'thread.share', { thread: A, to: 'user:bob' }), 'share-exists');
assert.deepEqual(call('alice', 'thread.unshare', { thread: A, to: 'user:bob' }), { thread: A, to: 'user:bob', op: 'unshare' });
assert.equal(code('bob', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.deepEqual(call('bob', 'thread.list', { shared: true }), []);
assert.deepEqual(call('alice', 'thread.shares', { thread: A }), []);
assert.equal(code('alice', 'thread.unshare', { thread: A, to: 'user:bob' }), 'share-not-found');
assert.equal(code('alice', 'thread.unshare', { thread: A, to: '*' }), 'share-not-found');
call('alice', 'thread.share', { thread: A, to: 'user:bob' });
assert.deepEqual(read('bob', A), ['alice: hello']);
assert.deepEqual(call('alice', 'thread.shares', { thread: A }).map((x) => x.to), ['user:bob']);
assert.deepEqual(store.all('SELECT op,target,by FROM thread_shares ORDER BY seq').map((r) => ({ ...r })), [
{ op: 'share', target: 'user:bob', by: 'user:alice' },
{ op: 'unshare', target: 'user:bob', by: 'user:alice' },
{ op: 'share', target: 'user:bob', by: 'user:alice' },
]);
// An unshare of `*` leaves bob's own share in force.
call('alice', 'thread.share', { thread: A, to: '*' });
call('alice', 'thread.unshare', { thread: A, to: '*' });
assert.deepEqual(read('bob', A), ['alice: hello']);
assert.equal(code('human', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.equal(rows(), 5);
});
test('mutant 8: a user target needs a live grant of any level covering the thread, else share-not-covered', (t) => {
const { b, caps, access, call, code, base, A, p1, rows, read } = setup(t);
// carol's grant covers p2 only; dave has none; erin's is expired.
access.doc.grants = [...base(), grant({ id: 'g-4', user: 'erin', projects: [p1], expires: '2000-01-01T00:00:00Z' })];
for (const to of ['user:carol', 'user:dave', 'user:erin'])
assert.equal(code('alice', 'thread.share', { thread: A, to }), 'share-not-covered', to);
assert.equal(rows(), 0);
// A viewer grant is enough to be shared with, and the viewer reads the share (row 275).
access.doc.grants = [...base(), grant({ id: 'g-5', user: 'vic', projects: [p1], level: 'viewer' })];
assert.deepEqual(call('alice', 'thread.share', { thread: A, to: 'user:vic' }), { thread: A, to: 'user:vic', op: 'share' });
caps.vic = b.bindUser({ business: 'demo', user: 'vic' });
assert.deepEqual(read('vic', A), ['alice: hello']);
// An unshare checks no coverage: an uncovered target's share can still be ended.
access.doc.grants = base();
assert.deepEqual(call('alice', 'thread.unshare', { thread: A, to: 'user:vic' }), { thread: A, to: 'user:vic', op: 'unshare' });
assert.equal(rows(), 2);
});
test('a viewer reads a thread shared with it, and share, unshare and shares refuse a viewer access-refused (design 4.3)', (t) => {
const { access, call, code, base, A, rows, read } = setup(t);
call('alice', 'thread.share', { thread: A, to: 'user:bob' });
// bob drops to viewer and still reads the share.
access.doc.grants = base().map((g) => (g.user === 'bob' ? { ...g, level: 'viewer' } : g));
assert.deepEqual(read('bob', A), ['alice: hello']);
assert.deepEqual(call('bob', 'thread.list', { shared: true }).map((x) => x.id), [A]);
// alice drops to viewer too: she reads her own thread, and each share verb on it refuses.
access.doc.grants = base().map((g) => ({ ...g, level: 'viewer' }));
assert.deepEqual(read('alice', A), ['alice: hello']);
for (const [verb, args] of [['thread.share', { to: '*' }], ['thread.unshare', { to: 'user:bob' }], ['thread.shares', {}]])
assert.equal(code('alice', verb, { thread: A, ...args }), 'access-refused', verb);
assert.equal(rows(), 1);
// Rule 3 asks any level, so alice's viewer grant keeps the thread readable to bob.
assert.deepEqual(read('bob', A), ['alice: hello']);
});
test('a share target is `user:<id>`, the business human or `*`, and never the sharer', (t) => {
const { call, code, A, H, rows } = setup(t);
for (const to of ['user:alice', 'bob', 'tester', 'user:tester', 'user:human', 'user:user', 'user:jason', 'user:', 'user:Bad Name', '', 42, null, ['*']])
assert.equal(code('alice', 'thread.share', { thread: A, to }), 'invalid-request', JSON.stringify(to));
for (const to of ['jason', 'user:x y'])
assert.equal(code('human', 'thread.share', { thread: H, to }), 'invalid-request', to);
assert.equal(code('alice', 'thread.share', { thread: A }), 'invalid-request');
assert.equal(code('alice', 'thread.share', { thread: A, to: '*', extra: 1 }), 'invalid-request');
assert.equal(code('alice', 'thread.shares', { thread: A, to: '*' }), 'invalid-request');
assert.equal(code('alice', 'thread.share', { thread: randomUUID(), to: '*' }), 'thread-not-found');
assert.equal(code('alice', 'thread.list', { shared: false }), 'invalid-request');
assert.equal(code('alice', 'thread.list', { shared: 'yes' }), 'invalid-request');
assert.equal(rows(), 0);
// The owner shares its own threads only; a user's thread refuses the business human too.
assert.equal(code('human', 'thread.share', { thread: A, to: '*' }), 'thread-not-owned');
assert.equal(code('alice', 'thread.share', { thread: H, to: '*' }), 'thread-not-owned');
assert.equal(rows(), 0);
assert.deepEqual(call('human', 'thread.share', { thread: H, to: '*' }).op, 'share');
});
test('a user whose collaborator grant no longer covers its own thread can\'t share, unshare or list its shares', (t) => {
const { access, call, code, base, A, p2, rows } = setup(t);
call('alice', 'thread.share', { thread: A, to: 'user:bob' });
access.doc.grants = base().map((g) => (g.user === 'alice' ? { ...g, projects: [p2] } : g));
for (const [verb, to] of [['thread.share', '*'], ['thread.unshare', 'user:bob'], ['thread.shares', undefined]])
assert.equal(code('alice', verb, { thread: A, ...(to ? { to } : {}) }), 'access-refused', verb);
assert.equal(rows(), 1);
});
test('the tester and a reader session never share: each verb refuses and writes nothing', (t) => {
const { store, call, code, A, H, rows } = setup(t);
const refusals = () => store.get("SELECT count(*) n FROM events WHERE kind='action.refused'").n;
const T = call('tester', 'thread.create', { project: call('human', 'project.list')[0].id, name: 'tester work' }).id;
const was = refusals();
for (const verb of ['thread.share', 'thread.unshare', 'thread.shares'])
for (const thread of [T, H, A]) {
assert.equal(code('tester', verb, { thread, to: '*' }), 'tester-refused', `${verb} ${thread}`);
assert.equal(code('reader', verb, { thread, to: '*' }), 'read-only', `${verb} ${thread}`);
}
// Each refusal is recorded, as every refused request is.
assert.equal(refusals() - was, 18);
assert.equal(rows(), 0);
// A share with `*` of the owner's thread doesn't reach the tester's reads.
call('human', 'thread.share', { thread: H, to: '*' });
assert.equal(code('tester', 'thread.messages', { thread: H }), 'thread-not-owned');
});
test('the owner\'s trail and replies show a user\'s message only while that user\'s thread is shared with the owner', (t) => {
const { store, call, code, A, sent } = setup(t);
// The run's reply to alice, as R3's `to: 'user'` path writes it.
const reply = randomUUID();
store.run(
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
reply, new Date().toISOString(), 'demo', 'assistant', 'assistant-run', 'user', 'INFO', sent, null, null, 'hello alice',
);
// A new thread's default session has its own id (only a migrated thread's reuses the thread's).
const { session } = store.get('SELECT session FROM session_defaults WHERE thread=? ORDER BY seq DESC LIMIT 1', A);
store.run('INSERT INTO message_threads(message,thread,session) VALUES(?,?,?)', reply, A, session);
for (const who of ['human', 'reader']) {
assert.deepEqual(call(who, 'trail', { subject: sent }), [], who);
assert.equal(code(who, 'replies', { id: sent }), 'message-not-found', who);
}
call('alice', 'thread.share', { thread: A, to: 'jason' });
for (const who of ['human', 'reader']) {
assert.ok(call(who, 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent), who);
assert.deepEqual(call(who, 'replies', { id: sent }).map((m) => m.id), [reply], who);
}
// The tester never reads another's thread (rule 4).
assert.deepEqual(call('tester', 'trail', { subject: sent }), []);
assert.equal(code('tester', 'replies', { id: sent }), 'message-not-found');
call('alice', 'thread.unshare', { thread: A, to: 'jason' });
for (const who of ['human', 'reader']) {
assert.deepEqual(call(who, 'trail', { subject: sent }), [], who);
assert.equal(code(who, 'replies', { id: sent }), 'message-not-found', who);
}
});
test('a share covers every session of the thread', (t) => {
const { store, call, code, H, read } = setup(t);
call('human', 'session.create', { thread: H, name: 'second', op: 'op-second' });
const S = store.get("SELECT id FROM sessions WHERE thread=? AND name='second'", H).id;
call('human', 'thread.send', { thread: H, session: S, body: 'jason: second' });
call('human', 'thread.share', { thread: H, to: 'user:alice' });
assert.deepEqual(read('alice', H), ['jason: hello']);
assert.deepEqual(call('alice', 'thread.messages', { thread: H, session: S }).map((m) => m.body), ['jason: second']);
assert.equal(code('alice', 'thread.messages', { thread: H, session: randomUUID() }), 'session-not-found');
assert.equal(code('bob', 'thread.messages', { thread: H, session: S }), 'thread-not-owned');
});
test('a sharee can\'t list the thread\'s sessions: session.list stays owner-only (Q7, sage-2818 Q1)', (t) => {
const { call, code, A, H } = setup(t);
call('alice', 'thread.share', { thread: A, to: 'jason' });
call('human', 'thread.share', { thread: H, to: '*' });
assert.equal(code('human', 'session.list', { thread: A }), 'thread-not-owned');
assert.equal(code('reader', 'session.list', { thread: A }), 'read-only');
for (const who of ['alice', 'bob']) assert.equal(code(who, 'session.list', { thread: H }), 'access-refused', who);
// The owners still list their own.
assert.equal(call('human', 'session.list', { thread: H }).length, 1);
});
test('only a human principal matches a share: an agent bound to the thread and the tester match none (sage-2818 Q2)', (t) => {
const { b, caps, call, code, A, H, sent } = setup(t);
// The assistant run serving alice's thread, and a chief run with no thread.
caps.agent = b.bindLaunch({ business: 'demo', role: 'assistant', run: 'assistant-run', harness: 'pi', thread: A });
caps.chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
for (const who of ['agent', 'chief']) call(who, 'role.claim');
const seen = (who) => call(who, 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent);
for (const who of ['agent', 'chief', 'tester']) assert.equal(seen(who), false, `${who} before`);
call('alice', 'thread.share', { thread: A, to: '*' });
call('alice', 'thread.share', { thread: A, to: 'jason' });
// The human principals now read it; the agents and the tester see what they saw before.
for (const who of ['human', 'reader']) assert.equal(seen(who), true, who);
for (const who of ['agent', 'chief', 'tester']) assert.equal(seen(who), false, `${who} after`);
for (const who of ['agent', 'chief']) {
assert.equal(code(who, 'thread.messages', { thread: A }), 'human-required', who);
assert.equal(code(who, 'replies', { id: sent }), 'human-required', who);
}
assert.equal(code('tester', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.equal(code('tester', 'replies', { id: sent }), 'message-not-found');
call('human', 'thread.share', { thread: H, to: '*' });
assert.equal(code('tester', 'thread.messages', { thread: H }), 'thread-not-owned');
});
// The 3l→3m step. A 3l store from fixtures/schema-3l.sql, with one row in each thread table.
const AT = '2026-10-22T00:00:00.000Z';
const ddl3l = readFileSync(here('./fixtures/schema-3l.sql'), 'utf8');
function source3l(t) {
const root = mkdtempSync(join(tmpdir(), 'thread-shares-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(join(root, 'bus'), { mode: 0o700 });
const path = join(root, 'bus/bus.sqlite'), db = new DatabaseSync(path);
assert.equal(createHash('sha256').update(ddl3l).digest('hex'), 'cd68512212ce8bf8f5631138b2e63a340fa7b940088430649bb7784af7df162a');
db.exec(ddl3l);
const run = (sql, ...args) => db.prepare(sql).run(...args);
run('INSERT INTO meta VALUES (?,?)', 'schema_version', '3l');
run('INSERT INTO meta VALUES (?,?)', 'schema_digest', schemaDigest(db));
run('INSERT INTO meta VALUES (?,?)', 'comment_cursor_epoch', AT);
run('INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)', 'p-1', AT, 'demo', 'stack work', 'stack', 'jason');
for (const [id, owner] of [['t1', 'jason'], ['t2', 'user:alice']]) {
run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', id, AT, 'demo', 'p-1', 'assistant', owner, id);
run("INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) VALUES(?,?,?,?,?,?,'default','legacy-3l')", AT, 'demo', id, id, 'assistant', owner);
run("INSERT INTO session_defaults(at,thread,session,by) VALUES(?,?,?,'migration-3l')", AT, id, id);
}
run('INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES(?,?,?,?,?,?,?,?)', 'm-1', AT, 'demo', 'user', 'user:alice', 'assistant', 'INFO', 'm-1');
run('INSERT INTO message_threads VALUES(?,?,?)', 'm-1', 't2', 't2');
run('INSERT INTO thread_opens(seq,at,thread,session,by) VALUES(?,?,?,?,?)', 7, AT, 't2', 't2', 'user:alice');
db.close();
chmodSync(path, 0o600);
return { root, path };
}
const open = (path, fn) => {
const db = new DatabaseSync(path, { readOnly: true });
try { return fn(db); } finally { db.close(); }
};
const master = (path) => open(path, (db) => db.prepare('SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name').all().map((r) => ({ ...r })));
// Every table's rows but meta's, whose version and digest the step changes.
const tables = (path) => open(path, (db) => Object.fromEntries(
db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('meta') ORDER BY name").all()
.map(({ name }) => [name, db.prepare(`SELECT * FROM "${name}" ORDER BY rowid`).all().map((r) => ({ ...r }))]),
));
const version = (path) => open(path, (db) => db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value);
test('a 3l store migrates to 3m: thread_shares is new and empty, and every other row stays as it was', (t) => {
const { root, path } = source3l(t);
const before = tables(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3m');
const after = tables(path);
assert.deepEqual(after.thread_shares, []);
delete after.thread_shares;
assert.deepEqual(after, before);
// The migrated schema is the fresh one, object for object.
const freshRoot = mkdtempSync(join(tmpdir(), 'thread-shares-fresh-'));
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
new Store(freshRoot).close();
assert.deepEqual(master(path), master(join(freshRoot, 'bus/bus.sqlite')));
// No existing thread is shared: each stays private to its owner.
const store = new Store(root);
t.after(() => store.close());
store.run('INSERT INTO thread_shares(at,business,thread,op,target,by) VALUES(?,?,?,?,?,?)', AT, 'demo', 't2', 'share', 'jason', 'user:alice');
for (const [sql, args] of [
["UPDATE thread_shares SET target='*'", []],
['DELETE FROM thread_shares', []],
['INSERT OR REPLACE INTO thread_shares(seq,at,business,thread,op,target,by) VALUES(?,?,?,?,?,?,?)', [1, AT, 'demo', 't2', 'unshare', 'jason', 'user:alice']],
]) assert.throws(() => store.run(sql, ...args), /thread_shares is append-only/, sql);
assert.throws(() => store.run('INSERT INTO thread_shares(at,business,thread,op,target,by) VALUES(?,?,?,?,?,?)', AT, 'demo', 't2', 'grant', '*', 'user:alice'), /CHECK/);
assert.throws(() => store.run('INSERT INTO thread_shares(at,business,thread,op,target,by) VALUES(?,?,?,?,?,?)', AT, 'demo', 'no-such', 'share', '*', 'jason'), /FOREIGN KEY/);
assert.deepEqual(store.all('SELECT seq,thread,op,target FROM thread_shares').map((r) => ({ ...r })), [{ seq: 1, thread: 't2', op: 'share', target: 'jason' }]);
});
// The 3l engine is store.mjs at this change with the 3l schema and version and no 3l→3m step.
async function engine3l(root) {
const moduleRoot = join(root, 'engine-3l');
mkdirSync(join(moduleRoot, 'src/migrations'), { recursive: true });
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3l).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3l';")
.replace(/\}, \{\n {2}\/\/ Row 206 \(193b2[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3l';") && edited.includes('3k-3l') && !edited.includes('3l-3m'), 'the 3l engine has no 3l→3m step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
writeFileSync(join(moduleRoot, 'schema.sql'), ddl3l);
for (const file of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql'])
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
test('the 3l engine refuses a 3m store, migrating or not, and leaves it as it was', async (t) => {
const { root, path } = source3l(t);
new Store(root, { migrateSchema: true }).close();
const was = master(path), before = tables(path);
const Store3l = await engine3l(root);
assert.throws(() => new Store3l(root), /schema-mismatch/);
assert.throws(() => new Store3l(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3m');
assert.deepEqual(master(path), was);
assert.deepEqual(tables(path), before);
// The same engine opens a fresh 3l store, so the refusal is the version's and not a broken copy.
const { root: old } = source3l(t);
new Store3l(old).close();
});
+1 -1
View File
@@ -68,7 +68,7 @@ test('REQ-LIMIT-4 3g copy migrates through 3h, 3i, 3j and 3k to 3l preserving ev
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
assert.equal(store.get('SELECT count(*) AS n FROM usage_records').n, 0);
assert.equal(store.get('SELECT name FROM instance_names').name, 'Builder');
+3 -1
View File
@@ -142,7 +142,9 @@ test('R3: every verb off the collaborator list refuses a user access-refused bef
]);
// R3 r6 (sage-2576): the collaborator list as ruled, written out, so a widened USER_VERBS fails here
// rather than leaving the set below with the verb it added (M3x, M6x).
assert.deepEqual(USER_VERBS, ['project.list', 'thread.create', 'thread.list', 'thread.open', 'thread.send', 'thread.messages', 'names']);
assert.deepEqual(USER_VERBS, ['project.list', 'thread.create', 'thread.list', 'thread.open', 'thread.send', 'thread.messages', 'names',
// Row 206 (193b2): the share verbs, owner only (thread-shares.test.mjs).
'thread.share', 'thread.unshare', 'thread.shares']);
for (const v of USER_VERBS) others.delete(v);
assert.ok(others.size > 40);
for (const verb of others) {
+117 -50
View File
@@ -75,6 +75,15 @@
// threadStore()), so the next run for the thread resumes the harness
// session (REQ-THREAD-2).
//
// Row 269 (264b, #1774): threadsState has one row per session of each
// thread, and everything above is per session. Each session has its own
// store (session.mjs sessionStore()) and its own workspace under
// session-workspaces/ (sessionWorkspace()), which a lock beside it keeps to
// one writer from before the clones until the run's proven exit. A legacy
// session (op legacy-3l) keeps the thread's store, and its new workspace
// shows inside the sandbox at the old instance path, the cwd its history
// recorded. The broker's half of the fence is its per-session claim.
//
// Landing runs (row 156, #1651). Every launch of a non-thread instance
// reads the broker's landHolders (launch op): the approved, unspent merge
// decisions each non-thread instance raised. The instance's entries go into
@@ -121,12 +130,14 @@
// processing-halted.
// - delete: instance-running against the registry, then lifecycleDelete
// (the writer's commit), then the rename of workspaces/<b>/I/ to
// workspaces/<b>/.retired/I-<transaction>/. A failed rename closes it
// workspace-rename-failed with the transaction; a later delete retries it.
// workspaces/<b>/.retired/I-<transaction>/, and of
// session-workspaces/<b>/I/ to session-workspaces/<b>/.retired/I-<transaction>/
// (row 269). A failed rename closes it workspace-rename-failed with the
// transaction; a later delete retries it.
import { createServer } from "node:net";
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
import { dirname, join } from "node:path";
import { dirname, join, relative } from "node:path";
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../business/src/index.mjs";
import { limitHit } from "../../business/src/launch.mjs";
import { BundleError, buildBundle, hasFallback, readSkills, sessionModel } from "../../harness/src/bundle.mjs";
@@ -142,8 +153,10 @@ import {
runnerOf,
SESSION_CREDENTIALS,
sessionEnv,
threadStore,
sessionExit,
sessionStore,
sessionWorkspace,
lockSessionWorkspace,
spawnSession,
workspaceDir,
writeSessionFile,
@@ -237,6 +250,9 @@ export function createLauncher({
throw new CliError(`${e.message}; check which sessions still run, then move the file aside`, 3);
}
const registry = new Registry(dataRoot, { readOnly: !launches });
// Row 269: earlier-host runs still live after recover(). check() sees each while it lives.
const survivors = [];
const liveRun = (s) => startTimeOf(s.pid) === s.startTime || startTimeOf(s.runnerPid) === s.runnerStartTime;
const children = new Map();
let chain = Promise.resolve();
let closed = false;
@@ -278,7 +294,7 @@ export function createLauncher({
// nothing else launches for one. Row 194 (#1692, V2): `human` is the
// human's launch request, which may name any declared instance. The listing
// limits the chief, not the human. launch.by still takes the `pm` branch.
async function check(instance, { pm = false, thread = null, human = false } = {}) {
async function check(instance, { pm = false, thread = null, session = null, human = false } = {}) {
const admissionGeneration = admission().generation;
try { b=loadBusiness(host.business,{dir:configDir(env),rolesDir:rolesDir(env)}); } catch(e){throw new Refusal("organization-refused",e.message);}
if (!b.launch) throw new Refusal("launch-not-configured", `business ${b.id} has no launch block`);
@@ -286,7 +302,12 @@ export function createLauncher({
const threaded = b.roles[instance]?.definition === THREAD_DEFINITION;
if (threaded && !thread) throw new Refusal("thread-required", `${instance} runs only for a thread`);
if (!threaded && thread) throw new Refusal("thread-not-allowed", `${instance} isn't a thread instance`);
if (registry.running(b.id, instance, thread)) throw new Refusal("instance-running");
if (threaded && !session) throw new Refusal("session-required", `${instance} runs for one session of a thread`);
if (registry.running(b.id, instance, thread, session)) throw new Refusal("instance-running");
// Row 269 (Q2): a run an earlier host started with no session works in the instance's old
// workspace and the thread's store, so no session of the instance starts while one lives.
if (threaded && [...registry.sessions, ...leftover, ...survivors.filter(liveRun)].some((s) => s.business === b.id && s.instance === instance && !s.session))
throw new Refusal("legacy-run-live", `a run of ${instance} with no session is still live`);
let resolved;
try {
resolved = resolveInstance({ system: vars, business: b, instance });
@@ -378,29 +399,59 @@ export function createLauncher({
}
// Bundle, spawn, bind, register, hand over the capability.
async function start({ instance, launchedBy, resolved, model, account = null, thread = null, human = false, admissionGeneration }) {
async function start({ instance, launchedBy, resolved, model, account = null, thread = null, session = null, legacy = false, legacyThread = legacy, human = false, admissionGeneration }) {
admission(admissionGeneration);
// Every launch path ends here, so a --no-launch host spawns nothing whichever path asked.
if (!launches) throw new Refusal("launches-disabled", "this host was started with --no-launch");
// A thread's store is checked first, so a bad id or an unsafe directory
// launches nothing.
// A thread session's store is checked first, so a bad id or an unsafe
// directory launches nothing.
let store = null;
if (thread) {
try {
store = threadStore(dataRoot, b.id, thread);
store = sessionStore(dataRoot, b.id, thread, session, { legacy, legacyThread });
} catch (e) {
throw new Refusal("thread-store-refused", e.message);
throw new Refusal(e.code === "legacy-store-unmapped" ? e.code : "thread-store-refused", e.message);
}
}
const credentials = await host.op({ op: "credentialStatus", business: b.id, role: instance });
admission(admissionGeneration);
// The clones come before the run, so one that can't be made leaves no
// run behind. The host's git treats each clone as hostile, and its
// checks hold only while no session writes the workspace: a workspace
// is one instance's, check() refused instance-running, and launches are
// serialized, so none does.
const workspace = workspaceDir(dataRoot, b.id, instance);
mkdirSync(workspace, { recursive: true, mode: 0o700 });
// checks hold only while no session writes the workspace. A non-thread
// workspace is one instance's, check() refused instance-running, and
// launches are serialized. A thread session's workspace is its own (row
// 269), and its lock refuses a second writer, whichever host asks.
let workspace;
let workspaceDest = null;
let lock = null;
if (thread) {
try {
const live = registry.sessions.filter((s) => s.business === b.id && s.workspace).map((s) => s.workspace);
workspace = sessionWorkspace(dataRoot, b.id, instance, session, { live });
lock = lockSessionWorkspace(workspace, { hostPid: process.pid, hostStartTime: startTimeOf(process.pid), business: b.id, instance, thread, session });
} catch (e) {
if (["session-workspace-unsafe", "session-workspace-overlap", "session-workspace-busy"].includes(e.code)) throw new Refusal(e.code, e.message);
throw e;
}
if (legacy) workspaceDest = workspaceDir(dataRoot, b.id, instance);
} else {
workspace = workspaceDir(dataRoot, b.id, instance);
mkdirSync(workspace, { recursive: true, mode: 0o700 });
}
try {
return await startLocked({ instance, launchedBy, resolved, model, account, thread, session, human, admissionGeneration, store, credentials, workspace, workspaceDest, lock });
} catch (e) {
// Every failure path in startLocked has proven the run's exit, or never spawned one.
lock?.release();
throw e;
}
}
// The rest of start(): clones, bundle, spawn, bind, register. The lock, when there is one, is
// released at the run's close.
async function startLocked({ instance, launchedBy, resolved, model, account, thread, session, human, admissionGeneration, store, credentials, workspace, workspaceDest, lock }) {
// What the session sees: a legacy session's workspace at the old instance path.
const inSandbox = workspaceDest ?? workspace;
let checkouts;
try {
checkouts = await withProcessing(dataRoot, () => {
@@ -416,7 +467,7 @@ export function createLauncher({
else if (c.outcome === "fetched") record({ event: "checkout-fetched", instance, project: c.project, reason: c.reason });
}
let harness, route, routeReason;
({resolved,model,account,harness,route,routeReason}=await check(instance,{pm:instance===b.launch.by,thread,human}));
({resolved,model,account,harness,route,routeReason}=await check(instance,{pm:instance===b.launch.by,thread,session,human}));
admission(admissionGeneration);
const { run, runDir } = newRun(dataRoot, b.id);
const sessionDir = store ? store.session : join(runDir, "session");
@@ -440,7 +491,7 @@ export function createLauncher({
arbiter: Object.values(b.arbiters).includes(instance),
tracker,
credentials,
session: { run, launchedBy, workspace, toolSocket, turnMarker, checkouts },
session: { run, launchedBy, workspace: inSandbox, toolSocket, turnMarker, checkouts: inSandbox === workspace ? checkouts : checkouts.map((c) => ({ ...c, path: join(inSandbox, relative(workspace, c.path)) })) },
harness,
route,
routeReason,
@@ -471,7 +522,7 @@ export function createLauncher({
brokerSocket: host.path,
launchSocket: launchSocketPath(dataRoot),
gitSocket: git,
workspace,
workspace: inSandbox,
sessionDir,
adapter,
bundle: files,
@@ -484,6 +535,7 @@ export function createLauncher({
const paths = {
workspace,
workspaceDest,
adapter,
harnessMounts,
brokerSocket: host.path,
@@ -519,11 +571,12 @@ export function createLauncher({
updateRuntime(dataRoot, managed.id, { runnerPid: runner.pid, runnerStartTime: runner.startTime });
admission(admissionGeneration);
if (!outerStart) throw new Error("the session process exited at once");
const launch = await host.bindLaunch({ business: b.id, role: instance, run, harness: model.harness, provider: model.provider, model: model.model, account, transcript, pid: child.pid, startTime: outerStart, ...(thread ? { thread } : {}) });
lock?.update({ run, runPid: child.pid, runStartTime: outerStart });
const launch = await host.bindLaunch({ business: b.id, role: instance, run, harness: model.harness, provider: model.provider, model: model.model, account, transcript, pid: child.pid, startTime: outerStart, ...(thread ? { thread, session } : {}) });
bound = true;
admission(admissionGeneration);
entry = {
business: b.id, instance, ...(thread ? { thread } : {}), run, launchedBy, harness: model.harness, model: model.model, provider: model.provider, modelKey: model.modelKey,
business: b.id, instance, ...(thread ? { thread, session, workspace } : {}), run, launchedBy, harness: model.harness, model: model.model, provider: model.provider, modelKey: model.modelKey,
pid: child.pid, startTime: outerStart, runnerPid: runner.pid, runnerStartTime: runner.startTime, runDir, startedAt: new Date().toISOString(),
};
registry.add(entry);
@@ -552,13 +605,15 @@ export function createLauncher({
const ended = await endRun(run, reason, signal ? null : code);
registry.remove(b.id, run);
// Row 125 (#1619, REQ-LIMIT-4): a run a limit check stopped names the limit, from the claim row.
record({ event: "end", instance, ...(thread ? { thread } : {}), run, reason, exitCode: code, signal, ...(haltGeneration ? { haltGeneration } : {}), ...(ended?.limit ? { limit: ended.limit } : {}) });
record({ event: "end", instance, ...(thread ? { thread, session } : {}), run, reason, exitCode: code, signal, ...(haltGeneration ? { haltGeneration } : {}), ...(ended?.limit ? { limit: ended.limit } : {}) });
log(`run ${run} (${instance}) ended: ${reason}`);
} catch (e) {
log(`run ${run}: ${e.message}`);
} finally {
try { unregisterRuntime(dataRoot, managed.id); }
catch { log(`run ${run}: processing receipt cleanup refused`); }
try { lock?.release(); }
catch (e) { log(`run ${run}: workspace lock release: ${e.message}`); }
children.delete(run);
stopping.delete(run);
spawnedAt.delete(run);
@@ -566,7 +621,7 @@ export function createLauncher({
refusedRelease.delete(run);
}
});
record({ event: "launch", instance, ...(thread ? { thread } : {}), run, launchedBy, harness: model.harness, route, routeReason, model: model.model, provider: model.provider, modelKey: model.modelKey, account, pid: child.pid, runnerPid: entry.runnerPid, manifest });
record({ event: "launch", instance, ...(thread ? { thread, session } : {}), run, launchedBy, harness: model.harness, route, routeReason, model: model.model, provider: model.provider, modelKey: model.modelKey, account, pid: child.pid, runnerPid: entry.runnerPid, manifest });
return { run, instance, harness: model.harness, model: model.model };
}
@@ -600,9 +655,12 @@ export function createLauncher({
}
const now = Date.now();
const ms = (at) => (at ? Date.parse(at) : 0);
// Row 269: a thread with a legacy session keeps its old store for that session only.
const legacyThreads = new Set(threads.filter((t) => t.legacy).map((t) => t.thread));
for (const t of threads) {
if (closed) return;
const s = registry.running(b.id, t.instance, t.thread);
const key = `${t.thread}/${t.session}`;
const s = registry.running(b.id, t.instance, t.thread, t.session);
if (s) {
const last = Math.max(ms(s.startedAt), ms(t.lastMessage), ms(t.lastOpen));
if (!stopping.has(s.run) && now - last >= b.launch.threadIdleSeconds * 1000) {
@@ -616,7 +674,7 @@ export function createLauncher({
// A turn in progress, or a message since the snapshot: the run keeps going.
if (!idle.released) continue;
stopping.add(s.run);
record({ event: "thread-idle", instance: t.instance, thread: t.thread, run: s.run });
record({ event: "thread-idle", instance: t.instance, thread: t.thread, session: t.session, run: s.run });
try {
if (startTimeOf(s.runnerPid) === s.runnerStartTime) process.kill(s.runnerPid, "SIGTERM");
} catch {}
@@ -629,20 +687,21 @@ export function createLauncher({
// the work (it died before its claim, say) waits for the next one
// rather than start again every tick. A refused start isn't a start.
const signature = `${t.lastMessage}|${t.lastOpen}`;
if (startedFor.get(t.thread) === signature) continue;
if (startedFor.get(key) === signature) continue;
await serialize(async () => {
if (closed || registry.running(b.id, t.instance, t.thread)) return;
if (closed || registry.running(b.id, t.instance, t.thread, t.session)) return;
try {
await start({ instance: t.instance, launchedBy: b.human, thread: t.thread, ...(await check(t.instance, { thread: t.thread })) });
startedFor.set(t.thread, signature);
refusedThreads.delete(t.thread);
const where = { thread: t.thread, session: t.session };
await start({ instance: t.instance, launchedBy: b.human, ...where, legacy: t.legacy, legacyThread: legacyThreads.has(t.thread), ...(await check(t.instance, where)) });
startedFor.set(key, signature);
refusedThreads.delete(key);
} catch (e) {
const code = e instanceof Refusal ? e.code : "launch-failed";
// Logged once per code, not once per tick.
if (refusedThreads.get(t.thread) === code) return;
refusedThreads.set(t.thread, code);
log(`thread ${t.thread} (${t.instance}): start refused: ${code}`);
record({ event: "refused", by: b.human, instance: t.instance, thread: t.thread, code, why: e.message, ...(e.limit?{limit:e.limit}:{}) });
if (refusedThreads.get(key) === code) return;
refusedThreads.set(key, code);
log(`thread ${t.thread} session ${t.session} (${t.instance}): start refused: ${code}`);
record({ event: "refused", by: b.human, instance: t.instance, thread: t.thread, session: t.session, code, why: e.message, ...(e.limit?{limit:e.limit}:{}) });
}
});
}
@@ -849,20 +908,23 @@ export function createLauncher({
const transaction = out?.transaction ?? null;
if (transaction === null) return { refusal: "workspace-rename-failed" };
const bot = out.bot ? { bot: out.bot } : {};
const from = workspaceDir(dataRoot, b.id, r.instance);
if (!existsSync(from)) return { transaction, ...bot };
const to = join(dirname(from), ".retired", `${r.instance}-${transaction}`);
try {
mkdirSync(dirname(to), { recursive: true, mode: 0o700 });
chmodSync(dirname(to), 0o700);
if (existsSync(to)) throw new Error(`${to} exists`);
renameSync(from, to);
chmodSync(to, 0o700);
} catch (e) {
log(`request ${r.request} (${r.instance}): workspace rename failed: ${e.message}; left at ${from}`);
return { transaction, ...bot, refusal: "workspace-rename-failed", why: `${e.message}; left at ${from}` };
// The old instance workspace, then (row 269) the instance's session workspaces, each moved
// whole. A retry moves whichever is still in place.
for (const from of [workspaceDir(dataRoot, b.id, r.instance), join(dataRoot, "session-workspaces", b.id, r.instance)]) {
if (!existsSync(from)) continue;
const to = join(dirname(from), ".retired", `${r.instance}-${transaction}`);
try {
mkdirSync(dirname(to), { recursive: true, mode: 0o700 });
chmodSync(dirname(to), 0o700);
if (existsSync(to)) throw new Error(`${to} exists`);
renameSync(from, to);
chmodSync(to, 0o700);
} catch (e) {
log(`request ${r.request} (${r.instance}): workspace rename failed: ${e.message}; left at ${from}`);
return { transaction, ...bot, refusal: "workspace-rename-failed", why: `${e.message}; left at ${from}` };
}
record({ event: "workspace-retired", instance: r.instance, request: r.request, transaction, path: to });
}
record({ event: "workspace-retired", instance: r.instance, request: r.request, transaction, path: to });
return { transaction, ...bot };
}
@@ -958,10 +1020,15 @@ export function createLauncher({
process.kill(s.pid, "SIGKILL");
} catch {}
const end = Date.now() + 5000;
while (startTimeOf(s.pid) === s.startTime && Date.now() < end) await new Promise((r) => setTimeout(r, 20));
while (liveRun(s) && Date.now() < end) await new Promise((r) => setTimeout(r, 20));
}
// Row 269 (Q2): the session or its runner outlived the kill, or wasn't a session to kill.
if (liveRun(s)) {
survivors.push(s);
log(`run ${s.run} (${s.instance}) from an earlier host still runs (pid ${s.pid}, runner ${s.runnerPid}): no session of ${s.instance} starts until it exits`);
}
try {
await host.bindLaunch({ business: b.id, role: s.instance, run: s.run, harness: s.harness, pid: s.pid, startTime: s.startTime, ...(s.thread ? { thread: s.thread } : {}) });
await host.bindLaunch({ business: b.id, role: s.instance, run: s.run, harness: s.harness, pid: s.pid, startTime: s.startTime, ...(s.thread ? { thread: s.thread } : {}), ...(s.session ? { session: s.session } : {}) });
} catch (e) {
// A host that died after ending the run but before updating sessions.json.
if (e.code === "run-ended") log(`run ${s.run} (${s.instance}) from an earlier host was already ended`);
@@ -969,7 +1036,7 @@ export function createLauncher({
continue;
}
await endRun(s.run, "host-lost", null);
record({ event: "end", instance: s.instance, ...(s.thread ? { thread: s.thread } : {}), run: s.run, reason: "host-lost", exitCode: null, signal: null });
record({ event: "end", instance: s.instance, ...(s.thread ? { thread: s.thread } : {}), ...(s.session ? { session: s.session } : {}), run: s.run, reason: "host-lost", exitCode: null, signal: null });
log(`run ${s.run} (${s.instance}) from an earlier host ended: host-lost`);
}
leftover = [];
+18
View File
@@ -0,0 +1,18 @@
// A stand-in harness turn for the session launch tests (row 269, #1774).
// thread-adapter.mjs's turn, plus the run's cwd and the names in it, so a
// test reads from the reply which workspace the session saw and at what
// in-sandbox path. It keeps a history in $MOSAIC_SESSION_DIR, the session's
// store.
import { appendFileSync, existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const request = process.env.MOSAIC_REQUEST ?? "";
const body = request.match(/^Message [^\n]*:\n\n([\s\S]*)$/m)?.[1] ?? "";
const historyFile = join(process.env.MOSAIC_SESSION_DIR, "history");
const before = existsSync(historyFile) ? readFileSync(historyFile, "utf8").split("\n").filter(Boolean) : [];
const cwd = process.cwd();
const entries = readdirSync(cwd).sort();
appendFileSync(historyFile, `${body}\n`);
process.stdout.write(`THREAD ${JSON.stringify({ body, before, cwd, entries })}\n`);
writeFileSync(process.env.MOSAIC_TURN_MARKER, "done\n");
+414
View File
@@ -0,0 +1,414 @@
// Session runs (row 269, 264b, #1774): every session of a thread runs on its
// own, with its own store under threads/<b>/<t>/sessions/<s>/ and its own
// workspace under session-workspaces/<b>/<instance>/<s>/, and a lock beside
// the workspace keeps it to one writer. A legacy session (op legacy-3l) keeps
// the thread's old store, and its workspace shows inside the sandbox at the
// old instance path. Real runners in the bwrap sandbox; a stub adapter keeps
// a history in the store and answers with what it found, its cwd and what
// the cwd holds.
//
// The host is thread-launch.test.mjs's: startBroker on bootConfig's config,
// with the ops the broker child forwards, and the human capability from
// bindHuman directly.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { launchDoc } from "../../business/tests/helpers.mjs";
import { BusError } from "../../bus/src/broker.mjs";
import { startBroker } from "../../bus/src/runtime.mjs";
import { Store } from "../../bus/src/store.mjs";
import { transition } from "../../processing-control/src/state.mjs";
import { gitEnv } from "../../seat/src/checkout.mjs";
import { startTimeOf } from "../../seat/src/proc.mjs";
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
import { launchLogFile, readSessions, Registry, SESSION_CREDENTIALS } from "../../seat/src/session.mjs";
import { bootConfig, loadSystem } from "../src/config.mjs";
import { createLauncher } from "../src/launcher.mjs";
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
// A thread made by hand gets the default session the 3k-3l migration gives a legacy thread, with
// the thread's id (as in thread-launch.test.mjs).
const DEFAULT_SESSION = [
"INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) SELECT at,business,id,id,instance,owner,'default','legacy-3l' FROM threads WHERE id=?",
"INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,'migration-3l' FROM threads WHERE id=?",
];
const ADAPTER = fileURLToPath(new URL("./fixtures/session-adapter.mjs", import.meta.url));
const skip = sandboxProblem() ?? false;
async function until(fn, ms = 20000) {
const end = Date.now() + ms;
while (Date.now() < end) {
const v = await fn();
if (v) return v;
await new Promise((r) => setTimeout(r, 50));
}
throw new Error("timed out waiting");
}
// A mutant's kill is an assertion failure, not a timeout (sage-2707), so the waits a mutant could
// stall return what they last saw and the assertions after them decide.
async function settle(fn, ms = 20000) {
const end = Date.now() + ms;
let v;
while (Date.now() < end) {
v = await fn();
if (v) return v;
await new Promise((r) => setTimeout(r, 50));
}
return v;
}
function gitIn(cwd, ...args) {
const r = spawnSync("git", ["-c", "user.name=test", "-c", "[email protected]", "-C", cwd, ...args], { env: gitEnv(), encoding: "utf8" });
assert.equal(r.status, 0, r.stderr);
}
// Each directory made 0700, as the store and workspace checks want.
function privateDir(...parts) {
for (let i = 1; i <= parts.length; i++) {
const d = join(...parts.slice(0, i));
if (!existsSync(d)) mkdirSync(d, { mode: 0o700 });
if (i > 1) chmodSync(d, 0o700);
}
return join(...parts);
}
// The assistant is the thread instance; sonnet is its model's limit.
function prepare(t, { sonnet = 3, idle = 1 } = {}) {
const root = tmp(t, "mosaic-session-");
const f = fixture(root, "acme", (doc) => {
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
doc.roles.assistant = { definition: "assistant", vars: { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" } };
doc.launch = launchDoc("chief", ["coder", "assistant"], { opus: 1, sonnet });
doc.launch.threadIdleSeconds = idle;
return doc;
});
for (const k of SESSION_CREDENTIALS.flat()) delete f.env[k];
const project = f.doc.projects.stack.root;
gitIn(project, "init", "-q", "-b", "main");
writeFileSync(join(project, "file.txt"), "one\n");
gitIn(project, "add", "file.txt");
gitIn(project, "commit", "-q", "-m", "one");
const adapter = join(root, "adapter.sh");
writeFileSync(adapter, `exec '${process.execPath}' '${ADAPTER}'\n`, { mode: 0o644 });
return { root, f, adapter };
}
// A host and its launcher on the prepared root. `ops` replaces launch ops, given the runtime.
async function start(t, { f, adapter }, { tickMs = 100, ops: replace } = {}) {
const system = loadSystem({ env: f.env });
const { trackers, ...boot } = bootConfig({ system, businessId: "acme", env: f.env });
assert.equal(trackers, undefined);
const runtime = await startBroker(boot);
const ops = {
identity: (m) => runtime.identity(m.cap),
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
endLaunch: (m) => runtime.endLaunch(m.record),
credentialStatus: (m) => runtime.credentialStatus(m.business, m.role),
threadsState: (m) => runtime.threadsState(m.business),
threadIdle: (m) => runtime.threadIdle(m.record),
mayLaunch: (m) => runtime.mayLaunch(m),
landHeld: (m) => runtime.landHeld(m.record),
landHolders: (m) => runtime.landHolders(m.business),
landRelease: (m) => runtime.landRelease(m.record),
...replace?.(runtime),
};
const refusing = (what, fn) => async (m) => {
try {
return fn(m);
} catch (e) {
const code = e instanceof BusError ? e.code : `${what}-refused`;
throw Object.assign(new Error(`${what} refused: ${code}`), { code });
}
};
const hooks = [];
let closing = null;
const host = {
business: "acme",
path: runtime.path,
dataRoot: f.dataRoot,
op: refusing("launch-op", (m) => {
if (!Object.hasOwn(ops, m.op)) throw new BusError("invalid-host-request");
return ops[m.op](m);
}),
bindLaunch: refusing("launch bind", (r) => runtime.bindLaunch(r)),
beforeClose: (fn) => void hooks.push(fn),
};
const logs = [];
const launcher = createLauncher({
host,
system,
env: f.env,
makeLoader: await fakeHarnesses(t, { pi: adapter, "claude-code": adapter }),
sessionDefaults: { pollInterval: 100 },
harnessVersion: "0.85.1",
readOnly: [ADAPTER],
threadTickMs: tickMs,
log: (l) => logs.push(l),
});
const close = () =>
(closing ??= (async () => {
for (const fn of hooks) await fn(0);
await runtime.close();
})());
t.after(close);
await launcher.listen();
const human = runtime.broker.bindHuman({ business: "acme", human: f.doc.human, via: "cli", outsideAgent: true });
const call = (verb, args = {}) => runtime.broker.request(human, { verb, args });
return { host, runtime, launcher, call, logs, close };
}
const launchLog = (f) =>
existsSync(launchLogFile(f.dataRoot, "acme"))
? readFileSync(launchLogFile(f.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l))
: [];
const events = (f, event) => launchLog(f).filter((e) => e.event === event);
const sessionWs = (f, session) => join(f.dataRoot, "session-workspaces", "acme", "assistant", session);
// The refusals once there are n, or once anything launched.
const outcome = async (f, n = 1) => {
await settle(() => events(f, "refused").length >= n || events(f, "launch").length);
return events(f, "refused").map((e) => [e.session, e.code]);
};
const historyOf = (p) => (existsSync(p) ? readFileSync(p, "utf8") : null);
// Send to one session of a thread; reply() waits for the assistant's RESULT reply in that session.
function send(call, thread, session, body) {
const sent = call("thread.send", { thread, ...(session ? { session } : {}), body });
const reply = async () => {
const r = await until(() => call("thread.messages", { thread, ...(session ? { session } : {}) }).find((m) => m.in_reply_to === sent.id && m.class === "RESULT"));
assert.equal(r.from_role, "assistant");
return JSON.parse(r.body.match(/THREAD (\{.*\})/)[1]);
};
return { sent, reply };
}
// A thread made with thread.create, its default session and a second named one.
function twoSessions(call) {
const project = call("project.create", { name: "stack work", repo: "stack" }).id;
const A = call("thread.create", { project, name: "alpha" }).id;
const [{ id: D }] = call("session.list", { thread: A });
const S = call("session.create", { thread: A, name: "second", op: "op-second" }).id;
return { project, A, D, S };
}
test("REQ-THREAD-2 REQ-LAUNCH-1 two sessions of one thread, and another thread's default, run at once, each with its own store and workspace; a restart of one resumes only that one", { skip, timeout: 90000 }, async (t) => {
const prepared = prepare(t, { idle: 5 });
const { f } = prepared;
const host = await start(t, prepared);
const { project, A, D, S } = twoSessions(host.call);
const B = host.call("thread.create", { project, name: "beta" }).id;
const [{ id: BD }] = host.call("session.list", { thread: B });
const toD = send(host.call, A, D, "first for D");
const toS = send(host.call, A, S, "first for S");
const toB = send(host.call, B, null, "first for B");
// All three run together (M1, M2: a shared workspace would refuse the second start busy).
await settle(() => readSessions(f.dataRoot).length === 3 || events(f, "refused").length);
assert.deepEqual(events(f, "refused"), []);
assert.deepEqual(readSessions(f.dataRoot).map((s) => [s.thread, s.session]).sort(), [[A, D], [A, S], [B, BD]].sort());
const [sawD, sawS, sawB] = await Promise.all([toD.reply(), toS.reply(), toB.reply()]);
// Each session's history is its own (M5), and each runs in its own workspace at its own path (M8).
assert.deepEqual([sawD.body, sawD.before, sawD.cwd], ["first for D", [], sessionWs(f, D)]);
assert.deepEqual([sawS.body, sawS.before, sawS.cwd], ["first for S", [], sessionWs(f, S)]);
assert.deepEqual([sawB.body, sawB.before, sawB.cwd], ["first for B", [], sessionWs(f, BD)]);
const store = (thread, session) => join(f.dataRoot, "threads", "acme", thread, "sessions", session, "session", "history");
assert.deepEqual([historyOf(store(A, D)), historyOf(store(A, S)), historyOf(store(B, BD))], ["first for D\n", "first for S\n", "first for B\n"]);
for (const thread of [A, B]) assert.equal(existsSync(join(f.dataRoot, "threads", "acme", thread, "session")), false);
assert.equal(existsSync(join(f.dataRoot, "workspaces", "acme", "assistant")), false, "no thread session uses the old instance workspace");
for (const e of events(f, "launch")) assert.ok([D, S, BD].includes(e.session), JSON.stringify(e));
// All three stop when idle, and each lock goes with its run.
await until(() => events(f, "end").length === 3, 30000);
assert.deepEqual(readSessions(f.dataRoot), []);
for (const s of [D, S, BD]) assert.equal(existsSync(`${sessionWs(f, s)}.lock`), false, s);
// The named session's next message starts only that session (M9), which finds only its history.
const again = await send(host.call, A, S, "second for S").reply();
assert.deepEqual([again.before, again.cwd], [["first for S"], sessionWs(f, S)]);
assert.deepEqual(events(f, "launch").slice(3).map((e) => [e.thread, e.session]), [[A, S]]);
assert.equal(historyOf(store(A, D)), "first for D\n");
});
// D9 (M7): a thread from before 3l. Its legacy session resumes from the thread's old store, and
// its new workspace shows at the old instance path, which the transcripts recorded as the cwd.
// The old host workspace isn't mounted.
test("REQ-THREAD-2 REQ-LAUNCH-1 a legacy session resumes the thread's old store at the old in-sandbox cwd, in a workspace of its own", { skip, timeout: 60000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
const store = new Store(f.dataRoot);
const at = () => new Date().toISOString();
store.run("INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)", "p-hand", at(), "acme", "hand made", "stack", "jason");
store.run("INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)", "th-legacy", at(), "acme", "p-hand", "assistant", "jason", "old");
for (const sql of DEFAULT_SESSION) store.run(sql, "th-legacy");
store.close();
const old = privateDir(f.dataRoot, "threads", "acme", "th-legacy");
writeFileSync(join(privateDir(old, "session"), "history"), "old sentinel\n");
privateDir(old, "claude-projects");
writeFileSync(join(privateDir(f.dataRoot, "workspaces", "acme", "assistant"), "old-file"), "the 264a workspace\n");
const host = await start(t, prepared);
const saw = await send(host.call, "th-legacy", null, "after 3l").reply();
assert.deepEqual(saw.before, ["old sentinel"]);
assert.equal(saw.cwd, join(f.dataRoot, "workspaces", "acme", "assistant"));
assert.equal(saw.entries.includes("old-file"), false, "the old host workspace isn't mounted");
assert.equal(readFileSync(join(old, "session", "history"), "utf8"), "old sentinel\nafter 3l\n");
assert.equal(existsSync(join(old, "sessions")), false);
assert.ok(existsSync(sessionWs(f, "th-legacy")));
assert.deepEqual(readdirSync(join(f.dataRoot, "workspaces", "acme", "assistant")), ["old-file"]);
assert.deepEqual(events(f, "launch").map((e) => [e.thread, e.session]), [["th-legacy", "th-legacy"]]);
});
// T2, M3: another host's live lock on the session's workspace refuses the start before any
// checkout. Once that host is gone the lock is stale, and the next start takes it over.
test("REQ-THREAD-2 REQ-LAUNCH-1 a live lock on a session workspace refuses session-workspace-busy before any checkout; a stale one is taken over", { skip, timeout: 60000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
const host = await start(t, prepared);
const { A, S } = twoSessions(host.call);
const other = spawn("sleep", ["60"], { stdio: "ignore" });
const gone = new Promise((r) => other.once("exit", r));
t.after(() => other.kill("SIGKILL"));
const otherStart = await until(() => startTimeOf(other.pid));
privateDir(f.dataRoot, "session-workspaces", "acme", "assistant");
const lock = `${sessionWs(f, S)}.lock`;
const held = `${JSON.stringify({ hostPid: other.pid, hostStartTime: otherStart, business: "acme", instance: "assistant", thread: A, session: S })}\n`;
writeFileSync(lock, held, { mode: 0o600 });
const toS = send(host.call, A, S, "for S");
assert.deepEqual(await outcome(f), [[S, "session-workspace-busy"]]);
assert.equal(events(f, "refused")[0].thread, A);
assert.deepEqual(events(f, "launch"), []);
assert.deepEqual(readSessions(f.dataRoot), []);
assert.deepEqual(readdirSync(sessionWs(f, S)), [], "no checkout");
assert.equal(readFileSync(lock, "utf8"), held);
other.kill("SIGKILL");
await gone;
const saw = await toS.reply();
assert.deepEqual([saw.before, saw.cwd], [[], sessionWs(f, S)]);
await until(() => events(f, "end").length === 1);
assert.equal(existsSync(lock), false);
});
// Q2 (BUILD.md:177-179, t1-sessions.md:109): an earlier host's sessionless run of the instance is
// still live, so no session of it starts until that run has exited. A sleep child stands in for
// its runner; its outer pid is gone, so recover() has nothing to kill and keeps it as a survivor.
test("REQ-THREAD-2 REQ-LAUNCH-1 a session start refuses legacy-run-live while an earlier host's sessionless run lives, and starts once it has exited", { skip, timeout: 60000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
const runner = spawn("sleep", ["60"], { stdio: "ignore" });
const gone = new Promise((r) => runner.once("exit", r));
t.after(() => runner.kill("SIGKILL"));
const runnerStartTime = await until(() => startTimeOf(runner.pid));
new Registry(f.dataRoot).add({ business: "acme", instance: "assistant", thread: "th-old", run: "run-left", harness: "pi", pid: 2 ** 22 + 7, startTime: "gone", runnerPid: runner.pid, runnerStartTime });
const host = await start(t, prepared);
assert.ok(host.logs.includes(`run run-left (assistant) from an earlier host still runs (pid ${2 ** 22 + 7}, runner ${runner.pid}): no session of assistant starts until it exits`), host.logs.join("\n"));
const { A, S } = twoSessions(host.call);
const toS = send(host.call, A, S, "for S");
assert.deepEqual(await outcome(f), [[S, "legacy-run-live"]]);
assert.equal(events(f, "refused")[0].thread, A);
assert.deepEqual(events(f, "launch"), [], "nothing spawned");
assert.deepEqual(readSessions(f.dataRoot), []);
assert.equal(existsSync(sessionWs(f, S)), false, "refused before the workspace");
runner.kill("SIGKILL");
await gone;
assert.equal(await settle(() => events(f, "launch").length), 1, "it starts once the run has exited");
const saw = await toS.reply();
assert.deepEqual([saw.before, saw.cwd], [[], sessionWs(f, S)]);
await until(() => events(f, "end").length === 1);
});
// T2, C3: a symlinked session root refuses session-workspace-unsafe, at the segment check.
test("REQ-THREAD-2 REQ-LAUNCH-1 a symlinked session workspace root refuses session-workspace-unsafe and launches nothing", { skip, timeout: 60000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
const elsewhere = privateDir(prepared.root, "elsewhere");
symlinkSync(elsewhere, join(f.dataRoot, "session-workspaces"));
const host = await start(t, prepared);
const { A, D } = twoSessions(host.call);
send(host.call, A, D, "for D");
assert.deepEqual(await outcome(f), [[D, "session-workspace-unsafe"]]);
assert.deepEqual(events(f, "launch"), []);
assert.deepEqual(readdirSync(elsewhere), []);
});
// T2, M4: every segment of the session path is a real directory, but workspaces/<b> is a symlink
// to it, so only the realpath compare finds that the session workspace holds the legacy root.
test("REQ-THREAD-2 REQ-LAUNCH-1 a session workspace that holds the legacy workspace root refuses session-workspace-overlap and launches nothing", { skip, timeout: 60000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
const host = await start(t, prepared);
const { A, D } = twoSessions(host.call);
const ws = privateDir(f.dataRoot, "session-workspaces", "acme", "assistant", D);
symlinkSync(ws, join(privateDir(f.dataRoot, "workspaces"), "acme"));
send(host.call, A, D, "for D");
assert.deepEqual(await outcome(f), [[D, "session-workspace-overlap"]]);
assert.deepEqual(events(f, "launch"), []);
assert.deepEqual(readdirSync(ws), []);
});
// T2: a symlink among the store's segments refuses thread-store-refused before the workspace.
test("REQ-THREAD-2 REQ-LAUNCH-1 a symlinked session store refuses thread-store-refused and makes no workspace", { skip, timeout: 60000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
const host = await start(t, prepared);
const { A, D } = twoSessions(host.call);
const elsewhere = privateDir(prepared.root, "elsewhere");
symlinkSync(elsewhere, join(privateDir(f.dataRoot, "threads", "acme", A), "sessions"));
send(host.call, A, D, "for D");
assert.deepEqual(await outcome(f), [[D, "thread-store-refused"]]);
assert.match(events(f, "refused")[0].why, /is unsafe$/);
assert.deepEqual(events(f, "launch"), []);
assert.deepEqual(readdirSync(elsewhere), []);
assert.equal(existsSync(join(f.dataRoot, "session-workspaces")), false);
});
// T2, M6 and the halt fence (C2). The credentialStatus op runs in start() after check() returned.
// Its first call halts and resumes, a new generation with no halt, so only start()'s own
// admission(admissionGeneration) refuses, before the session workspace is made. On the retry,
// the recheck's mayLaunch op (inside check(), after the lock is taken) halts once it has answered,
// and the recheck's admission() refuses processing-halted. Neither attempt spawns, and the second releases its lock.
test("REQ-THREAD-2 REQ-LAUNCH-1 a named session's start refuses processing-generation-changed, then processing-halted, after check() and before any spawn", { skip, timeout: 60000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
let credentials = 0;
let halts = 0;
const halt = () => transition(f.dataRoot, { business: "acme", human: f.doc.human, halted: true });
const host = await start(t, prepared, {
ops: (runtime) => ({
credentialStatus: (m) => {
if (m.role === "assistant" && ++credentials === 1) {
transition(f.dataRoot, { business: "acme", human: f.doc.human, halted: false, generation: halt().generation });
halts++;
}
return runtime.credentialStatus(m.business, m.role);
},
// The halt lands after the op answers: halted inside it, the bus's own fence
// (runtime.mjs processingWork) throws and the launcher refuses limit-check-failed.
mayLaunch: async (m) => {
const may = await runtime.mayLaunch(m);
if (m.instance === "assistant" && credentials === 2 && halts === 1) {
halt();
halts++;
}
return may;
},
}),
});
const { A, S } = twoSessions(host.call);
send(host.call, A, S, "for S");
const refused = await outcome(f, 2);
assert.deepEqual(refused, [[S, "processing-generation-changed"], [S, "processing-halted"]]);
assert.deepEqual([credentials, halts], [2, 2], "check() passed both times");
assert.equal(events(f, "launch").length, 0);
assert.deepEqual(readSessions(f.dataRoot), []);
assert.equal(existsSync(join(f.dataRoot, "launches", "acme")), false, "no run directory");
assert.ok(existsSync(sessionWs(f, S)), "the retry made the workspace");
assert.equal(existsSync(`${sessionWs(f, S)}.lock`), false, "and released its lock");
});
+3 -2
View File
@@ -295,9 +295,10 @@ export const KINDS_OLD=KINDS_NEW.replace('\n harness: Object.freeze({','\n "ha
assert.notEqual(KINDS_OLD,KINDS_NEW);
// Row 200 (#1698): C1 and C take --sso and have the row 192 loader and checkConsole; C's checker
// refuses RS256, a stand-in for a target whose rules differ from the repository's. B has the loader
// from before row 192, which refuses a `console` key. The other copies have no loader.
// from before row 192, which refuses a `console` key. The other copies have no loader. Row 203: B's
// loader also refuses a `federation` key, as a pre-203 loader does (its list has no federation).
const LOADER=readFileSync(join(repo,'scripts','mosaic-config.mjs'),'utf8'),SSO_CONFIG=readFileSync(join(repo,'packages','webui','src','sso-config.mjs'),'utf8');
const LOADER_OLD=LOADER.replace('["configVersion", "environment", "dataRoot", "execution", "console"]','["configVersion", "environment", "dataRoot", "execution"]');
const LOADER_OLD=LOADER.replace('["configVersion", "environment", "dataRoot", "execution", "console", "federation"]','["configVersion", "environment", "dataRoot", "execution"]');
const SSO_CONFIG_C=SSO_CONFIG.replace("'ES256', 'RS256',","'ES256',");
assert.notEqual(LOADER_OLD,LOADER);assert.notEqual(SSO_CONFIG_C,SSO_CONFIG);
const ssoFiles=(id)=>id===B?[['scripts/mosaic-config.mjs',LOADER_OLD]]:[C1,C].includes(id)?[['scripts/mosaic-config.mjs',LOADER],['packages/webui/src/sso-config.mjs',id===C?SSO_CONFIG_C:SSO_CONFIG]]:[];
+15 -2
View File
@@ -858,7 +858,7 @@ const COUNTED=/^COUNTED="([^"]*)"$/m.exec(srText)[1].split(' ');
const STORE_JS=/^STORE_JS='([\s\S]*?)'$/m.exec(srText)[1];
// sage-1163's list 2, written out so that a table dropped from the script's COUNTED fails T45 as
// well as T46.
const LISTED=['role_claims','thread_claims','decision_events','messages','deliveries','task_snapshots','projects','threads','sessions','session_defaults','thread_opens','instance_names','usage_records'];
const LISTED=['role_claims','thread_claims','decision_events','messages','deliveries','task_snapshots','projects','threads','sessions','session_defaults','thread_opens','thread_shares','instance_names','usage_records'];
const LATE='2999-01-01T00:00:00.000Z';
const addRow=(file,table,at=LATE)=>{const db=new DatabaseSync(file);db.exec(`CREATE TABLE IF NOT EXISTS ${table} (seq INTEGER PRIMARY KEY, at TEXT NOT NULL)`);db.prepare(`INSERT INTO ${table} (at) VALUES (?)`).run(at);db.close()};
@@ -1468,7 +1468,7 @@ const SHOW=`show-${CON}.txt`;
const setOf=(e,act)=>join(e.state,'stack-rollback',act);
const arrivalSet=(e,to)=>setOf(e,logLines(e).findLast((l)=>l.result==='ok'&&l.to===to&&l.from!==to).activation);
const ROLLBACK_PLAN=(set)=>`sso: dropped; ${B}'s Console does not support --sso, so the rollback takes the Console flags from ${set}/${SHOW}, with --tester-port as the pointer has it`;
const KEY_MSG=(e,to,op,why)=>`${e.cfg} holds a console key, and ${to}'s loader refuses it (${why}); remove the key first (restore the config.json backup from the window's step 1), then run the ${op} again`;
const KEY_MSG=(e,to,op,why)=>`${e.cfg} holds a console or federation key, and ${to}'s loader refuses it (${why}); remove the key first (restore the config.json backup from the window's step 1), then run the ${op} again`;
const RB_MSG=(why)=>`${B}'s Console does not support --sso, and the Console flags it ran with cannot be read: ${why}`;
// Rewrites a file in a rollback set (0700, files kept by cp -p) and, unless sums is false, its SHA256SUMS line.
function setEdit(set,name,from,to,sums=true){
@@ -1582,6 +1582,19 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B);
await noLeak(e);
});
await t.test('row 203: a config whose federation key the target loader refuses stops an activate or a rollback before any stop, naming the first step',async(t)=>{
const e=await mk(t);await seedPreview(e);
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
// B's loader has no federation key in its list, as a pre-203 loader has none: it refuses the key.
const setFed=(fed)=>{const c=JSON.parse(readFileSync(e.cfg,'utf8'));if(fed===undefined)delete c.federation;else c.federation=fed;writeFileSync(e.cfg,JSON.stringify(c))};
setFed({self:'hub',peers:[{id:'factory',origin:'https://mosaic.woltje.com',label:'Software factory'}]});
const why='mosaic-config: unsupported configuration key: "federation"';
await refused(e,['activate',B,'acme'],'config-console-key',KEY_MSG(e,B,'activate',why));
await refused(e,['rollback','acme'],'config-console-key',KEY_MSG(e,B,'rollback',why));
setFed(undefined);
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B);
await noLeak(e);
});
await t.test('item 3: a rollback from --sso to a Console without it takes the flags from the set of the activation onto the copy, past a same-copy re-pin (sage-1389)',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']);await ssoConfig(e);
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
+2 -1
View File
@@ -179,10 +179,11 @@ test("REQ-THREAD-2 REQ-LAUNCH-1 REQ-MSG-1 a thread message starts the thread's s
assert.deepEqual(first.saw, { body: "first for A", before: [], transcripts: [] });
const [run1] = launchLog(f).filter((e) => e.event === "launch" && e.thread === A).map((e) => e.run);
assert.ok(run1);
const store = join(f.dataRoot, "threads", "acme", A);
const store = join(f.dataRoot, "threads", "acme", A, "sessions", host.runtime.threadsState("acme").find((x) => x.thread === A).session);
assert.equal(readFileSync(join(store, "session", "history"), "utf8"), "first for A\n");
assert.deepEqual(readdirSync(join(store, "claude-projects")), ["turn-1.jsonl"]);
assert.equal(existsSync(join(f.dataRoot, "launches", "acme", run1, "session")), false, "a thread run keeps no per-run session directory");
assert.equal(existsSync(join(f.dataRoot, "threads", "acme", A, "session")), false, "a thread.create session keeps no thread-level store");
await idleStop(f, host, A, run1);
// A second run of the same thread finds the first run's history.
+7 -4
View File
@@ -97,9 +97,12 @@ function systemBinds(node = process.execPath) {
// the harness module's mounts (row 65: its mounts(), [{ src, dest, why }],
// read-only), the broker socket, the launch socket (null for a role that may
// not launch), the git socket (null for a role that neither pushes nor
// reviews), the thread's session store (session.mjs threadStore(), or null)
// and node. `readOnly` adds files, for tests only.
export function binds({ runDir, workspace, adapter, harnessMounts = [], brokerSocket, launchSocket = null, gitSocket = null, threadStore = null, node = process.execPath, readOnly = [] }) {
// reviews), the thread's session store (session.mjs threadStore() or
// sessionStore(), or null) and node. workspaceDest (row 269) is where the
// workspace shows inside the sandbox when that isn't its host path: a legacy
// thread session's, so its harness finds the cwd its history recorded.
// `readOnly` adds files, for tests only.
export function binds({ runDir, workspace, workspaceDest = null, adapter, harnessMounts = [], brokerSocket, launchSocket = null, gitSocket = null, threadStore = null, node = process.execPath, readOnly = [] }) {
const out = systemBinds(node);
for (const f of RUNNER_FILES) out.push({ kind: "ro", src: f, why: "a repository file the runner loads" });
out.push({ kind: "ro", src: adapter, why: "the harness adapter the runner starts each turn" });
@@ -109,7 +112,7 @@ export function binds({ runDir, workspace, adapter, harnessMounts = [], brokerSo
if (gitSocket) out.push({ kind: "ro", src: gitSocket, why: "the host's git socket, for push_working and fetch_review, and land_work" });
for (const f of readOnly) out.push({ kind: "ro", src: f, why: "a test fixture" });
out.push(
{ kind: "rw", src: workspace, why: "the instance's workspace, where the harness works" },
{ kind: "rw", src: workspace, ...(workspaceDest && workspaceDest !== workspace ? { dest: workspaceDest } : {}), why: "the instance's workspace, where the harness works" },
{ kind: "rw", src: runDir, why: "the run directory: turns/, session/, tools.sock, turn.done, home/, tmp/" },
{ kind: "ro", src: join(runDir, "bundle"), why: "the run's bundle stays as the host wrote it" },
{ kind: "ro", src: join(runDir, "session.json"), why: "the session file stays as the host wrote it" },
+176 -17
View File
@@ -20,17 +20,22 @@
// every launch, refusal and end
// workspaces/<business>/<instance>/ the instance's workspace (0700), kept
// across launches
// threads/<business>/<thread>/ a thread's session store (0700), kept
// across the thread's runs (row 121):
// threads/<business>/<thread>/ a legacy thread session's store (0700),
// kept across its runs (row 121):
// session/ and claude-projects/
// threads/<business>/<thread>/sessions/<session>/
// any other session's store (row 269)
// session-workspaces/<business>/<instance>/<session>/
// a thread session's workspace (0700),
// with <session>.lock beside it
// bus-host/sessions.json the running sessions (0600), written
// only by the host; `mosaic stop` reads it
import { spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import { appendFileSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { appendFileSync, closeSync, existsSync, linkSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
import { constants } from "node:os";
import { dirname, join } from "node:path";
import { dirname, join, sep } from "node:path";
import { childrenOf, cmdlineOf, startTimeOf } from "./proc.mjs";
import { binds, prepareRun, REPO, sandboxArgv } from "./sandbox.mjs";
import { SeatError } from "./seat.mjs";
@@ -38,7 +43,10 @@ import { ID_PATTERN } from "../../business/src/vocabulary.mjs";
export { REPO };
export const RUNNER = join(REPO, "packages", "harness", "src", "runner.mjs");
export const SESSIONS_VERSION = 1;
// Row 269 (264b): 2 adds `session` and `workspace` to a thread run's entry.
// readSessions still reads a version 1 file an earlier host left.
export const SESSIONS_VERSION = 2;
const SESSIONS_READ = [1, 2];
export const sessionsFile = (dataRoot) => join(dataRoot, "bus-host", "sessions.json");
export const launchSocketPath = (dataRoot) => join(dataRoot, "bus-host", "launch.sock");
@@ -47,6 +55,7 @@ export const gitSocketPath = (dataRoot) => join(dataRoot, "bus-host", "git.sock"
export const runsDir = (dataRoot, business) => join(dataRoot, "launches", business);
export const launchLogFile = (dataRoot, business) => join(dataRoot, "launches", `${business}.jsonl`);
export const workspaceDir = (dataRoot, business, instance) => join(dataRoot, "workspaces", business, instance);
export const sessionWorkspaceDir = (dataRoot, business, instance, session) => join(dataRoot, "session-workspaces", business, instance, session);
// Row 121 (#1615): a thread's session store, which outlives each of the
// thread's runs so the next run resumes the harness session (REQ-THREAD-2).
@@ -56,24 +65,174 @@ export const workspaceDir = (dataRoot, business, instance) => join(dataRoot, "wo
// is the run's sessionDir; claude-projects/ is bound at the run's
// home/.claude/projects (sandbox.mjs binds()). Throws SeatError.
const segment = (x) => typeof x === "string" && ID_PATTERN.test(x) && !x.includes("..");
export function threadStore(dataRoot, business, thread) {
for (const [what, x] of [["business", business], ["thread", thread]]) {
if (!segment(x)) throw new SeatError(`thread store: ${what} ${JSON.stringify(x)} isn't an id`, 2);
const seatError = (message, code) => Object.assign(new SeatError(message, 2), { code });
function segments(what, pairs, code) {
for (const [name, x] of pairs) {
if (!segment(x)) throw seatError(`${what}: ${name} ${JSON.stringify(x)} isn't an id`, code);
}
const dir = join(dataRoot, "threads", business, thread);
const out = { dir, session: join(dir, "session"), projects: join(dir, "claude-projects") };
for (const d of [join(dataRoot, "threads"), join(dataRoot, "threads", business), dir, out.session, out.projects]) {
}
// Makes each directory in order, then checks it is ours, a real directory and private.
function privateDirs(what, dirs, code) {
for (const d of dirs) {
try {
mkdirSync(d, { mode: 0o700 });
} catch (e) {
if (e.code !== "EEXIST") throw e;
}
const st = lstatSync(d);
if (st.isSymbolicLink() || !st.isDirectory() || st.uid !== process.getuid() || st.mode & 0o077) throw new SeatError(`thread store: ${d} is unsafe`, 2);
if (st.isSymbolicLink() || !st.isDirectory() || st.uid !== process.getuid() || st.mode & 0o077) throw seatError(`${what}: ${d} is unsafe`, code);
}
}
export function threadStore(dataRoot, business, thread) {
segments("thread store", [["business", business], ["thread", thread]], "thread-store-refused");
const dir = join(dataRoot, "threads", business, thread);
const out = { dir, session: join(dir, "session"), projects: join(dir, "claude-projects") };
privateDirs("thread store", [join(dataRoot, "threads"), join(dataRoot, "threads", business), dir, out.session, out.projects], "thread-store-refused");
return out;
}
// Row 269 (264b, #1774): one session's store. A legacy session (op legacy-3l,
// whose id is its thread's) keeps the thread's store, so its harness resumes
// the history it has; any other session gets its own under sessions/, with
// the same checks on every segment and directory. Same shape as threadStore.
// legacyThread: the thread has a legacy session. When it has none, a
// threads/<b>/<t>/session left by a 264a run of the thread's default session
// refuses legacy-store-unmapped, so that history isn't silently left behind.
export function sessionStore(dataRoot, business, thread, session, { legacy = false, legacyThread = legacy } = {}) {
if (legacy) return threadStore(dataRoot, business, thread);
segments("thread store", [["business", business], ["thread", thread], ["session", session]], "thread-store-refused");
const threadDir = join(dataRoot, "threads", business, thread);
if (!legacyThread) {
let old = true;
try {
lstatSync(join(threadDir, "session"));
} catch (e) {
if (e.code !== "ENOENT") throw e;
old = false;
}
if (old) throw seatError(`thread store: ${join(threadDir, "session")} holds history no legacy session maps`, "legacy-store-unmapped");
}
const dir = join(threadDir, "sessions", session);
const out = { dir, session: join(dir, "session"), projects: join(dir, "claude-projects") };
privateDirs("thread store", [join(dataRoot, "threads"), join(dataRoot, "threads", business), threadDir, join(threadDir, "sessions"), dir, out.session, out.projects], "thread-store-refused");
return out;
}
const inside = (a, b) => a === b || a.startsWith(b + sep);
const realOrNull = (p) => {
try {
return realpathSync(p);
} catch (e) {
if (e.code === "ENOENT") return null;
throw e;
}
};
// Row 269: a thread session's workspace, session-workspaces/<b>/<i>/<s>. Each
// segment is an id and each directory ours, real and private
// (session-workspace-unsafe). Then the realpaths: it may not be inside, or
// contain, the legacy workspaces root, its business and instance directories
// or any entry of either (old instance, .retired and task workspaces), nor
// sit above or below a `live` workspace (session-workspace-overlap). The same
// path as a live one is left to the lock, which refuses session-workspace-busy.
export function sessionWorkspace(dataRoot, business, instance, session, { live = [] } = {}) {
segments("session workspace", [["business", business], ["instance", instance], ["session", session]], "session-workspace-unsafe");
const root = join(dataRoot, "session-workspaces");
const path = join(root, business, instance, session);
privateDirs("session workspace", [root, join(root, business), join(root, business, instance), path], "session-workspace-unsafe");
const real = realpathSync(path);
const legacy = join(dataRoot, "workspaces");
const named = [legacy, join(legacy, business), join(legacy, business, instance)];
const entries = (d) => {
try {
return readdirSync(d).map((x) => join(d, x));
} catch (e) {
if (e.code === "ENOENT" || e.code === "ENOTDIR") return [];
throw e;
}
};
for (const p of [...named, ...entries(legacy), ...entries(join(legacy, business))]) {
const other = realOrNull(p);
if (other && (inside(real, other) || inside(other, real))) throw seatError(`session workspace: ${path} overlaps the legacy workspace ${p}`, "session-workspace-overlap");
}
for (const p of live) {
const other = realOrNull(p);
if (other && other !== real && (inside(real, other) || inside(other, real))) throw seatError(`session workspace: ${path} overlaps the live workspace ${p}`, "session-workspace-overlap");
}
return path;
}
// Row 269: the single-writer lock on a session workspace, <workspace>.lock,
// beside it and outside every mount. It names the host (pid, start time) and,
// once spawned, the run's outer process. A lock whose host and run are both
// gone is stale and is taken over; any other refuses session-workspace-busy.
// Returns { file, update(fields), release() }.
const lockAlive = (pid, start) => Number.isSafeInteger(pid) && pid > 0 && start != null && startTimeOf(pid) === start;
export function lockSessionWorkspace(workspace, owner) {
const file = `${workspace}.lock`;
let held = { ...owner };
const write = (flag) => writeFileSync(file, `${JSON.stringify(held)}\n`, { mode: 0o600, flag });
const busy = (why) => seatError(`session workspace ${workspace} is busy: ${why}`, "session-workspace-busy");
for (let attempt = 0; attempt < 2; attempt++) {
try {
write("wx");
// One release per lock: a second unlink could take a lock another host has written since.
let released = false;
return {
file,
update(fields) {
if (released) return;
held = { ...held, ...fields };
write("w");
},
release() {
if (released) return;
released = true;
try {
unlinkSync(file);
} catch (e) {
if (e.code !== "ENOENT") throw e;
}
},
};
} catch (e) {
if (e.code !== "EEXIST") throw e;
}
let raw;
try {
raw = readFileSync(file, "utf8");
} catch (e) {
if (e.code === "ENOENT") continue;
throw e;
}
let other = null;
try {
other = JSON.parse(raw);
} catch {}
// An unreadable lock may be one being written, so it counts as held.
if (!other || typeof other !== "object") throw busy(`${file} is unreadable`);
if (lockAlive(other.hostPid, other.hostStartTime)) throw busy(`host pid ${other.hostPid} holds it`);
if (lockAlive(other.runPid, other.runStartTime)) throw busy(`run pid ${other.runPid} holds it`);
// Stale. Move it aside; if what moved isn't what was judged stale, another
// host took it over in between, so it goes back and this start refuses.
const aside = `${file}.stale-${process.pid}-${randomBytes(4).toString("hex")}`;
try {
renameSync(file, aside);
} catch (e) {
if (e.code === "ENOENT") continue;
throw e;
}
if (readFileSync(aside, "utf8") !== raw) {
try {
linkSync(aside, file);
} catch {}
unlinkSync(aside);
throw busy(`another host took ${file} over`);
}
unlinkSync(aside);
}
throw busy(`${file} could not be taken`);
}
// The session's environment is an allowlist: nothing else from the host's
// environment passes, so a founder credential variable (GITEA_TOKEN,
// GH_TOKEN, SSH_AUTH_SOCK, ...) never reaches a session (REQ-CRED-2).
@@ -212,10 +371,10 @@ export class Registry {
count(business, provider, model = undefined) {
return this.#sessions.filter((s) => s.business === business && s.provider === provider && (model === undefined || s.model === model)).length;
}
// A thread instance runs once per thread (row 121), so `thread` is part of
// the key; null is a session that holds no thread.
running(business, instance, thread = null) {
return this.#sessions.find((s) => s.business === business && s.instance === instance && (s.thread ?? null) === thread) ?? null;
// A thread instance runs once per thread session (row 121, row 269), so
// `thread` and `session` are part of the key; null is an entry without one.
running(business, instance, thread = null, session = null) {
return this.#sessions.find((s) => s.business === business && s.instance === instance && (s.thread ?? null) === thread && (s.session ?? null) === session) ?? null;
}
#write() {
if (this.#readOnly) return;
@@ -238,7 +397,7 @@ export function readSessions(dataRoot) {
} catch {
throw new SeatError(`sessions file is unreadable: ${file}`, 2);
}
if (doc?.sessionsVersion !== SESSIONS_VERSION || !Array.isArray(doc.sessions)) throw new SeatError(`sessions file is malformed: ${file}`, 2);
if (!SESSIONS_READ.includes(doc?.sessionsVersion) || !Array.isArray(doc.sessions)) throw new SeatError(`sessions file is malformed: ${file}`, 2);
return doc.sessions.map((s) => ({ ...s, live: startTimeOf(s.runnerPid) === s.runnerStartTime }));
}
@@ -0,0 +1,126 @@
// Row 269 (264b, #1774): a thread session's store, its workspace and the
// single-writer lock beside the workspace (session.mjs sessionStore(),
// sessionWorkspace(), lockSessionWorkspace()), and sessions.json v2.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { startTimeOf } from "../src/proc.mjs";
import { SeatError } from "../src/seat.mjs";
import { lockSessionWorkspace, readSessions, Registry, sessionsFile, sessionStore, sessionWorkspace, threadStore } from "../src/session.mjs";
function scratch(t) {
const dir = realpathSync(mkdtempSync(join(tmpdir(), "mosaic-session-ws-")));
t.after(() => rmSync(dir, { recursive: true, force: true }));
return dir;
}
const mode = (p) => statSync(p).mode & 0o777;
const refuses = (code) => (e) => e instanceof SeatError && e.code === code;
test("REQ-THREAD-2 sessionStore: a legacy session keeps the thread's store, any other gets sessions/<s>/, each segment checked first", (t) => {
const root = scratch(t);
assert.deepEqual(sessionStore(root, "acme", "th-1", "th-1", { legacy: true }), threadStore(root, "acme", "th-1"));
const s = sessionStore(root, "acme", "th-1", "s-2", { legacyThread: true });
const dir = join(root, "threads", "acme", "th-1", "sessions", "s-2");
assert.deepEqual(s, { dir, session: join(dir, "session"), projects: join(dir, "claude-projects") });
for (const d of [dirname(dir), dir, s.session, s.projects]) assert.equal(mode(d), 0o700, d);
for (const [thread, session] of [["..", "s"], ["th-1", ".."], ["th-1", "a/b"], ["th-1", null]])
assert.throws(() => sessionStore(root, "acme", thread, session, { legacyThread: true }), refuses("thread-store-refused"), `${thread}/${session}`);
assert.deepEqual(readdirSync(join(root, "threads", "acme", "th-1", "sessions")), ["s-2"], "a refused call makes nothing");
});
test("REQ-THREAD-2 sessionStore refuses legacy-store-unmapped for a thread whose old store no legacy session maps", (t) => {
const root = scratch(t);
threadStore(root, "acme", "th-1");
assert.throws(() => sessionStore(root, "acme", "th-1", "s-1"), (e) => refuses("legacy-store-unmapped")(e) && /holds history no legacy session maps$/.test(e.message));
assert.equal(existsSync(join(root, "threads", "acme", "th-1", "sessions")), false);
// A thread with a legacy session: its other sessions take D2's layout.
assert.ok(sessionStore(root, "acme", "th-1", "s-1", { legacyThread: true }).dir.endsWith(join("sessions", "s-1")));
const elsewhere = join(root, "elsewhere");
mkdirSync(elsewhere, { mode: 0o700 });
symlinkSync(elsewhere, join(root, "threads", "acme", "th-2"));
assert.throws(() => sessionStore(root, "acme", "th-2", "s-1"), (e) => refuses("thread-store-refused")(e) && /is unsafe$/.test(e.message));
assert.deepEqual(readdirSync(elsewhere), []);
});
test("REQ-THREAD-2 sessionWorkspace: private directories per session, unsafe segments and overlaps refused", (t) => {
const root = scratch(t);
const ws = sessionWorkspace(root, "acme", "assistant", "s-1");
assert.equal(ws, join(root, "session-workspaces", "acme", "assistant", "s-1"));
for (const d of [join(root, "session-workspaces"), dirname(dirname(ws)), dirname(ws), ws]) assert.equal(mode(d), 0o700, d);
assert.equal(sessionWorkspace(root, "acme", "assistant", "s-1"), ws, "a second call reuses it");
assert.notEqual(sessionWorkspace(root, "acme", "assistant", "s-2"), ws);
for (const session of ["..", "a/b", ""]) assert.throws(() => sessionWorkspace(root, "acme", "assistant", session), refuses("session-workspace-unsafe"), session);
// A live workspace at the same path is the lock's to refuse; one above or below it overlaps.
assert.equal(sessionWorkspace(root, "acme", "assistant", "s-1", { live: [ws] }), ws);
assert.throws(() => sessionWorkspace(root, "acme", "assistant", "s-3", { live: [dirname(ws)] }), refuses("session-workspace-overlap"));
// The legacy root symlinked into a session path: every segment is real, only the realpath finds it.
mkdirSync(join(root, "workspaces"), { mode: 0o700 });
symlinkSync(ws, join(root, "workspaces", "acme"));
assert.throws(() => sessionWorkspace(root, "acme", "assistant", "s-1"), refuses("session-workspace-overlap"));
// A symlinked root fails the segment check first.
const other = scratch(t);
const elsewhere = join(other, "elsewhere");
mkdirSync(elsewhere, { mode: 0o700 });
symlinkSync(elsewhere, join(other, "session-workspaces"));
assert.throws(() => sessionWorkspace(other, "acme", "assistant", "s-1"), refuses("session-workspace-unsafe"));
assert.deepEqual(readdirSync(elsewhere), []);
});
test("REQ-THREAD-2 lockSessionWorkspace: one writer; a live host or run holds it, a stale lock is taken over, an unreadable one holds", async (t) => {
const root = scratch(t);
const ws = sessionWorkspace(root, "acme", "assistant", "s-1");
const me = { hostPid: process.pid, hostStartTime: startTimeOf(process.pid) };
const lock = lockSessionWorkspace(ws, me);
assert.equal(lock.file, `${ws}.lock`);
assert.equal(mode(lock.file), 0o600);
assert.throws(() => lockSessionWorkspace(ws, me), refuses("session-workspace-busy"));
lock.update({ run: "r-1", runPid: 1, runStartTime: "x" });
assert.deepEqual(JSON.parse(readFileSync(lock.file, "utf8")), { ...me, run: "r-1", runPid: 1, runStartTime: "x" });
lock.release();
assert.equal(existsSync(lock.file), false);
// A run that outlives its host still holds it.
const run = spawn("sleep", ["60"], { stdio: "ignore" });
const gone = new Promise((r) => run.once("exit", r));
t.after(() => run.kill("SIGKILL"));
let runStart = null;
for (let i = 0; i < 100 && !runStart; i++) {
runStart = startTimeOf(run.pid);
if (!runStart) await new Promise((r) => setTimeout(r, 20));
}
const held = `${JSON.stringify({ hostPid: 2 ** 22 + 7, hostStartTime: "gone", runPid: run.pid, runStartTime: runStart })}\n`;
writeFileSync(`${ws}.lock`, held, { mode: 0o600 });
assert.throws(() => lockSessionWorkspace(ws, me), refuses("session-workspace-busy"));
assert.equal(readFileSync(`${ws}.lock`, "utf8"), held);
run.kill("SIGKILL");
await gone;
// Now stale: taken over, and nothing is left beside it.
const taken = lockSessionWorkspace(ws, me);
assert.deepEqual(JSON.parse(readFileSync(taken.file, "utf8")), me);
assert.deepEqual(readdirSync(dirname(ws)).sort(), ["s-1", "s-1.lock"]);
// One release per lock: a second doesn't remove a lock written since.
taken.release();
writeFileSync(`${ws}.lock`, "{}\n", { mode: 0o600 });
taken.release();
assert.equal(readFileSync(`${ws}.lock`, "utf8"), "{}\n");
// An unreadable lock may be one being written, so it holds.
writeFileSync(`${ws}.lock`, "{", { mode: 0o600 });
assert.throws(() => lockSessionWorkspace(ws, me), refuses("session-workspace-busy"));
});
test("REQ-THREAD-2 sessions.json v2: entries carry session and workspace, running() keys on the session, a v1 file still reads", (t) => {
const root = scratch(t);
const registry = new Registry(root);
const entry = { business: "acme", instance: "assistant", thread: "th-1", session: "s-1", workspace: "/w", run: "r-1" };
registry.add(entry);
assert.equal(JSON.parse(readFileSync(sessionsFile(root), "utf8")).sessionsVersion, 2);
assert.deepEqual(registry.running("acme", "assistant", "th-1", "s-1"), entry);
assert.equal(registry.running("acme", "assistant", "th-1", "s-2"), null);
assert.equal(registry.running("acme", "assistant", "th-1"), null, "an entry with a session isn't the thread's sessionless run");
writeFileSync(sessionsFile(root), `${JSON.stringify({ sessionsVersion: 1, sessions: [{ business: "acme", instance: "assistant", thread: "th-1", run: "r-0" }] })}\n`);
assert.deepEqual(readSessions(root).map((s) => [s.run, s.session ?? null]), [["r-0", null]]);
});
+4 -4
View File
@@ -1,6 +1,6 @@
{
"prd": "docs/prd/mosaic-stack.md",
"version": "1.14",
"version": "1.15",
"requirements": [
{ "id": "REQ-ROLE-1", "title": "Each role is a reviewed roles/<name>.json: contract, authority map, credentials" },
{ "id": "REQ-ROLE-2", "title": "One holder per role instance, one per assistant session; the chief of staff may end a run, others need a decision" },
@@ -77,12 +77,12 @@
{ "id": "REQ-SESS-1", "title": "Each session shows its lifetime count of completed compactions on pi and Claude Code; an unproven count shows unknown" },
{ "id": "REQ-SESS-2", "title": "Clear context retires a session after a safe stop and starts a fresh successor with none of the old context" },
{ "id": "REQ-SESS-3", "title": "Every session start passes the launch checks: authority, route, revocation, kill switch, limits and admission" },
{ "id": "REQ-LIB-1", "title": "A library maps the user's writing repository onto universes, books, types and playgrounds; only the human changes it" },
{ "id": "REQ-LIB-2", "title": "Scope comes from the path; lookups default to canon and a playground file is never cited as canon" },
{ "id": "REQ-LIB-1", "title": "A library maps the user's repository onto universes, books, types, playgrounds and supporting folders, set by a human" },
{ "id": "REQ-LIB-2", "title": "Scope comes from the path, lookups default to canon, and a playground or supporting file is never cited as canon" },
{ "id": "REQ-LIB-3", "title": "Universes stay separate; a crossover is a hybrid universe only a human creates, reading its sources' canon" },
{ "id": "REQ-LIB-4", "title": "Git holds every change; the bus fast-forwards a read-only mirror every 60 seconds and agents read only that" },
{ "id": "REQ-LIB-5", "title": "Agents check the library through typed lookup tools that name the commit and show links out and in" },
{ "id": "REQ-LIB-6", "title": "Creative agents can't write the repository; messages to the user are cited items the broker checks, or a fixed notice" },
{ "id": "REQ-LIB-6", "title": "Agents never write story, only docs in supporting folders a human marks. Chat is free text, and any citation is checked" },
{ "id": "REQ-LIB-7", "title": "One chat room with several creative agents, addressed by leading @tags; an unknown tag sends nothing" },
{ "id": "REQ-INST-1", "title": "The installer offers optional components: pinned, health-checked, removable, switched on in config; first is SearXNG" },
{ "id": "REQ-INST-2", "title": "With no OIDC provider to configure, the installer deploys a bundled Pocket ID, Q203 (post-v1)" },
+1 -1
View File
@@ -21,7 +21,7 @@ test("the list holds the PRD's requirement ids, in its order, at its version", (
assert.equal(ids.length, 85);
assert.deepEqual(list.requirements.map((r) => r.id), ids);
assert.match(prd, new RegExp(`^- Status: approved, version ${list.version.replace('.', '\\.')}\\.`, 'm'));
assert.deepEqual([list.prd, list.version], ['docs/prd/mosaic-stack.md', '1.14']);
assert.deepEqual([list.prd, list.version], ['docs/prd/mosaic-stack.md', '1.15']);
});
test('the committed list loads frozen', () => {
+58 -1
View File
@@ -237,13 +237,66 @@ async function validateConsole(value) {
}
}
// FED R2 (#1701, DESIGN.md section 3.1 D1): the optional `federation` key names this instance and its
// peers. Ids and origins are labels, not trust: nothing here grants a peer anything (3.1, W1). The
// origin must be exactly an https origin, the form `new URL(origin).origin` returns, so a path, a
// trailing slash, a port that is the default, or userinfo refuses.
const FEDERATION_ID_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
const FEDERATION_LABEL_MAX = 80;
function validateFederationOrigin(value, where) {
let url = null;
try {
url = new URL(value);
} catch {
url = null;
}
if (url === null || url.protocol !== "https:" || url.origin !== value) {
fail(2, `${where} must be an https origin with no path, query, or userinfo (got ${JSON.stringify(value)})`);
}
}
function validateFederation(value) {
if (!isPlainObject(value)) {
fail(2, '"federation" must be a JSON object');
}
rejectUnknownKeys(value, ["self", "peers"], "federation");
if (typeof value.self !== "string" || !FEDERATION_ID_PATTERN.test(value.self)) {
fail(2, `federation.self must match ${FEDERATION_ID_PATTERN} (got ${JSON.stringify(value.self)})`);
}
if (!Array.isArray(value.peers)) {
fail(2, 'federation.peers must be an array');
}
const ids = new Set([value.self]);
const peers = value.peers.map((peer, index) => {
const where = `federation.peers[${index}]`;
if (!isPlainObject(peer)) {
fail(2, `${where} must be a JSON object`);
}
rejectUnknownKeys(peer, ["id", "origin", "label"], where);
if (typeof peer.id !== "string" || !FEDERATION_ID_PATTERN.test(peer.id)) {
fail(2, `${where}.id must match ${FEDERATION_ID_PATTERN} (got ${JSON.stringify(peer.id)})`);
}
if (ids.has(peer.id)) {
fail(2, `${where}.id ${JSON.stringify(peer.id)} is already named (by federation.self or an earlier peer)`);
}
ids.add(peer.id);
validateFederationOrigin(peer.origin, `${where}.origin`);
if (typeof peer.label !== "string" || peer.label.trim() === "" || peer.label.length > FEDERATION_LABEL_MAX) {
fail(2, `${where}.label must be a non-empty string of at most ${FEDERATION_LABEL_MAX} characters`);
}
return { id: peer.id, origin: peer.origin, label: peer.label };
});
return { self: value.self, peers };
}
async function validate(document, file) {
if (!isPlainObject(document)) {
fail(2, "configuration must be a JSON object");
}
rejectUnknownKeys(
document,
["configVersion", "environment", "dataRoot", "execution", "console"],
["configVersion", "environment", "dataRoot", "execution", "console", "federation"],
"configuration",
);
@@ -281,6 +334,9 @@ async function validate(document, file) {
// it, so a config without it prints what it always printed.
const consoleKey = Object.hasOwn(document, "console") ? await validateConsole(document.console) : undefined;
// FED R2: the same rule as `console`. The key appears in the output only when the file has it.
const federationKey = Object.hasOwn(document, "federation") ? validateFederation(document.federation) : undefined;
return {
configVersion: document.configVersion,
environment: document.environment,
@@ -292,6 +348,7 @@ async function validate(document, file) {
adapter,
},
...(consoleKey === undefined ? {} : { console: consoleKey }),
...(federationKey === undefined ? {} : { federation: federationKey }),
};
}
+4 -4
View File
@@ -417,7 +417,7 @@ plugin_checks() {
# Row 181: the tables a bus writer fills without an event in the same transaction, in schema
# order. A restore drops their rows after STAMP as it drops events, so the guard counts them too.
# BUILD.md for row 181 names each writer; the T46 guard classifies every table in the schema.
COUNTED="role_claims thread_claims decision_events messages deliveries task_snapshots projects threads sessions session_defaults thread_opens instance_names usage_records"
COUNTED="role_claims thread_claims decision_events messages deliveries task_snapshots projects threads sessions session_defaults thread_opens thread_shares instance_names usage_records"
STORE_JS='
const {DatabaseSync}=require("node:sqlite");const {pathToFileURL}=require("node:url");
@@ -1078,12 +1078,12 @@ slot_problem() {
# target's own scripts/mosaic-config.mjs validate; its output, the resolved config, is not kept.
config_checks() {
local cfg="${MOSAIC_CONFIG:-$HOME/.config/mosaic-dev/config.json}" out loader="$COPIES/$TO/scripts/mosaic-config.mjs" first
# 0: a console key; 1: no key, or no file; 2: the file doesn't parse, so whether it holds the key
# 0: a console or federation key (row 203: a pre-203 loader refuses federation too); 1: neither, or no file; 2: the file doesn't parse, so whether it holds the key
# is unknown, and the run refuses rather than pass it (webby-0028 N2). load_config already exits on
# such a file before anything is read or stopped, so this is reached only if the file changes after it.
local key=0
node -e 'const fs=require("fs"),f=process.argv[1];if(!fs.existsSync(f))process.exit(1);let d;try{d=JSON.parse(fs.readFileSync(f,"utf8"))}catch{process.exit(2)}
process.exit(d!==null&&typeof d==="object"&&Object.hasOwn(d,"console")?0:1)' -- "$cfg" 2>/dev/null || key=$?
process.exit(d!==null&&typeof d==="object"&&(Object.hasOwn(d,"console")||Object.hasOwn(d,"federation"))?0:1)' -- "$cfg" 2>/dev/null || key=$?
[ "$key" != 1 ] || return 0
if [ "$key" != 0 ]; then
check_refuse config-console-key "$cfg cannot be read as JSON, so whether $TO's loader reads it is unknown; fix the config first, then run the $OP again"
@@ -1093,7 +1093,7 @@ process.exit(d!==null&&typeof d==="object"&&Object.hasOwn(d,"console")?0:1)' --
elif out="$(MOSAIC_CONFIG="$cfg" node "$loader" validate 2>&1 >/dev/null)"; then return 0
fi
first="${out%%$'\n'*}"
check_refuse config-console-key "$cfg holds a console key, and $TO's loader refuses it ($first); remove the key first (restore the config.json backup from the window's step 1), then run the $OP again"
check_refuse config-console-key "$cfg holds a console or federation key, and $TO's loader refuses it ($first); remove the key first (restore the config.json backup from the window's step 1), then run the $OP again"
}
# Row 189 (mos-0972): --no-send drops --send from RUN_CONSOLE, so the unit and the pointer both lose
+66
View File
@@ -191,6 +191,72 @@ expect_refusal "console.sso slot name outside the pattern exits 2 naming the pat
expect_refusal "unknown key under console exits 2 naming the path" \
"$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider)"']},"extra":1}')" 'console.extra is not a supported key'
# --- FED R2 (#1701, DESIGN.md section 3.1): the optional federation key ---
# fed_body FEDERATION-JSON: a config whose federation key is the argument.
fed_body() {
printf '{"configVersion":1,"environment":"development","dataRoot":"%s","execution":{"backend":"docker","provider":"zai","model":"m"},"federation":%s}' "$DATA_ROOT" "$1"
}
FED_VALID='{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com","label":"Software factory"}]}'
cfg fed-valid.json "$(fed_body "$FED_VALID")"
OUT="$(MOSAIC_CONFIG="$SANDBOX/fed-valid.json" timeout 20 $CONFIG_OP validate)"
[ $? -eq 0 ] && printf '%s' "$OUT" | grep -q '"self": "hub"' && printf '%s' "$OUT" | grep -q '"origin": "https://mosaic.woltje.com"'
check "valid federation key loads and is in the output" $?
# expect_fed_refusal NAME PATH-IN-MESSAGE FEDERATION-JSON: exit 2, and the message names the bad key path.
expect_fed_refusal() {
cfg fed-bad.json "$(fed_body "$3")"
local err rc
err="$(MOSAIC_CONFIG="$SANDBOX/fed-bad.json" timeout 20 $CONFIG_OP validate 2>&1 >/dev/null)"
rc=$?
[ "$rc" -eq 2 ] && printf '%s' "$err" | grep -qF -- "$2"
check "$1 (exit $rc)" $?
}
expect_fed_refusal "federation unknown sub-key exits 2 naming the key" 'unsupported federation key: "extra"' \
'{"self":"hub","peers":[],"extra":1}'
expect_fed_refusal "federation peer unknown sub-key exits 2 naming the key" 'unsupported federation.peers[0] key: "trust"' \
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com","label":"F","trust":"full"}]}'
expect_fed_refusal "federation peer origin over http exits 2 naming the key" "federation.peers[0].origin" \
'{"self":"hub","peers":[{"id":"factory","origin":"http://mosaic.woltje.com","label":"F"}]}'
expect_fed_refusal "federation peer origin with a path exits 2 naming the key" "federation.peers[0].origin" \
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com/console","label":"F"}]}'
expect_fed_refusal "federation peer origin with userinfo exits 2 naming the key" "federation.peers[0].origin" \
'{"self":"hub","peers":[{"id":"factory","origin":"https://[email protected]","label":"F"}]}'
expect_fed_refusal "federation peer id equal to self exits 2 naming the key" "federation.peers[0].id" \
'{"self":"hub","peers":[{"id":"hub","origin":"https://mosaic.woltje.com","label":"F"}]}'
expect_fed_refusal "federation peer label empty exits 2 naming the key" "federation.peers[0].label" \
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com","label":""}]}'
expect_fed_refusal "federation not an object exits 2 naming the key" '"federation" must be a JSON object' '"hub"'
expect_fed_refusal "federation self pattern exits 2 naming the key" "federation.self must match" \
'{"self":"Hub","peers":[]}'
expect_fed_refusal "federation peers not an array exits 2 naming the key" "federation.peers must be an array" \
'{"self":"hub","peers":{}}'
expect_fed_refusal "federation peer not an object exits 2 naming the key" "federation.peers[0] must be a JSON object" \
'{"self":"hub","peers":["factory"]}'
expect_fed_refusal "federation peer id pattern exits 2 naming the key" "federation.peers[0].id must match" \
'{"self":"hub","peers":[{"id":"Factory","origin":"https://mosaic.woltje.com","label":"F"}]}'
FED_LONG_LABEL="$(printf 'x%.0s' $(seq 81))"
expect_fed_refusal "federation peer label over 80 characters exits 2 naming the key" "federation.peers[0].label" \
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com","label":"'"$FED_LONG_LABEL"'"}]}'
expect_fed_refusal "federation duplicate peer id exits 2 naming the key" 'federation.peers[1].id "factory" is already named' \
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com","label":"F"},{"id":"factory","origin":"https://other.woltje.com","label":"G"}]}'
expect_fed_refusal "federation peer origin with a trailing slash exits 2 naming the key" "federation.peers[0].origin" \
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com/","label":"F"}]}'
# A missing value: the pattern test coerces undefined to "undefined", which matches, so the typeof half is what refuses.
expect_fed_refusal "federation self missing exits 2 naming the key" "federation.self must match" \
'{"peers":[]}'
expect_fed_refusal "federation peer id missing exits 2 naming the key" "federation.peers[0].id must match" \
'{"self":"hub","peers":[{"origin":"https://mosaic.woltje.com","label":"F"}]}'
expect_fed_refusal "federation peer label missing exits 2 naming the key" "federation.peers[0].label must be" \
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com"}]}'
# Without the key: no federation in the output, and the config still loads (FED R2 boundary).
cfg no-federation.json '{"configVersion":1,"environment":"development","dataRoot":"'$DATA_ROOT'","execution":{"backend":"docker","provider":"zai","model":"m"}}'
NO_FED_OUT="$(MOSAIC_CONFIG="$SANDBOX/no-federation.json" timeout 20 $CONFIG_OP validate)"
NO_FED_RC=$?
[ "$NO_FED_RC" -eq 0 ] && ! printf '%s' "$NO_FED_OUT" | grep -q '"federation"'
check "config without federation loads and has no federation output" $?
# Without the key: the output is exactly what the loader printed before phase 1 (sage-1340, F2).
cfg no-console.json '{"configVersion":1,"environment":"development","dataRoot":"'$DATA_ROOT'","execution":{"backend":"docker","provider":"zai","model":"m"}}'
EXPECTED="$(printf '{\n "configVersion": 1,\n "environment": "development",\n "dataRoot": "%s",\n "execution": {\n "backend": "docker",\n "provider": "zai",\n "model": "m",\n "adapter": "pi"\n }\n}' "$DATA_ROOT")"