Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5428c1a87 | ||
|
|
8d34a9e948 | ||
|
|
e704a18aec | ||
|
|
f2735bb6a1 | ||
|
|
0cbe2e9b35 | ||
|
|
9538e0fc86 | ||
|
|
1a228f395f | ||
|
|
8295622600 | ||
|
|
ac0e4dc9bc | ||
|
|
c49fdb2692 | ||
|
|
cfb86f1bd7 | ||
|
|
178eefd5c4 | ||
|
|
8a29f94771 | ||
|
|
d78a9cc83c | ||
|
|
17f4157920 | ||
|
|
647424442f | ||
|
|
71fed6281c | ||
|
|
86a665bdf0 | ||
|
|
efcc9ea8dd | ||
|
|
f39508c24f | ||
|
|
d20ef317fc | ||
|
|
6e50bd6304 | ||
|
|
6c80b12904 | ||
|
|
42fd3edd45 | ||
|
|
dc5836c7df | ||
|
|
2133c5a0c6 | ||
|
|
194dec83d6 | ||
|
|
84b32e3903 | ||
|
|
9d241fdcc2 | ||
|
|
76a6524cfe | ||
|
|
693f7e745e | ||
|
|
94c33a43c0 | ||
|
|
a870fd85bb | ||
|
|
5b1f1dc885 | ||
|
|
8c638b6f66 | ||
|
|
a700992cd4 | ||
|
|
d30f35e292 | ||
|
|
e5b7d1d083 | ||
|
|
836b2f25ca | ||
|
|
73516da853 | ||
|
|
95e3f71661 | ||
|
|
cde0cd0832 | ||
|
|
100f018af3 | ||
|
|
4a8215e3a1 | ||
|
|
669e680368 | ||
|
|
c06ccc9ba4 | ||
|
|
136e5c1ca3 | ||
|
|
cf17ab387b | ||
|
|
09948da187 | ||
|
|
35615b01b7 | ||
|
|
3f1f4e44fa | ||
|
|
65b8c8a9bd | ||
|
|
271cef205c | ||
|
|
3e5db651b7 | ||
|
|
e1c9c09935 | ||
|
|
aaaf45576a | ||
|
|
cf6abca9c0 |
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+104
-32
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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'), []);
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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]]:[];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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]]);
|
||||
});
|
||||
@@ -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)" },
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")"
|
||||
|
||||
Reference in new issue
Block a user