Compare commits

..
Author SHA1 Message Date
Herb a5b6367503 fix(proof): match network names and preflight Docker watchers 2026-10-21 10:17:16 -05:00
jason.woltje b8461266cd test: clean proof temp roots and catch preparation signals (#1695) 2026-10-21 03:29:20 -05:00
Herb 8ae9d8acd9 test: add row 197 proof evidence driver for review (#1695) 2026-10-21 03:11:34 -05:00
Herb 9aa6d52d94 test: merge approved gate lanes onto row 238 (#1695) 2026-10-21 02:29:19 -05:00
jason.woltje e7078e6a85 deferred: Webby's row 238 N1, the sso-config first-step hint has no business (#1744) 2026-10-21 02:27:43 -05:00
jason.woltje d49b1f2bd3 row 238: stack-release's sso-config first step carries --instance NAME on a named instance (#1744)
Lands Launchpad's round 3 candidate, manifest 1bf831f3 (4c90099ea on launchpad/row238-r4, base
c515273db), approved by Webby (29956, rev 2717). Three paths: scripts/stack-release.sh, packages/cli/tests/
stack-release.test.mjs and docs/TOOLS.md. It is row 200's follow-up N1. Round 3 only merges docs/TOOLS.md
onto the row 240 landing: the landed blob plus the one hunk at :599. The 3-path patch-id, b9d0bb0ee3c7,
is the same in all three rounds, and stack-release.sh stays 100755.

Evidence: Launchpad's slot run on round 2, 2afbfbb01: stack-release.test.mjs 196/0 (sage-1794). Sage's
combined gate with row 228 on c515273db plus both rows' paths (07:11:30Z to 07:23:34Z): packages/seat 165 pass, 0 fail; packages/cli 640 pass, 0 fail; all ten scripts/test-*.sh 585 pass, 0 fail (out/, run.log). Row 238's mutant, the sso-config first step dropping --instance NAME, was killed (2 fails), survived: none, and stack-release.sh was restored to its blob.
`queue review verify-commit 238` matches the manifest on all 3 paths. Round 1's N1 stays on the #1744
carry list.
2026-10-21 02:24:25 -05:00
jason.woltje 86249ac552 row 228: ZAI_API_KEY in the session env allowlist (#1729)
Lands Researcher's round 3 candidate, manifest 82cf98c6 (cf18c42b9 on researcher/row228-r3, base
c771a36f4), approved by Webby (29949, rev 2708). Three paths: packages/seat/src/session.mjs (ZAI_API_KEY
in ENV_ALLOW), packages/seat/tests/session.test.mjs (the pass-through test) and packages/cli/README.md
(the line, with the round 1 and round 2 N1 trims at :711 and :718). Round 3 is the round 2 rebase onto
the row 234 landing; the two seat files carry the blobs approved in rounds 1 and 2.

Evidence: Researcher's slot run on cf18c42b9: session.test.mjs 13/0, host.test.mjs 42/0 (sage-1796).
Round 2's seat and cli runs, 94/0, and its killed mutant stand, since the seat blobs are unchanged.
Sage's combined gate with row 238 on c515273db plus both rows' paths (07:11:30Z to 07:23:34Z):
packages/seat 165 pass, 0 fail; packages/cli 640 pass, 0 fail; all ten scripts/test-*.sh 585 pass, 0 fail (out/, run.log). bus runtime.mjs and webui reads.mjs import only readSessions from session.mjs, which this
change doesn't touch. `queue review verify-commit 228` matches the manifest on all 3 paths.
2026-10-21 02:24:25 -05:00
jason.woltje 59f817c321 sessions: Darkwing, row 195 DESIGN r7 pushed (5425ea2f3), Duckworth review requested (Gitea 29960), row 226 items to Gosalyn 2026-10-21 02:15:49 -05:00
jason.woltje c515273db7 row 240: installer I4, root's host check, its pins and the boot unit; I3's N1, N4 and N5 (#1746, Vikunja #432)
Lands Darkwing's round 1 candidate, manifest 573ece95a4a9 (ad1b23ab2 on darkwing/row240-r3, base
c771a36f4), approved by Filbert (29945, rev 2696). Nine paths: scripts/case1-hostcheck.sh (new),
scripts/case1-pins.sh (new), packages/cli/systemd/mosaic-hostcheck.service (new),
packages/cli/tests/case1-hostcheck.test.mjs (new), scripts/stack-upgrade.sh,
packages/cli/tests/stack-upgrade.test.mjs, docs/guides/host-run-sheet.md, docs/TOOLS.md and
agents/darkwing/work/queue-240/BUILD.md. File modes match the candidate. case1-hostcheck.sh and
stack-upgrade.sh are 100755. case1-pins.sh is 100644, because the host check sources it.

Evidence: Darkwing's first slot run failed one test, the N1 test's after hook (ENOENT once the
fixture had removed its root). The fix b45852daa is test-only. The second run on b45852daa was green:
singles 37/0, mutants M1-M8 8 of 8 killed, cli 639/0, scripts 585/0. Since c771a36f4 no commit
touches the nine paths. Sage's gate on 060a17b54 plus the nine paths (06:45:05Z to 06:56:21Z):
packages/cli 639 pass, 0 fail, and all ten scripts/test-*.sh 585 pass, 0 fail (out/, run.log). `queue review verify-commit 240` matches the manifest on all 9 paths.

Filbert's notes: N1 (the boot unit orders only mosaic-bus@mosaic-stack) and N2 (the run sheet writes
the apt hook before the first --check) are in docs/plans/DEFERRED.md. N3, repeated DRIFT lines after
bwrap is closed, is by design and is noted with N2.
2026-10-21 01:56:47 -05:00
jason.woltje da3051ccc6 deferred: Filbert's row 240 N1 and N2 (29945, #1746); 10-24 window draft Q1 takes the mos-1161 ruling 2026-10-21 01:50:34 -05:00
jason.woltje 060a17b549 sessions: Filbert, row 240 round 1 approve (29945, rev 2696) 2026-10-21 01:41:13 -05:00
jason.woltje ffd90e7fc4 sessions: Darkwing, row 240 in review round 1 (ad1b23ab2, manifest 573ece95, revs 2694 and 2695), with its BUILD-LOG entry 2026-10-21 01:37:57 -05:00
jason.woltje 02709125a6 sessions: Sage, row 234 landing c771a36f4, 235 briefed, rebuilt-row slot order, 10-24 draft NEW (sage-1766..1787)
Doc only. The 10-24 window draft 0 now names NEW c771a36f4 and adds row 233 and the T46 untimed tables.
2026-10-21 01:20:30 -05:00
jason.woltje c771a36f4b row 234: machine grant row a, host enrollment, store records and bus schema 3i to 3j (#1740, Vikunja #436)
Lands Gizmoduck's round 3 candidate, manifest 4782c8b5ecb7 (c783d5e39 on gizmoduck/row234-r3, one commit
on 57f59e0c3), approved by Duckworth: content on round 1 (861182fa6, 29919), the round 2 rebase check
(29928, rev 2670) and round 3 (REVIEW-r3.md 7c8c38b69, posted 29935, rev 2683). 41 paths.

Round 3 fixes the two failures in Sage's combined gate with row 233: bus schema.test.mjs now counts 18
tables and 71 triggers for the new hosts and host_grants tables, and stack-release.test.mjs T46 puts both
tables in the untimed restore-guard list, since every write shares a transaction with a grant.* event.
Round 3 rebuilt on the row 233 landing as one commit. 38 of the 39 round 2 blobs are unchanged and BUILD.md
is append-only. Gizmoduck's singles on round 3: stack-release 196/0, schema 1/0.

Evidence: Gizmoduck's slot runs, 25 of 25 mutants killed (A22b and A23 included, A9 structural), singles on
r2 387/0 over 47 files. Sage's landing gate on the c783d5e39 tree: 06:02:12Z to 06:15:26Z, every package except queue 2890 pass, 0 fail (15 packages; bus 539, cli 622), and all ten scripts/test-*.sh 585 pass, 0 fail.

This is code only. The live stack runs from a pinned copy at schema 3i. The 3i to 3j migration runs in the
10-24 window (mos-1145), agent-run under Q173, with a backup first. Duckworth's N10 (hosts.mjs
Object.hasOwn) and N12 (doc-only) go to row 235's carry list.
2026-10-21 01:15:43 -05:00
jason.woltje caa4b59440 sessions: Sage, row 233 landing 57f59e0c3, 234 gate stop and r3, 240 briefed, 197/238 drift (sage-1742..1758) 2026-10-21 00:47:45 -05:00
jason.woltje 57f59e0c39 row 233: installer I3, stack-upgrade.sh and the host run sheet (#1735, Vikunja #432)
Lands Darkwing's round 1 candidate, manifest 464ce9eb177a (1a1dc80ca on darkwing's branch), approved by
Filbert (29922, rev 2659). Seven paths: scripts/stack-upgrade.sh (new), scripts/stack-release.sh,
docs/guides/host-run-sheet.md (new), docs/TOOLS.md, packages/cli/tests/stack-upgrade.test.mjs (new),
packages/cli/tests/stack-release.test.mjs and agents/darkwing/work/queue-233/BUILD.md.

Evidence: Darkwing's gate on d99166ade (base e65a3ab4d) 1201/0, mutants m1-m8 8 of 8 killed. The N1 fix
08db6ac3f (flock sleep leak in stack-upgrade.test.mjs) reran c0 20/0 with m1 killed (sage-1708); BUILD.md
doesn't record that rerun. Since e65a3ab4d no commit touches the seven paths. Rows 241 and 242 changed bus
and cli files. Only cli tests read the seven paths, so Sage ran the cli suite on 5465fd52d plus the seven
paths: 617 pass, 0 fail (out233/pkg-cli.log, 05:26:33Z to 05:35:00Z). A combined gate with row 234 failed bus schema.test.mjs:522 on 234's new tables, and
Gizmoduck found a second 234 failure (stack-release.test.mjs T46), so Sage stopped it. Row 234 round 3 fixes
both and rebuilds on this landing, since its T46 lines go in a file this commit changes.

Filbert's N1, N4 and N5 go to row 240 (brief b00d7d94d). N2 and N3 are in DEFERRED.md.
2026-10-21 00:35:18 -05:00
jason.woltje 5465fd52d5 sage: DEFERRED, checkCreative doesn't refuse credentials (row 213 r3 Webby N1, mos-1160) 2026-10-21 00:12:52 -05:00
jason.woltje b00d7d94d0 sage: row 240 (I4) carries Filbert N1, N4 and N5 from the 233 review; N2 and N3 go to DEFERRED (29922, sage-1727) 2026-10-21 00:01:26 -05:00
jason.woltje 642a9b87de sessions: Filbert, row 233 round 1 approve (29922, rev 2659) 2026-10-20 23:58:01 -05:00
jason.woltje 16019b80e6 sessions: Darkwing, row 233 in review round 1 (1a1dc80ca, manifest 464ce9eb, revs 2644 and 2647), with its BUILD-LOG entry 2026-10-20 23:51:01 -05:00
jason.woltje 9b53620185 sage: 10-24 window draft 0 counts grant look-alike events before --migrate (row 234 A22b, gizmoduck-0107) 2026-10-20 23:29:34 -05:00
jason.woltje 7662b04902 sage: 10-24 window draft 0 adds rows 232 (e65a3ab4d), 241 (5d4a61776) and 242 (5fb736b92, fc1cb856b); 199 and 210 evidence list due in draft 1 2026-10-20 23:24:00 -05:00
jason.woltje 04ce8dfd56 queue: revs 2639-2641 (row 242 Duckworth approve record, landing note and done) 2026-10-20 23:22:41 -05:00
jason.woltje fc1cb856b0 publish: row 242 follow-up docs, the git socket comments name the hold and the late record; BUILD.md residual and :311 cite (Duckworth N1, N2, N4, #1748)
Megavolt's c5a125f4b on megavolt/row242-docs, parent 5fb736b92 (the row 242
landing). Comments and .md only: git-socket.mjs changes comment lines at
:39-44 and :433-435, BUILD.md records N2 and N4. No test pins either path's
hash. Landed without a gate under the doc-only rule.
2026-10-20 23:17:14 -05:00
jason.woltje c0c4907af9 sessions: Sage, rows 241 (5d4a61776) and 242 (5fb736b92) landed, queue revs 2628-2641 2026-10-20 23:14:49 -05:00
jason.woltje 5fb736b92d publish: a retry after publish-timeout pushes once; the bus holds the decision until its push ends and records a late push (row 242, #1748, P19 440)
Megavolt's candidate 635c10924 on megavolt/row242, manifest 1fbfab6c (3 paths:
agents/megavolt/work/queue-242/BUILD.md, packages/bus/src/runtime.mjs,
packages/cli/tests/publish-socket.test.mjs). Duckworth approved (comment 29889, rev 2639).

Mutants M1 and M2, run by Megavolt on Sage's slot: both killed (publish-socket 8/1 each).
Sage's landing gate on 12b8538e0 (96bac588f plus these 3 paths): 15 packages 2826 pass 0 fail (bus 500, cli 594, seat 164), 10 scripts/test-*.sh suites 429 pass 0 fail plus 156 node tests in the queue suite, 03:59Z to 04:12:44Z

The 3 paths are unchanged on refactor since the candidate's base 8fede5fc6. Follow-ups:
Duckworth's doc notes N1, N2 and N4 go in a later doc-only commit; the late-record notice
to the run is Vikunja 440 comment 432. No live change.
2026-10-20 23:13:06 -05:00
jason.woltje 96bac588fb queue: revs 2628-2638 (row 241 approve record, landing note and done; row 232 landing note and done; row 233 brief re-pin and Darkwing claim; row 242 in review round 1, request 29888) 2026-10-20 22:47:48 -05:00
jason.woltje 5d4a617762 bus: row 241 one decision lands once; a broker test with two checkLand tickets on one decision, where the second recordLand refuses decision-consumed and records nothing (row 241, #1747, P19 441)
Megavolt's candidate 3e3ed303e on megavolt/row241, manifest e8266864 (2 paths:
agents/megavolt/work/queue-241/BUILD.md, packages/bus/tests/land.test.mjs). Tests and
packet only, no source change. Duckworth approved (comment 29870, rev 2628).

Sage's slot on bbf3b1ee8 (0444acd08 plus these 2 paths):
- mutants M1 (#spent recheck, broker.mjs:963), M2 (CAS old value, seat working.mjs:391),
  M3 (land-busy, cli git-socket.mjs:351): all killed
- full gate: 15 packages 2797 pass 0 fail (bus 500, cli 565), 10 scripts/test-*.sh suites 429 pass 0 fail plus 156 node tests in the queue suite, 03:21:13Z to 03:34:16Z

Row 232's landing e65a3ab4d sits between that base and this commit. Its 9 paths are
disjoint from these 2 and touch no bus code. No live change.
2026-10-20 22:34:30 -05:00
jason.woltje e65a3ab4d2 stack-release: --units external for root-owned units, with confirm, revert and gate --since; stack-copy copy --bundle and wrapper --create (row 232, #1734, installer I1)
Blobs of Darkwing's round 2 da2da2f6c on origin/darkwing/row232-r2 (base
8fede5fc6), the 9 manifest paths, manifest 7af3c943. Round 2 is a
rebase-only round over row 230 (c8bc5cf4c): packages/cli/README.md is a
clean 3-way merge with 232's hunk unchanged, and the other 8 paths keep the
round 1 hashes (manifest 7a3b4ea1). Filbert approve round 1 (comment 29829)
and round 2 (comment 29865).

Under --units user every command line and every byte of output is the
base's. Under external the script reads root's system units, starts and
stops none, stages a copy behind AWAITING-UNITS, and confirm gates root's
start on the bus unit's ActiveEnterTimestamp and launches list.

Mutants 17 of 17 killed (BUILD.md). Darkwing's landing gate on da2da2f6c,
03:07:15Z to 03:17:48Z: packages/cli 593 pass 0 fail, 10 scripts/test-*.sh
suites 429 pass 0 fail (plus 156 node tests in the queue suite). Refactor
moved to 0444acd08 since, three commits touching no manifest path. No live
change: the stack runs from copy 4429a104f, and --units user stays the
default.
2026-10-20 22:19:47 -05:00
jason.woltje 0444acd084 queue: revs 2618-2627 (row 232 round 2 approve; row 241 in review round 1, request 29868; row 234 and row 242 claimed)
Gizmoduck: row 234 claim (2618). Darkwing: row 232 rebase round 2 onto 8fede5fc6, in-progress and in-review with request comment 29860 (2619-2621). Filbert: row 232 round 2 record, approve, comment 29865 (2622). Megavolt: row 241 in-review round 1, request failed on the token (2623-2624), row 242 claim (2626). Sage: the row 241 request as jarvis and its resolve to comment 29868 (2625, 2627).
2026-10-20 22:06:29 -05:00
jason.woltje 052ba95c7c sessions: Filbert row 232 I1 review round 2, approve (#1734) 2026-10-20 22:01:43 -05:00
jason.woltje a52d64ed41 sage: 10-24 window draft 0 names the landed commits for rows 210 (f5753c0e8), 237 (d7d943199) and the PRD (0adf8ddfe) 2026-10-20 21:52:36 -05:00
jason.woltje 8fede5fc6a sessions: Sage, row 210 landed f5753c0e8, queue revs 2604-2617 2026-10-20 21:51:47 -05:00
jason.woltje c084d8d423 queue: revs 2604-2617 (row 210 done, landed f5753c0e8; rows 241 and 242 added and briefed, 241 claimed by Megavolt; row 234 briefed)
Sage: rows 241 and 242 added and briefed (#1747, #1748), the row 210 round 3 request as jarvis, the row 210 landing note and done move, row 234 queued to briefed. Gizmoduck: row 210 r5 start and in-review round 3 (2607-2611). Duckworth: row 210 round 3 record, approve (2614). Megavolt: row 241 claim (2606).
2026-10-20 21:50:59 -05:00
jason.woltje f5753c0e8d host access: agent host sudo through the credential store, host.run over broker.sock (row 210, #1707)
Blobs of Gizmoduck's r5 0485384e9 on origin/gizmoduck/row210-r5 (base
ad95c888c), the 23 manifest paths, manifest 688d38b9. Duckworth approve
round 2 (rev 2592) and round 3 (comment 29851, rev 2614). Round 3 is a
rebase-only round over row 237 (d7d943199): broker.mjs is a clean 3-way
merge (610af0c4), the 210 hunks sit away from 237's insert, and range-diff
against r4 c43008749 is all equal.

Mutants 52 of 53 killed on the merged tree. M22b is equivalent (rev 2584);
its coverage moves to row 235. Preflight singles: seat sandbox 10/0, bus
project-runtime 3/0. Gate on ad95c888c plus these paths: 15 package suites
2796 pass 0 fail, 10 scripts/test-*.sh suites 429 pass 0 fail (plus 156 node
tests in the queue suite). Refactor moved to 9d165be8c since, two briefs
touching no manifest path. No live change: the stack runs from copy
4429a104f until the 10-24 re-pin window.
2026-10-20 21:47:11 -05:00
jason.woltje 9d165be8c7 plans: row 237 follow-up N1 brief, a retry after publish-timeout pushes once (duckworth-0069, P19 440) 2026-10-20 20:54:33 -05:00
jason.woltje c25785d3d5 plans: row 237 follow-up brief, one decision lands once, tests at the broker (megavolt-0069, sage-1651, P19 441) 2026-10-20 20:52:31 -05:00
jason.woltje ad95c888c3 sessions: Sage, row 237 and PRD 1.7+1.8 landed, queue revs 2540-2603 2026-10-20 20:49:55 -05:00
jason.woltje 584e2bd827 queue: revs 2540-2603 (rows 199, 200, 230, 237 done; 210 r4 and 238 in review; 232 in review round 1 approve; 233 briefed; rows 239 and 240 added)
Sweeps 64 uncommitted revs. Seats: sage, duckworth (2540, 2581, 2592), gosalyn (2550-2551, 2558-2560),
megavolt (2555, 2574, 2576-2577), webby (2557, 2563, 2599), launchpad (2570, 2590-2591),
gizmoduck (2585-2587), darkwing (2595-2596), filbert (2600).
Landings: 200 9e1085816, 199 cb5fdb064, 230 c8bc5cf4c, 237 d7d943199.
2026-10-20 20:49:45 -05:00
jason.woltje 0adf8ddfe2 prd: Mosaic Stack PRD versions 1.7 and 1.8, status approved version 1.8 (DECISIONS Q192, decision d52febb5, Vikunja #434)
1.7 is PRD-1.7-DRAFT.md (sha256 e896746d), approved by decision d52febb5 (prd.approve, yes). 1.8 is r7
82df17347 (sha256 de079281), approved by Jason in T3, relayed by Mos (mos-1154, DECISIONS Q192), so it has no
in-Stack prd.approve. packages/tasks/requirements.json moves to version 1.8 with 68 ids: COS-3, CRED-3, CRED-4,
GRANT-1 to 4, TRUST-1, TRUST-2 and LAND-2 are new. The tasks and bus requirement pins move to 68 and 1.8.
Written by Honker (honker/prd-17-18 2884ee496), reviewed by Duckworth (duckworth-0075, -0076, -0077).
2026-10-20 20:46:55 -05:00
jason.woltje d7d9431990 bus: one decision, one open publish; checkPublish refuses publish-busy while another ticket holds the decision (row 237, #1743)
The decision is spent only at recordPublish, after the push, so two checkPublish calls on one decision could
both push and land the commit on two fork refs. checkPublish now refuses publish-busy while an open ticket
holds the same business and decision. A refusal drops the ticket and leaves the decision unspent. Broker test
and a socket race test with exactly one git-receive-pack. Residuals 1-3 in BUILD.md; residual 3 (checkLand)
is a follow-up row.

Built by Megavolt (megavolt/row237 e42415935, duckworth-0068 N1), reviewed by Duckworth (approve, comment
29810, manifest bb777cb0). Sage's gate on 816d48b03 plus these paths: M1 killed, every package suite and every
scripts/test-*.sh green.
2026-10-20 20:45:08 -05:00
jason.woltje a0e3d7cfdd plans: installer I4 hostcheck row brief, split out of I3 (darkwing row 233 G2, sage-1650) 2026-10-20 20:38:37 -05:00
jason.woltje 9d24ff9b28 sessions: Filbert row 232 I1 review round 1, approve (#1734) 2026-10-20 20:34:13 -05:00
jason.woltje 6c04b89dae plans: row 194 follow-up brief, the revoke under the writer's lock and a fire test (duckworth-0074, mos-1158) 2026-10-20 20:29:43 -05:00
jason.woltje 6f7dacb417 sage: 10-24 window draft 0 carries PRD 1.7+1.8 (mos-1154, mos-1156, DECISIONS Q192); the live pm cites 1.6 until the window 2026-10-20 20:28:39 -05:00
jason.woltje d21b2615c1 darkwing: row 232 in review round 1 (#1734), BUILD-LOG and SESSIONS lines 2026-10-20 20:28:11 -05:00
jason.woltje 816d48b03e sage: row 42 run log, Jason's pre-S7 checks (mos-1154); PRD 1.8 r7 raise dropped, Jason approved in T3 (DECISIONS Q192, NOTE.md) 2026-10-20 20:22:19 -05:00
jason.woltje 011d41ba6a sage: row 42 S7 dates in the run log (sage-1622, mos-1148 C1 C2, Vikunja 442), 10-24 window draft 0 (mos-1145; NEW open), Vikunja scripts and records: 412/424 landing comments, 237 follow-ups 440/441, P19 405-408 and 411 re-plan comments 416-420, read-tasks.mjs (#1524) 2026-10-20 20:06:39 -05:00
jason.woltje c8bc5cf4cb case2: unit render and host precheck, case2-unit.sh and case2-precheck.sh (row 230, #1732)
Squash of Gosalyn's 96768766c on gosalyn/c2-13a-r2 (base 722136772), the
5 manifest paths, build.patch 56649f9a, manifest fbb0d7e42c67 (Webby
approve round 2, rev 2563). Gosalyn's gate on 96768766c: cli 541 pass
0 fail; scripts/test-*.sh 429 pass 0 fail (summed by script); mutants
10 of 10 killed. packages/cli/README.md is a 3-way merge with row 199
(cb5fdb064): the hunks don't overlap (line 710 vs 1022). host.test.mjs:855
is the only test that reads that file. On cb5fdb064 plus the patch,
case2-unit.test.mjs 30/0 and host.test.mjs 42/0. Refactor moved to
390ea5261 since, a README fix touching no manifest path. The script
runs on a host only in a separate window with Jason's go.
2026-10-20 19:36:22 -05:00
jason.woltje 390ea5261c business: README names nine hard waits, git.push.remote included; row 199 added it to GATED_ONLY (row 199 review N3, duckworth-0068, #1697)
Megavolt's 5d7e4d26e on megavolt/n3-readme, from cb5fdb064. Doc-only: no
test or scripts/test-*.sh reads packages/business/README.md. The text
matches packages/business/src/vocabulary.mjs, where GATED_ONLY has nine
actions.
2026-10-20 19:29:22 -05:00
jason.woltje cb5fdb064f credentials: a credential store per agent, its own git identity, pushes to a remote (row 199, #1697)
Squash of Megavolt's 627979a17..86963d281 on origin/megavolt/row199-on-190
(base 00d4cc1f6, row 190's landing), the 37 manifest paths, build.patch
05383075, manifest 35f900fd (Duckworth approve round 1, comment 29779,
rev 2540). Mutant batches 1 and 2 in agents/megavolt/work/queue-199/BUILD.md:
batch 2 26 of 28 killed, M17 and M27 equivalent. docs/TOOLS.md is a 3-way
merge with row 200 (9e1085816): row 199's hunk applies unchanged (patch-id
d88379111e2e) and no test reads the file. Gate on 9e1085816 plus the patch:
15 package suites 2735 pass 0 fail, 10 scripts/test-*.sh suites 429 pass
0 fail (plus 156 node tests in the queue suite). Refactor moved to 76134dfde
since, a brief touching no manifest path. No live change: the stack runs from
copy 4429a104f until the 10-24 re-pin window.
2026-10-20 19:23:12 -05:00
jason.woltje 76134dfde1 plans: row 200 follow-up N1 brief (#1744, Launchpad, Webby): the sso-config hint carries --instance NAME 2026-10-20 19:12:34 -05:00
jason.woltje 9e10858160 stack-release: activate --sso for the Q175 phase 2 window (row 200, #1698)
Squash of Launchpad's 30e4bb1a3..9df40878f (round 3 on row 190's landing
00d4cc1f6), the 3 manifest paths: scripts/stack-release.sh,
packages/cli/tests/stack-release.test.mjs, docs/TOOLS.md. Manifest
b3ead55c8b48 (Webby approve round 3, rev 2539, comment 29772).
Patch b2a8f5de18b7. Gate on 9df40878f: 15 package suites 2675 pass
0 fail, 10 scripts/test-*.sh suites 429 pass 0 fail (plus 156 node tests
in the queue suite). Mutants 13 of 14 killed, M12 the ruled equivalent
(sage-1446). Refactor moved to 722136772 since the base, touching none of
the 3 paths. No live change: the stack runs from copy 4429a104f until a
re-pin.
2026-10-20 19:09:00 -05:00
jason.woltje 7221367728 plans: row 194 item 7 takes the restart route (honker-0044) and item 8 revokes on fire and delete before the business file changes (row 199 D13), with tests and mutants 2026-10-20 18:51:20 -05:00
jason.woltje a304dad947 plans: C2-03a askpass ruling (megavolt-0060 to -0063, dewey-0765, gosalyn-0068): fixed argv with followRedirects=false and credential.helper reset, LC_ALL=C, exact-prompt helper with row 199's HOST/NAME checks, no userinfo; tests and four mutants 2026-10-20 18:42:52 -05:00
jason.woltje 919a84738b plans: #436b build brief (#1742, schema 3j-3k, design r5 1be535aca) and row 199 N1 follow-up (#1743); row 194 carries 199 N2; 193b2 moves to 3k-3l after #436b 2026-10-20 18:39:24 -05:00
jason.woltje 2d8687bcfd sessions: pending lines from Darkwing, Rocko, Launchpad, Morgana, Honker, Gander, Herb, Dewey and Sage (2026-10-20 18:45Z to 23:32Z) 2026-10-20 18:32:42 -05:00
jason.woltje 1c46d0abf6 queue: revs 2456-2539. Morgana row 213 in-review rounds 1-2 (2456-2476); Gizmoduck row 210 in-review 1 (2458-2459); Gosalyn row 212 in-review r1 and row 230 start (2464-2470); Duckworth row 210 record (2466); Webby row 213 verdicts r1-r2 and row 200 r3 approve (2469, 2500, 2539); Darkwing row 190 r3 in-progress through row 232 in-progress (2501-2514); Filbert row 190 record and row 66 r3 approve (2504, 2526); Dewey row 66 r3 approve (2529); Megavolt row 199 in-review round 1 (2535-2536); Launchpad row 200 r3 in-progress and in-review (2509-2525); Sage requests, row 190 done, rows 232/233 added and briefed, row 195 after 190 and 233, brief re-pins of 226/230/232/233, row 66 round 3, the row 200 r3 request as jarvis, machine grant rows 234 and 235 added and row 206 after 234 with its 3k brief and piece, the row 199 r1 request as jarvis (2460-2538, 57 revs) 2026-10-20 18:32:06 -05:00
jason.woltje f519fb1d26 plans: machine grant rows a (#1740, schema 3i-3j) and b (#1741) briefs from design r6 f9039682e; 193b2 moves to schema 3j-3k after row a 2026-10-20 18:24:28 -05:00
jason.woltje b8236a7e13 sessions: Filbert row 66 round 3 APPROVE 2026-10-20 18:20:31 -05:00
jason.woltje c7675476b1 sage: P19 catch-up comments 411-413 on 413 (row 189 landed, no-send activate), 424 (row 210 status) and 414 (rows 201 and 204 landed) 2026-10-20 18:19:49 -05:00
jason.woltje 9de7ad8292 sage: row 66 r3 packet, rebase of r2 onto c40d846cb (sage/row66-r3 2369c00e36, same patch-id, manifest 42e3876ca168) 2026-10-20 18:15:59 -05:00
jason.woltje c40d846cb8 sage: Vikunja comment scripts and read-backs for row 192 landing (P19 412) and the row 190 W11 comments 406-409 2026-10-20 18:12:18 -05:00
jason.woltje 01b40d3576 plans: C2-13a rulings for gosalyn-0065. G1: default instance only, no --instance, a named instance is a follow-up. G2: library refuses --sso for 10-27, preview set and proof pair stay, --sso waits for Q175 phase 2. Line cites moved to their places after rows 190 and 192 (webui-service.sh, stack-copy.sh, bus-service.sh, serve.mjs), and the Console's WantedBy replaces :41 2026-10-20 18:09:04 -05:00
jason.woltje ad6f0be7aa plans: installer I1/I3 brief, the stack user's stack-release.sh runs from its own clone of the release bundle (darkwing question, sage-1561) 2026-10-20 18:05:14 -05:00
jason.woltje ffc124bf68 plans: installer I2 re-brief of C2-13a (row 230, #1732) and C2-13b (row 226, #1727) for Vikunja #432. Q189: both units WantedBy=multi-user.target, root's 2c step enables both and starts the bus then the Console, no hand start at step 9 (gosalyn-0064, mos-1122, mos-1123). Row 190 landed first, so row 230 renders @CONFIG_ENV@ and @SESSION_ENV@ 2026-10-20 18:02:39 -05:00
jason.woltje 8a14931fa4 plans: installer briefs I1 (Gitea #1734) and I3 (#1735) for Vikunja #432 (mos-1118, mos-1122). I1 builds on row 190's landing 00d4cc1f6; I3 carries the root lock finding, duckworth-0055's notes, the per-host enrollment step and the credential slot table 2026-10-20 17:56:07 -05:00
jason.woltje 0272383f58 sessions: Darkwing row 190 landed as 00d4cc1f6, row 195 r6 approved and held 2026-10-20 17:53:27 -05:00
jason.woltje 00d4cc1f69 stack: several Mosaic Stack instances on one host (row 190, #1688)
Squash of Darkwing's 176a6bf4b..df65a3d4c on origin/darkwing/row190-r3,
the 25 manifest paths, build-r3.patch efe898c2, manifest cc0ea9e8
(Filbert approve round 3, comment 29713). Design revision 3 in
agents/darkwing/work/queue-190/DESIGN.md. Gate on base d0bbc5c1c with
the patch: 15 package suites 2664 pass 0 fail, 10 scripts/test-*.sh
suites 429 pass 0 fail (plus 156 node tests in the queue suite).
Refactor moved to 99a406ed3 since, a window record touching no manifest
path. Mutants 20 of 20 killed (BUILD.md, round 3). No live change: the
stack runs from copy 4429a104f until a re-pin.
2026-10-20 17:49:06 -05:00
jason.woltje 99a406ed3c sage: re-pin window 490e2771d to 4429a104f (row 192) record, STATE.md (sha256 a4d0a7b12f431f15), W.txt (ca9ad4a09059d3b7), S1/S2, matrices, W10 browser check and diagnosis (screenshots not committed), Mos's P3 rehearsal and flags check, W11 reads. Plan corrections after the window: NEW is 103 commits past OLD (not 106), the P3 flags note (mos-1107), and W10's line g and corrected browser checks (mos-1135, mos-1137). PRD 1.8 raise record, decision 3a6c8fb9 (mos-1140) (#1690; P19 386, 412) 2026-10-20 17:46:36 -05:00
jason.woltje d0bbc5c1cb business: 193a access level check widened to viewer (row 204, #1702)
Squash of Morgana's 7f07da4aa..f869fd062 on origin/morgana/row193a (diff
from R1's candidate 44e36fc4c), the three manifest paths, manifest d38de8c9
(Duckworth approve, comment 29595). LEVELS gains collaborator and viewer,
covers() takes a required least level, and an unknown grant level covers
nothing. Gate on base 4385b9ec1 with the patch: packages and
scripts/test-*.sh 2789 pass 0 fail. Refactor moved to c9f374284 since, a
SESSIONS line only. Mutants 7 of 7 killed (agents/morgana/work/queue-193a/
mutants.log on the branch). No live change.
2026-10-20 17:25:27 -05:00
jason.woltje c9f374284b sessions: Filbert row 190 manifest round 3 APPROVE 2026-10-20 17:16:20 -05:00
jason.woltje 4385b9ec18 plans: Case 2 briefs. C2-13a factory profile from gosalyn-0057 items 1-3 (--pm, --claude under the --node rule, no proxy flags), row 190 tokens to whichever lands second. New C2-01b for row 212's notes N1-N3, N4 to the C2-12 setup sheet, N5 to C2-10b (webby-0041, sage-1517) 2026-10-20 16:52:39 -05:00
jason.woltje afad4993f6 business: FED R1, access.json loader and validator with covers (row 201, #1699)
Squash of Gizmoduck's ae3c36f24..44e36fc4c, the three manifest paths,
manifest 225597146daf1c0e3ffe2049636095733f40a3395318f8ba07dad1bf08c0c520
(Duckworth approve, comment 29581). Gate on 44e36fc4c, run by Gizmoduck in
Sage's slot: business suite 173 pass 0 fail, scripts/test-*.sh 10 scripts
422 pass 0 fail, mutants 44 of 44 killed. index.mjs gains one additive
export line. No live change.
2026-10-20 16:47:42 -05:00
jason.woltje f41729178e plans: re-pin plan 490e2771d to 4429a104f (row 192, Q175 phase 1), npm ci in the new copy, for Mos's check (mos-1099 item 3) 2026-10-20 16:39:56 -05:00
jason.woltje 305857c8f9 queue: revs 2362-2455. Sage: Case 2 rows re-pinned to design r11 (5b8def2f1, row 226 to C2-13b), row 230 C2-13a added and briefed (#1732), row 215 (C2-04) assigned to Herb, row 192 landed 4429a104f with landing note and done, row 228 review request resolved, plus earlier Sage revs. Other seats' revs: Filbert 2362, 2447 (row 190 verdicts); Herb 2395-2396; Webby 2420, 2428 (rows 197/228 verdicts); Morgana 2421 (row 213 claim); Researcher subagent 2422-2424 (row 228); Gosalyn 2426 (row 212 claim); Darkwing 2431, 2433, 2438 (row 190) 2026-10-20 16:35:11 -05:00
jason.woltje 176a6bf4b9 sessions: Filbert row 190 manifest round 2 CHANGES (base drift on row 192) 2026-10-20 16:29:20 -05:00
jason.woltje 5b8def2f14 plans: Case 2 row briefs from Gosalyn's eac92d956 (design r11, approved webby-0038: C2-13 split into C2-13a unit render and host precheck and C2-13b install script and library precheck, C2-04 owner Herb, Console unit without [Install] per mos-1097) 2026-10-20 16:22:47 -05:00
jason.woltje 4429a104f6 webui: Q175 phase 1, the Console verifies SSO itself under --sso, OIDC RP with oauth4webapi 3.8.8 (row 192, #1690)
Squash of Launchpad's launchpad/row192 53bb59462..37582e820, manifest round 2
6f8cee147713c53652b58d47c5efd15ed47da1efbef6a126464857269b0b7787 (18 paths).
Sage's gate on 088087ac9: package suites 2758 pass 0 fail, scripts/test-*.sh
429 pass 0 fail; mutants 22 of 23 killed, #23 (second origin check) declared
equivalent by Launchpad (sage-1407). New dependency oauth4webapi 3.8.8: the
canonical checkout and any stack copy need npm ci. No live change; the live
re-pin is a window with Mos (mos-1099 item 3).
2026-10-20 16:21:29 -05:00
286 changed files with 36467 additions and 2459 deletions
+11 -6
View File
@@ -25,10 +25,15 @@ green at every step. Not production software — a proven foundation.
1. **Root is bootstrap-only.** First-class system configuration lives at the
repository root; everything else gets a dedicated directory (`roles/`,
`contracts/`, `missions/`, `tasks/`, `docs/`). Do not add new files to root.
2. **Configuration**: `~/.config/mosaic-dev/config.json` is the sole system
config — created only by `scripts/bootstrap.sh`, never overwritten,
fail-closed on any problem. Repo-scoped role authority lives in
`roles/*.json` (versioned, reviewed commits only).
2. **Configuration**: each stack instance has exactly one system config. The
default instance's is `~/.config/mosaic-dev/config.json`. Instance NAME's
is `~/.config/mosaic-NAME/config.json`, and its data root carries a marker
that names it. `~` is the home of the OS user that runs the instance. A
process reads only the config `MOSAIC_CONFIG` names, or the default's when
that is unset. Configs are created only by
`scripts/bootstrap.sh [--instance NAME]`, never overwritten, fail-closed on
any problem. Repo-scoped role authority lives in `roles/*.json`
(versioned, reviewed commits only).
3. **Secrets** never enter the repository or container images; auth is
runtime-only (read-only mount or environment variable).
4. **Contracts** (`contracts/`) are immutable and image-baked. Missions and
@@ -177,8 +182,8 @@ Full reference — usage, fields, exit codes, safety notes:
## Data map (canon)
- `~/.config/mosaic-dev/config.json` — system config (user-authored; never
auto-written).
- `~/.config/mosaic-dev/config.json`, or `~/.config/mosaic-NAME/config.json`
per named instance: system config (user-authored; never auto-written).
- `<dataRoot>` (from config; default `~/.mosaic-dev`):
- `runs/` — write-once run evidence (`result.json`, snapshots, `stderr.txt`)
- `sessions/` — pi JSONL session trees, one directory per named session
+3
View File
@@ -5174,3 +5174,6 @@ Before: managed project/thread verbs had no Console conversation area. After: fi
2026-10-20 04:44:54 UTC | Row 178 round 2 preparation | Before: Dewey C1 five render guards lacked killing tests. Added independent refusal fixtures, a temporary uid+1 id stub with cleanup, and empty loopback proof case; activation ordering recorded in r2 BUILD. Product unchanged; syntax/text preflight only, no test or mutant execution. Pending row 181 rebase and Sage slot.
2026-10-20 05:10:34 UTC | Row 178 #1676 round 2 verified | Before: C1 render refusals lacked killing tests and row 181 base was pending. Rebased onto 931651f2d, kept all COUNTED/count code and tests, appended T178 tail; added independent owner/collision/absolute/charset/header and empty-loopback proof tests. Four files singly 156/156, all 24 mutants killed, metadata and sandbox fixture probes pass; scratch sweep empty. Packet agents/rocko/work/queue-178/r2/, candidate 9300a670, inventory d0dcde7b (59/59), scan 58 files no findings; in-review revs 2166-2167, expected no-token request failure. No live actions or commits.
2026-10-21T01:28:05Z | Darkwing | row 232 #1734 installer I1 build, round 1 in review | Before: stack-release.sh managed only user units (systemctl --user), and stack-copy.sh had no bundle copy. After: --units external (adopt, activate, rollback --to, status, confirm, revert, gate --since, AWAITING-UNITS, stale-start, external-pm, --plan --expect-from) and stack-copy check --units external, copy --bundle FILE SHA, wrapper ID --create; user mode byte-identical to the base by test. First build 0af0c73c6 on 00d4cc1f6; rebased onto row 200 (cb5fdb064) as a0ffabc3f on darkwing/row232-r1; --sso refused under external (D9), row 200 config_checks run under external (D10). Gate on Sage's slot (sage-1618): cli 561/0, every scripts/test-*.sh green; 17/17 mutants killed. Results commit 84f1a91e6 (pushed, seat branch only). Manifest 7a3b4ea1 (9 paths), queue revs 2595-2596, request comment 29825, reviewer Filbert. Packet agents/darkwing/work/queue-232/BUILD.md.
2026-10-21T04:50:44Z | Darkwing | row 233 #1735 installer I3 build, round 1 in review | Before: root had no upgrade driver for root-owned units and no host run sheet; stack-release.sh had no rehearse --backup last and no external plan refusals. After: scripts/stack-upgrade.sh upgrade and rollback (r6 A1-A5, A2f, R1-R4) with 20 tests, docs/guides/host-run-sheet.md, TOOLS.md lines, and on Sage's rulings stack-release.sh --backup last, BACKUP_STORE and the --plan refusals open-activation and migrate-required (3 tests). Base e65a3ab4d (row 232's landing). Gate on Sage's slot (sage-1704) on d99166ade: cli 616/0, every scripts/test-*.sh green, 1201/0 summed by awk; m1-m8 8/8 killed. Correction: the process sweep after the gate found an orphaned sleep 60 from the lock test's flock holder (N1); 08db6ac3f fixes it test only, Sage ruled the gate stands (sage-1707), rerun c0 20/0 and m1 killed with 0 left (sage-1708). Tip 1a1dc80ca (pushed, seat branch only). Manifest 464ce9eb (7 paths), queue revs 2644 and 2647, request comment 29901, reviewer Filbert. Packet agents/darkwing/work/queue-233/BUILD.md.
2026-10-21T06:37:41Z | Darkwing | row 240 #1746 installer I4 build, round 1 in review | Before: root had no host check for the bwrap AppArmor profile, its pins or a boot unit, and stack-upgrade.sh exited 1 args-failed when the args move after A5 failed, while exit 1 means the old copy runs. After: scripts/case1-hostcheck.sh (seven items, --check, --enforce, --upstream), scripts/case1-pins.sh, mosaic-hostcheck.service and 16 tests; the args move failure exits 5 args-failed, documented in TOOLS.md and run sheet section 6 and tested; run sheet section 3 puts the pinned npm on PATH and names the bin/claude.exe --claude path; sections 8 and 10 have precheck lines. Verification: slot run r1 on 47ec9fe2f failed at the singles (the new N1 test's after hook threw ENOENT after the fixture cleanup removed the root); b45852daa drops the hook; r2 on b45852daa green: singles 16/0 and 21/0, c0 plus M1-M8 8/8 killed, cli 639/0, scripts 585/0. Candidate darkwing/row240-r3 ad1b23ab2 on c771a36f4, manifest 573ece95 (9 paths), revs 2694 and 2695, comment 29943.
+287
View File
@@ -0,0 +1,287 @@
# Row 232 (I1): stack-release external units and bundle copies
Brief: `docs/plans/2026-10-20_installer-rows.md` section I1, blob `f35e7e57daee` (commit ad6f0be7a). Gitea
#1734, Vikunja P19 #432. Owner Darkwing, reviewer Filbert, gate owner Sage. Design input: row 195 DESIGN r6,
`agents/darkwing/work/queue-195/DESIGN.md` at b7239bb66 (approved in duckworth-0055). Where the brief and
r6 differ, the brief wins. sage-1561 ruled where the stack user's `stack-release.sh` runs: from its own
clone of the release bundle, so `check_target`, the plan's commit list and status drift are unchanged and
nothing adds `safe.directory`.
## Files
Base: cb5fdb064 (row 199's landing, on top of row 200's 9e1085816). Branch `darkwing/row232-r1`. The first
build, 0af0c73c6 on `darkwing/row232`, sat on 00d4cc1f6 (row 190); row 200 landed first, so this one is
rebased onto it (see D9 and D10).
- `scripts/stack-release.sh`: `--units user|external`, the `sc` and `jctl` backend, `confirm`, `revert`,
`gate --since`, `rollback --to`, `--plan --expect-from`, the external gate (start floor, PM check from
`launches list`), `external-pm`, and the `awaiting units:` status line.
- `scripts/stack-copy.sh`: `check --units external`, `copy --bundle FILE SHA`, `wrapper ID --create`.
- `packages/cli/tests/stack-release-external.test.mjs` (new, 25 tests).
- `packages/cli/tests/stack-release-fixture.mjs` (new): the helpers `stack-release.test.mjs` defined, moved
so both files share them. See deviation D1.
- `packages/cli/tests/stack-release.test.mjs`: the helpers leave and one import line comes in. The 74 tests
have the same names and the same bodies as on the base.
- `packages/cli/tests/stack-copy.test.mjs`: three new `row 232:` tests and a wider `node:fs` import.
- `docs/TOOLS.md`: the stack-copy usage block and three paragraphs, the stack-release usage block, and a new
subsection "Root-owned units (`--units external`, row 232)".
- `packages/cli/README.md`: one paragraph.
- This file.
The worktree's `node_modules` symlink is not part of the candidate.
## User mode is unchanged
Every `systemctl` call goes through `sc` and every `journalctl` call through `jctl`. Under `user` they
are `systemctl --user "$@"` and `journalctl --user "$@"`. Under `external`, `sc` allows only `show`,
`is-active` and `list-units`, and `jctl` returns 1, so an external run can't start, stop, install or read
the journal even through a path the tests miss.
Two tests hold the brief's "every command line and every byte of output":
- `user mode: every systemctl call carries --user, and activate, rollback and check make the base's
calls`. A fixture repo gets the base's two scripts from `git show cb5fdb064:`. The same activate,
rollback and `stack-copy.sh check` run from both trees, and the recorded argv (with the fixture root and
port replaced by placeholders) must match line for line. Every call carries `--user`.
- `user mode: a default activate --plan prints the base's bytes`, for four plans.
The base output is produced live from the base blobs, not stored as a fixture file. See deviation D3.
## r6 section 7, test by test
All in `stack-release-external.test.mjs` unless named.
| r6 :742-812 item | Test |
|---|---|
| `units-running` (N4), store, log and TAKEN left | `units-running (N4) refuses while a unit is active...`; also checks that `failed` counts as down and that the Console is checked first |
| activate leaves the pointer, writes `AWAITING-UNITS` and a backup | `activate stages: ...`, which also checks the set's file list, `UNITS.sha256`, no `--user`, no journal call and `status` |
| `open-activation` with zero, two, the wrong copy, one not open | `confirm and revert refuse open-activation ...` |
| `confirm` polls: 3 s fails at `--timeout 1`, passes at `--timeout 10` | `confirm polls: bus status good after 3 s fails ...` and `confirm polls to a pass ...` |
| `stale-start` floor with `started` at .900 | `stale-start: @1792540799 fails ...` and `stale-start is a gate code ...` |
| `confirm` pass writes pointer, ok, removes marker; timeout writes `FAILED` and exits 1 | the two polling tests |
| `activation-failed` when `FAILED` exists | `confirm polls: ... FAILED then refuses activation-failed` |
| lines carry the started op, from, to, activation and backup | `sameAsStarted` in every confirm test, and sequence 7 |
| `gate` (N2) with and without `--since` | `gate (N2, A2f): ...` and the usage test |
| `--plan --expect-from` (N5), activate and `rollback --to` | sequence 7 |
| A3 order: capture-failed, marker-failed, wrapper failure after migrate | `A3 order: ...` and sequence 2 |
| refusals log no activation; `interrupted` unchanged | `open-activation` test and sequence 5 |
| PM check: one, none, two, early, not live, other business, other instance; no journal call | `the external PM check reads launches list ...` (modes '', coder, none, twolive, early, dead, other) and `journalCalls` in every external test |
| `external-pm` | `pm-on and activate --pm-after-harness refuse external-pm` |
| `revert` restores TAKEN only after a migration | `revert puts the wrapper back and leaves the store ...` and sequences 2 and 8 |
| `confirm --rollback` per cause, `unrecorded`, `rollback-failed`, mismatch | sequences 1, 2, 3, 5 and 8, and `confirm --rollback refuses rollback-mismatch, and logs rollback-failed ...` |
| sequences 1-8 | `sequence 1:` to `sequence 8:`, each ending with the `closed` check (one started and one result line, empty `interrupted`, no marker or `FAILED`, a following `--plan` that doesn't refuse `interrupted-run`) |
| `rollback --to` refuses not-previous; `rollback --restore` finds an external backup | sequence 7, and `confirm --rollback after a --restore swap ...` |
| `gate` writes nothing | the `gate (N2, A2f)` test and sequence 4 compare the log, the pointer and the state tree |
| `confirm`, `revert`, `gate`, `--to` refuse without `--units external` | `confirm, revert, gate, --to and --expect-from need --units external ...` |
| default path: every call carries `--user`; default `--plan` bytes | the two user-mode tests above |
The brief's additions, in `stack-copy.test.mjs`:
- `row 232: copy --bundle copies a SHA the bundle's refactor reaches, byte-identical to copy SHA, and
refuses the rest`: a short SHA, a missing file, a symlink, a bundle that fails `git bundle verify`, a
bundle with no `refs/heads/refactor`, a SHA not in the bundle, and a SHA the bundle carries on another
branch that refactor doesn't reach. The bundle copy's tree matches `copy SHA`'s byte for byte, and the
`release:` and `bundle sha256:` lines are checked.
- `row 232: wrapper --create writes the first wrapper only, the same bytes wrapper ID writes, and refuses
over any file`, a symlink included. Without `--create` a missing wrapper still refuses.
- `row 232: check --units external reads the system units, the default reads the user units`.
## Mutants
`mutants.sh` in my scratch (not committed, it edits the worktree), 17 mutants: r6's re-pin list (:817-838)
minus the three `case1-repin.sh` ones, plus the brief's two. Each one applies exact replacements after a
checked backup, runs `bash -n`, runs its named killers, restores the file and checks its sha256. Two
controls run first with no change: the whole external file, and stack-copy's `row 232:` tests.
| Mutant | Killers |
|---|---|
| m01 `confirm` writes the pointer on a gate failure | `confirm polls: bus status` |
| m02 `gate_once` in place of `gate` | `confirm polls to a pass`, `stale-start is a gate code` |
| m03 PM check counts any instance | `external PM check` |
| m04 PM check drops the `startedAt` bound | `external PM check` |
| m05 `confirm` takes the newest `AWAITING-UNITS` | `open-activation` |
| m06 `rollback --to` skips the comparison | `sequence 7:` |
| m07 the default path drops `--user` from one call (`is-active`) | `user mode: every systemctl` |
| m08 the timeout writes r4's interim `rollback pending` line | `confirm polls: bus status`, `sequence 1:`, `sequence 5:` |
| m09 `AWAITING-UNITS` last, after the wrapper | `sequence 2:` |
| m10 a refusal names the open activation | `sequence 5:`, `open-activation` |
| m11 `stale-start` is a refusal | `stale-start is a gate code` |
| m12 `revert` restores without `MIGRATING` | `revert puts the wrapper back` |
| m13 `confirm` logs op `confirm` | `sequence 7:`, `confirm polls to a pass` |
| m14 `MIGRATING` after the migration | `sequence 8:` |
| m15 `gate` takes its floor from the log without `--since` | `gate (N2` |
| m16 `copy --bundle` skips the reachability check | stack-copy `row 232:` |
| m17 `wrapper --create` overwrites | stack-copy `row 232:` |
## Deviations and choices
D1. The shared helpers moved to a new file, `packages/cli/tests/stack-release-fixture.mjs`. The brief's
files list names `stack-release.test.mjs` "where a shared helper moves" but not a destination. Importing
from the other test file would run its 74 tests twice, so the helpers need a module of their own.
D2. The fixture's T17 check (what a run may never remove) now exempts a rollback set's `AWAITING-UNITS` and
`FAILED`. `confirm` removes both when it closes the activation (r6 A5 and R4). User mode writes neither, so
user-mode runs check exactly what they did.
D3. The user-mode argv and `--plan` tests read the base's scripts with `git show cb5fdb064:` rather than
storing their output as a fixture file. A later row that changes user-mode output on purpose moves the
`BASE` constant in the test to its own base, or the test fails. I chose this over a stored fixture because
a stored copy can drift from the base without anyone noticing.
D4. `marker-failed` is reached by making `state/stack-rollback` unwritable. r6 says "an existing `$RS`", but
`next_activation` never picks a taken name, so no test can make the run meet an existing `$RS`. The
exclusive `mkdir` is still what refuses.
D5. The stale-start polling case turns the enter time to `@1792540800`, not r6's `@1792540805`, with the
started line at 1792540800.900. That also checks that the floor is the whole second, so .900 doesn't push it
to 1792540801.
D6. No fake `sudo` or `case2-unit.sh`. r6's sequences ran under them because r6 had `case1-repin.sh` drive
the steps, and that script is I3's. Here the test plays root: it stops and starts the fake system units and
installs with the existing `bus-service.sh` and `webui-service.sh` stand-ins, between the real
`stack-release.sh` calls, on one log.
D7. The fake marker in the `open-activation` test is named `20000101T000000Z-<C1 prefix>` so it sorts
before the real one. A marker that sorted after it would leave m05 (newest marker) alive.
D8. m10 sets `ACT` inside `open_activation`, where r6's wording ("ACT set before the checks") lands in this
code. `ACT` is set only after every check in `confirm`.
D9. Row 200's `--sso` is a usage error under `--units external`, with the proof flags, `--no-proof` and
`--no-send`. It is a Console flag, and root's Console unit carries the Console flags; the script under
external renders no Console line and never calls `console_for_target`, so `sso_checks` doesn't run there
either. The usage text, the TOOLS.md bullet and the external usage test name it.
D10. Row 200's `config_checks` runs under `--units external` too, after `plugin_checks`, in both the
`--plan` and run paths of `activate` and `rollback --to`. It checks the target's loader against the stack
user's `config.json`, which the bus on a VM still reads, so a `console` key the target refuses would break
root's bus start the same way. The cost is one more refusal (`config-console-key`) an external run can
print; I took it over a bus that fails after root's start. The rebase changed no user-mode line: the
conflicts were the `SSO=0` globals line, the `plan` body (row 200's `SSO_PLAN` line sits in the user
branch) and the two `--plan` lines that gained `config_checks`. In the tests, row 200's fixture lines
(the loaders, `sso-config.mjs` and the `--sso` cli.mjs line for C1 and C) moved into
`stack-release-fixture.mjs` with the rest of the copy setup, and the `ALGS` import stays in
`stack-release.test.mjs`, which T57 uses.
## duckworth-0055's notes on r6 (brief line 169)
Duckworth's r6 approval (REVIEW-r5.md at 23f5520b5) carried two notes.
- N1, A2's stop timeout under `gate --since`. A unit that never stopped keeps its old `ActiveEnterTimestamp`,
so `gate --since` fails it with `stale-start`, and the run ends at exit 2 with root stopping the units.
That is intended: a unit that won't stop is a fair reason to stop. In I1 this is the gate code and its
polling (`stale-start: @1792540799 fails ...`, `gate (N2, A2f)`, m11). The run sheet's A2f line and the
re-pin test that points "exits 1 with OLD's gate passing" at an A3 exit 3 belong to I3, which owns
`scripts/stack-upgrade.sh` and `docs/guides/host-run-sheet.md`. I'll name both in the I3 build.
- N2, the claude path under `/opt/mosaic-stack` (gosalyn-0058, sage-1522). I1 doesn't touch it. Under
`--units external` the script checks no claude pin of its own (`no-claude-pin` applies only to user
mode), because root's unit carries it. The `--claude` value, the managed-settings reasoning, F6 and the
run sheet's claude step go to I2 and I3.
## Run environment
Worktree `/mnt/storage/scratch/darkwing-r232`, `TMPDIR=/mnt/storage/scratch/tmp/r232`, node from PATH.
One external file run takes about 94 s.
## Results
Singles on the rebased candidate, before the gate:
| File | Tests | Pass | Fail |
|---|---|---|---|
| `packages/cli/tests/stack-release-external.test.mjs` | 25 | 25 | 0 |
| `packages/cli/tests/stack-copy.test.mjs` | 16 | 16 | 0 |
| `packages/cli/tests/stack-release.test.mjs` (row 200's T57 included) | 192 | 192 | 0 |
| `packages/cli/tests/stack-instances.test.mjs` | 23 | 23 | 0 |
| `packages/webui/tests/pinned-copy.test.mjs` | 10 | 10 | 0 |
| `packages/cli/tests/channel-recovery.test.mjs` (imports `channel-fixture.mjs`, which names the scripts) | 1 | 1 | 0 |
| `packages/cli/tests/host.test.mjs` (reads `packages/cli/README.md`) | 42 | 42 | 0 |
These are every test file that reads `stack-release.sh` or `stack-copy.sh` (a grep of `packages/*/tests` and
`scripts/`). None of them pins a sha256 of a changed file. `packages/cli/tests/host.test.mjs` reads
`packages/cli/README.md` for a unit file name; this row only adds a paragraph there. The grep and the runs
were repeated on the rebased tree on 2026-10-21; the reader set is the same. A dry run of `mutants.sh`
matches each of the 17 replacements once on the rebased scripts.
### Gate, 2026-10-21, Sage's slot (sage-1618)
Candidate a0ffabc3f on `darkwing/row232-r1`, run by `~/darkwing-scratch/r232/gate.sh`: the cli suite, then
every `scripts/test-*.sh`, one after another, stopping at the first failure. busy.sh passed at 01:07:49Z
after Launchpad's single `stack-release.test.mjs` run ended. The gate stopped at the first one, so the
chain waited for that PID to exit and checked again. The EXIT trap removed the compose network; no
container was left.
| Step | Time (UTC) | Result |
|---|---|---|
| `packages/cli`, `node --test tests/*.test.mjs` | 01:07:50 to 01:17:53 | 561 pass, 0 fail, 0 skipped |
| `scripts/test-auth.sh` | 01:17:53 to 01:17:54 | 15 passed, 0 failed |
| `scripts/test-conductor.sh` | 01:17:54 to 01:18:00 | 17 passed, 0 failed |
| `scripts/test-config.sh` | 01:18:00 to 01:18:02 | 31 passed, 0 failed |
| `scripts/test-discord.sh` | 01:18:02 to 01:18:12 | 78 passed, 0 failed |
| `scripts/test-extension-package.sh` | 01:18:12 to 01:18:13 | 18 passed, 0 failed |
| `scripts/test-foundation.sh` | 01:18:13 to 01:19:01 | 44 passed, 0 failed |
| `scripts/test-land-work.sh` | 01:19:01 to 01:19:14 | 86 passed, 0 failed |
| `scripts/test-queue.sh` | 01:19:14 to 01:19:37 | 156 node tests pass, queue suite 28 passed, 0 failed |
| `scripts/test-release.sh` | 01:19:37 to 01:19:51 | 14 passed, 0 failed |
| `scripts/test-task.sh` | 01:19:51 to 01:20:44 | 98 passed, 0 failed |
### Mutants, 2026-10-21, same slot
`~/darkwing-scratch/r232/mutants.sh`, right after the gate. Both controls (the external file whole, and
stack-copy's `^row 232: ` tests) exited 0 on the unmutated tree. Each mutant was applied after a checked
backup and a syntax check, ran its killers from the table above, and was put back with its sha256
checked. The script stops on any restore mismatch, and it ran to its end.
| Mutant | Killer exit | Result |
|---|---|---|
| m01 pointer on gate failure | 1 | killed |
| m02 gate once | 1 | killed |
| m03 PM from any instance | 1 | killed |
| m04 no startedAt check | 1 | killed |
| m05 newest marker | 1 | killed |
| m06 --to not compared | 1 | killed |
| m07 one call without --user | 1 | killed |
| m08 interim pending line | 1 | killed |
| m09 marker written last | 1 | killed |
| m10 refusal names the open activation | 1 | killed |
| m11 stale-start refuses | 1 | killed |
| m12 revert always restores | 1 | killed |
| m13 confirm op | 1 | killed |
| m14 MIGRATING after migrate | 1 | killed |
| m15 gate floor from the log | 1 | killed |
| m16 bundle reachability unchecked | 1 | killed |
| m17 --create overwrites | 1 | killed |
17 of 17 killed, 01:22:16 to 01:24:32Z. I read the logs for m01 and m09, two of the quick kills: each failed on
its named test's assertion (m09 on `AWAITING-UNITS` in sequence 2), not on a load error.
## Round 2: rebase onto 8fede5fc6 (sage-1673)
Rebase only. Before the landing gate, a check of BASE..HEAD on the manifest paths found that row 230
(c8bc5cf4c) had changed one of them after the round 1 base, cb5fdb064. Sage ruled a re-round with a
scoped review, the same shape as row 210 round 3.
Branch `darkwing/row232-r2`: the two round 1 commits, a0ffabc3f and 84f1a91e6, cherry-picked onto
8fede5fc6 with no conflict, then this section. No code or test changed.
- `git merge-tree --write-tree origin/refactor darkwing/row232-r1` (refactor at 8fede5fc6) gives tree
837bde79236c with no conflict. The cherry-picked tip before this section has the same tree.
- `packages/cli/README.md` is a clean 3-way merge. Row 230's hunk is at lines 710-717 (the session credential
paragraph); this row's is a new paragraph at 773. With `git patch-id --stable`, the README diff from r1 to
r2 is 02b679e2, the same as row 230's README diff `cb5fdb064..8fede5fc6`. This row's README diff is
c395ecd2 on both bases (`cb5fdb064..r1` and `8fede5fc6..r2`).
- The seven code, test and doc paths other than the README hash the same as in manifest 7a3b4ea1, which
Filbert approved in round 1: `docs/TOOLS.md`, `packages/cli/tests/stack-copy.test.mjs`,
`packages/cli/tests/stack-release-external.test.mjs`, `packages/cli/tests/stack-release-fixture.mjs`,
`packages/cli/tests/stack-release.test.mjs`, `scripts/stack-copy.sh`, `scripts/stack-release.sh`.
This file also matched until this section was appended. Its other lines are unchanged.
- The one test that reads the README, `host.test.mjs:855`, looks for the unit's `EnvironmentFile=` line.
That line is unchanged context in row 230's hunk.
- No row 230 test reads `stack-release.sh` or `stack-copy.sh`, and no test of this row reads a row 230 file.
Nothing was run for this round. The landing gate runs on the r2 tip on Sage's slot.
Follow-up, doc-only (sage-1673): `scripts/case2-unit.sh:108` and `scripts/case2-precheck.sh:157` cite
`scripts/stack-copy.sh:8` and `:22` for `COPIES` and `copy_root`. With this row those lines are `:10` and
`:26`. Only comments are affected and no test reads them. Row 233 doesn't touch those scripts, so the fix
goes on the #1732 carry list.
+249
View File
@@ -0,0 +1,249 @@
# Row 233 (I3): root's upgrade driver and the host run sheet
Brief: `docs/plans/2026-10-20_installer-rows.md` section I3, blob `e61291bcf1c6` (commit a0e3d7cfd), re-pinned
by Sage (sage-1685, rev 2631) from `f35e7e57daee` (ad6f0be7a). The later blob only appends I4; the I3 text
is the same. Gitea #1735, Vikunja P19
#432. Owner Darkwing, reviewer Filbert, gate owner Sage. Design input: row 195 DESIGN r6,
`agents/darkwing/work/queue-195/DESIGN.md` at b7239bb66, sections 2, 4 and 7. Where the brief and r6
differ, the brief wins. Sage's rulings during the build: sage-1650 and sage-1652 (`--backup last`, the
`--no-rehearsal` mapping, `--rehearse-port`, hostcheck), and sage-1653 (`from_log newest`, the two external
plan refusals, the `BACKUP_STORE` line).
## Files
Base: e65a3ab4d, row 232's landing, on `darkwing/row233-r1`. The build started on ea1e85c7b
(`darkwing/row233`, row 232's candidate e6ca230d2 and its BUILD.md) before row 232 landed. The three row 232
files this row changes are identical on the two bases, so the uncommitted diff moved across unchanged.
- `scripts/stack-upgrade.sh` (new): `upgrade` and `rollback`, root's side of r6 A1-A5, A2f and R1-R4.
- `packages/cli/tests/stack-upgrade.test.mjs` (new, 20 tests).
- `docs/guides/host-run-sheet.md` (new).
- `docs/TOOLS.md`: a new subsection "Root's upgrade driver (`scripts/stack-upgrade.sh`, row 233)", plus the
row 232 lines below.
- Row 232 files, changed on Sage's rulings (the brief's files list doesn't name them):
- `scripts/stack-release.sh`:
- `rehearse --backup last` takes the newest backup the log records (`from_log newest`) and prints
`PASS backup-last`. With none it fails `no-backup`, exits 3 and makes no rehearsal directory.
A newest backup that isn't an activation id aborts `backup-unreadable`, exit 1.
- The rehearsal prints `BACKUP_STORE <activation id> schema <version>` before its migration step.
- `--plan` under `--units external` refuses `open-activation` when any rollback set holds
`AWAITING-UNITS`, and `migrate-required` for a migrating target without `--migrate`. Root reads
only the plan's exit, so these two have to be refusals there. User mode prints the same bytes.
- `packages/cli/tests/stack-release.test.mjs`: three new `row 233:` tests for `--backup last`.
- `docs/TOOLS.md`: `--backup ACTIVATION|PATH|last` in the rehearse usage, a paragraph on `last`, and a
paragraph on the two external plan refusals.
- This file.
## The brief, item by item
| Brief item | Where |
|---|---|
| Root runs from a root-owned checkout, never a copy | `head-mismatch` (A1); TOOLS.md; run sheet section 6 |
| I2's render with `/etc/mosaic-stack/<instance>.args`, `--copy` the one changed value | `read_args`, `args_with`; the args test |
| The args file changes only after a confirmed run | `prepare_args` before A2, `mv` after A5; sequences 1 and 7, m6 |
| `copy --bundle` as the stack's user before A1 | the `fetch`, `ff`, `copy` steps; sequence 1's sudo keys |
| A rehearsal before A2, a failed one refuses with nothing stopped | `rehearse`; three rehearsal tests, m4 |
| The root lock, held to the end, rollback included | `flock -n 9`; the lock test and sequence 3's probe, m1, m2 |
| A1 refuses `open-activation` | stack-release.sh's external plan; the AWAITING-UNITS test, m3 |
| `migrate-required` without `--migrate` | stack-release.sh's external plan; its test, m5 |
| The step log, 0640, one line per step | `logl`, `step`; every test checks lines against `STEP` |
| Exit codes 0-3 | `finish`, `refuse`; exit 4 is usage |
| duckworth-0055's notes | the next section |
| Root parses no stack user output beyond exits | the step log test; the one exception is sage-1653's `BACKUP_STORE` |
| The host run sheet | `docs/guides/host-run-sheet.md` |
## duckworth-0055's notes on r6 (brief line 169)
- N1, A2's stop timeout under `gate --since`. Row 232's BUILD.md left two pieces to I3. The run sheet's
A2f line is in section 6 ("When a run doesn't exit 0", exit 2). The test is `N1: a bus that outlives
A2's timeout keeps its old start`: a bus that ignores `stop` keeps its old `ActiveEnterTimestamp`,
`wait-down` logs `rc=1 ... state: active`, A2f restarts and gates, the gate fails `stale-start`, and the
driver stops both units and exits 2. Sequence 4 is the other half: an A3 exit 3 goes to A2f and exits
1 with the old copy's gate passing.
- N2, the claude path under `/opt/mosaic-stack`. Run sheet section 3 installs it there as root with
managed settings. The args file's `--claude` carries it, and `case2-unit.sh` already refuses a binary
that isn't root's or sits under the home. The driver passes the args file through and adds no claude
check of its own. F6 is I2's.
## Sage's rulings during the build
- sage-1650 and sage-1652. Root always runs the rehearsal and maps its exit:
- 0 goes on, or refuses `rehearsal-required` with `--no-rehearsal`.
- 3 refuses `no-backup`, or with `--no-rehearsal` logs `rehearsal-skipped` and goes on.
- Anything else refuses `rehearsal-failed`.
`--rehearse-port N`, default 7339. Hostcheck: a missing file or a nonzero `--check` refuses
`profile-drift`.
- sage-1653. `from_log newest`, and the external plan refusals `open-activation` and `migrate-required`.
Residual: the `BACKUP_STORE` id and schema land in root's step log, but nothing ties that backup to the
one the activation's own backup step takes later. A stopped backup before the upgrade would close that
gap; it's a later row, not this one.
## Tests
`stack-upgrade.test.mjs` runs the driver as the test's user against a scratch root, with the test seams
`STACK_UPGRADE_*`. The stand-ins are a fake `sudo` (logs its argv, checks the driver's form, then runs the
real command as the test's user, r6's B1 pattern), a fake `case2-unit.sh` render that installs and starts
the fixture units, a fake `systemctl` for root's system units, and a shell `hostcheck.sh`. The stack's
user's steps run the real `stack-release.sh` and `stack-copy.sh` from a clone made from a real bundle. The
units, copies and store are `stack-release-fixture.mjs`'s.
| Test | Covers |
|---|---|
| sequence 1 | confirm fails healthz, rollback with `--cause A5`, exit 1; every sudo call in the driver's form |
| sequence 2 | `activate --migrate` fails at the wrapper, store restored, `--cause A3`, exit 1 |
| sequence 3 | render fails, `--cause A4`; a probe run during `render-old` refuses `upgrade-running` |
| sequence 4 | stage exits 3, A2f restarts and gates the old units, exit 1 |
| N1 | a unit that won't stop: A2f's gate fails `stale-start`, exit 2, units stopped |
| sequence 5 | confirm of the wrong copy refused, `--cause A5`, exit 1 |
| sequence 6 | revert fails, exit 2; the next run refuses `interrupted-run` at its plan |
| sequence 7 | two upgrades and a `rollback --to` with `--restore`; the args file after each confirm; `to-not-previous` |
| sequence 8 | a migration that writes and fails, store back byte for byte, exit 1 |
| lock | a second run while the lock is held: one log line, no call |
| open-activation | any `AWAITING-UNITS` refuses at A1, nothing stopped |
| migrate-required | refused without `--migrate`, no rehearsal, nothing stopped |
| rehearsal-failed | the rehearsal's argv, exit 3, nothing stopped |
| `BACKUP_STORE` | the line after a match; four malformed lines log `BACKUP_STORE none` |
| no-backup | the real rehearsal with no backup refuses; `--no-rehearsal` logs the skip and goes on |
| backup-last | the real rehearsal names the newest backup; `--no-rehearsal` with one refuses `rehearsal-required` |
| args file | group write, other write, two `--copy`, an unknown flag, a symlink, `args-differ` |
| hostcheck | nonzero `--check` and a missing file refuse `profile-drift` before the plan |
| head-mismatch | checkout not at `--sha`; a commit the clone can't fast-forward to refuses `fetch-failed` |
| usage | twelve bad argvs exit 4 with no log and no lock |
The step log check (`STEP`) runs in every test that reads the log: each line must be a step line or the
result line, and none may hold the stack user's output (`stack-release:`, `PASS `, `FAIL `, `REHEARSAL`,
the fake rehearsal's text).
## Mutants
`~/darkwing-scratch/r233/mutants.sh`, not committed because it edits the worktree. It has the brief's six
mutants and two for sage-1650's mapping. The method is row 232's: a checked backup, exact replacements, a
syntax check, the named killers, then the file put back with its sha256 checked. One control runs first:
the whole `stack-upgrade.test.mjs` with no change. `DRY=1` matched each replacement once on the candidate
(2026-10-21).
| Mutant | File | Killers |
|---|---|---|
| m1 drop the flock | stack-upgrade.sh | `a second run while the lock is held`, `sequence 3:` |
| m2 release the lock when the rollback starts | stack-upgrade.sh | `sequence 3:` |
| m3 skip A1's open-activation check | stack-release.sh | `A1 refuses open-activation` |
| m4 skip the rehearsal | stack-upgrade.sh | `the rehearsal runs after the plan`, `sequence 1:` |
| m5 accept a migrating target without `--migrate` | stack-release.sh | `migrate-required` |
| m6 write the args file before confirm | stack-upgrade.sh | `sequence 7:`, `sequence 1:` |
| m7 `--no-rehearsal` accepted with a backup | stack-upgrade.sh | `refuses rehearsal-required` |
| m8 drop the `rehearsal-skipped` line | stack-upgrade.sh | `a real rehearsal with no backup` |
## Deviations
D1. The brief has A1 refuse `open-activation` for an open `AWAITING-UNITS` or a non-empty `interrupted`
set. The interrupted case refuses with row 232's existing code, `interrupted-run`; only `AWAITING-UNITS`
gives `open-activation`. Both reach root as `plan-refused`, exit 3 (sequence 6, the open-activation test).
D2. Root copies one line of the stack user's output into its log, `BACKUP_STORE`, on sage-1653. It
matches a fixed pattern first, takes the last match, logs `BACKUP_STORE none` otherwise, and decides
nothing on it.
D3. The stack user's steps carry no `--instance`. The driver takes `--instance dev` only, row 190's default
instance, which the stack user's commands select with no flag. A named instance's units are a later row.
D4. Most tests fake the rehearsal in the fake `sudo`: the fixture's stub copies can't pass a real one. Two
tests run the real rehearsal (`faults/real-rehearse`): no-backup and backup-last. `stack-release.test.mjs`
covers `--backup last` itself.
D5. Any nonzero `--plan` exit refuses `plan-refused`, exit 3, whatever the code. The stack user's output
above it names the refusal; root doesn't parse it.
D6. `rollback --restore ACT` is accepted without `--events`, as `stack-release.sh rollback` accepts it.
The brief's usage pairs them. `--events` without `--restore` is a usage error.
D7. The refusal of any `STACK_UPGRADE_*` seam when run as root has no test, because the tests don't run
as root. It is three lines at the top of the script.
D8. The fake `sudo` keeps the test's environment, where real `sudo` resets it. The driver passes
everything the stack user needs through `env PATH=...` and `-H`, and the fake checks that form.
D9. Exit 3 means nothing stopped, not nothing changed. A refusal after `fetch` leaves the stack user's
clone fast-forwarded and the new copy made. Both are the user's own state, and the next run reuses them.
TOOLS.md and the run sheet say so.
D10. The brief's usage has neither `--no-rehearsal` nor `--rehearse-port`. Both come from sage-1650 and
sage-1652.
## Decisions
- `rollback` has no HEAD check. Its target is an older copy than root's checkout, which already exists.
It fetches, copies and rehearses nothing (sequence 7).
- The new args file is written next to the old one (`.dev.args.XXXXXX`, same mode) before A2, so a full
`/etc` refuses before anything stops. It moves into place after A5. A2f and the rollback remove it; a failed move keeps it and names it.
- Every stack user step gets `--copies` explicitly, so the copies directory comes from the args file's
`--home`, never from the user's environment.
- The stack's user does the fetch, the fast-forward and the copy, so root never runs git on a tree that
user can write.
- A4 is the render, and `case2-unit.sh` starts the bus, then the Console. The forward path has no
`systemctl start` of its own; only A2f does.
## Run environment
Worktree `/mnt/storage/scratch/darkwing-r233`, `TMPDIR=/mnt/storage/scratch/tmp/r233`, node from PATH. One
whole `stack-upgrade.test.mjs` run takes about 56 s.
## Results
### Singles, run 1, 2026-10-21, Sage's slot (sage-1689)
`~/darkwing-scratch/r233/singles.sh` on `darkwing/row233-r1` (base e65a3ab4d, uncommitted tree), 03:48:20Z
to 03:59:00Z. These are the test files that read a changed script. `stack-instances.test.mjs` reads
`stack-release.sh`, so it joined the three named in the plan.
| File | Result |
|---|---|
| `stack-upgrade.test.mjs` | 20 pass, 0 fail |
| `stack-release.test.mjs` | 194 pass, 1 fail |
| `stack-release-external.test.mjs` | 25 pass, 0 fail |
| `stack-instances.test.mjs` | 23 pass, 0 fail |
The failure was my test, not the script. "row 233: --backup last takes the newest backup..." compares
`untouched(e)` before and after the run, and `untouched` includes `existsSync(e.log)`. The test took its
before snapshot ahead of `rlog()`, which writes the log, so that flag went from false to true. Every hash in
the snapshot matched. The fix takes the snapshot after `rlog()`. No other row 233 test calls `untouched`.
### Singles, run 2
`~/darkwing-scratch/r233/rerun.sh` on aacfa2d7a, Sage's slot (sage-1695), 04:13:05Z to 04:21:28Z:
`stack-release.test.mjs` whole, 195 pass, 0 fail, 0 skipped. The fixed test passed. The other three files
did not change after run 1. The gate and the mutants run next, in Sage's slot.
### Gate and mutants, 2026-10-21, Sage's slot (sage-1704)
`~/darkwing-scratch/r233/gate.sh` then `mutants.sh`, chained behind `busy.sh`, on d99166ade (code
aacfa2d7a; d99166ade changes only this file). Counts are an awk sum over `gate-r1.out`.
| Part | Time (UTC) | Result |
|---|---|---|
| cli suite (`node --test tests/*.test.mjs`) | 04:29:01-04:37:27 | 616 pass, 0 fail, 0 skipped |
| the 10 `scripts/test-*.sh` | 04:37:27-04:39:38 | selftests 429 passed, 0 failed; `test-queue`'s node part 156 pass, 0 fail |
| gate total | | 1201 pass, 0 fail, 11 suites, `GATE_PASSED` |
| c0 control | 04:39:38-04:40:35 | exit 0 |
| m1-m8 | 04:40:35-04:41:25 | 8 of 8 killed, every one exit 1 |
Every restore passed its sha256 check, and `git diff` was clean at d99166ade afterwards. The EXIT trap
removed the `darkwing-r233_default` network.
N1, found in the process sweep after the run: a `sleep 60` was left with its cwd in `packages/cli`.
`stack-upgrade.test.mjs:312` spawns `flock LOCK sleep 60`, and its `t.after` SIGKILLs only the flock, so
flock's child outlives the test by up to 60 s. It exited on its own. Row 232's T20
(`stack-release.test.mjs:370`) spawns its holder `detached` and kills the group with `process.kill(-pid)`.
The fix is the same two lines. That test is one of m1's killers.
Sage ruled it a fix now (sage-1707), and 08db6ac3f makes it, test only. After the gate's d99166ade
the only change outside this file is that test:
```
$ git diff --stat d99166ade 08db6ac3f
packages/cli/tests/stack-upgrade.test.mjs | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
```
The gate stands on Sage's ruling. The rerun is the whole `stack-upgrade.test.mjs` singly plus m1, in
Sage's slot, with a sweep for a leftover `sleep` afterwards.
+173
View File
@@ -0,0 +1,173 @@
# Row 240 (I4): root's host check, its pins and the boot unit
Brief: `docs/plans/2026-10-20_installer-rows.md` section I4, blob `4a5a65020f00` (commit b00d7d94d, rev 2665,
the pin row 240 carries; the I3 carry items came in with it). Sage split it out of I3 (sage-1650). Gitea #1746, Vikunja P19 #432. Owner Darkwing, reviewer Filbert, gate owner Sage.
Design: row 195 DESIGN r6 B2, `agents/darkwing/work/queue-195/DESIGN.md` at b7239bb66, :262-303 (the script,
the three callers) and :839-860 (files, test cases, mutants). Where the brief and r6 differ, the brief wins.
## Files
Drafted on `darkwing/row240-r1` from d99166ade (row 233's candidate) while row 233's gate ran, then applied
as one patch onto row 233's landing, 57f59e0c3, on `darkwing/row240-r2` (sage-1753). It applied cleanly; between
the two bases the only change on this row's paths is 233's N1 test fix in `stack-upgrade.test.mjs`. Row 234
then landed as c771a36f4 on 57f59e0c3. Sage preferred that the gate run with it in the tree (sage-1773), so both
commits were picked onto c771a36f4 as `darkwing/row240-r3`. That was a clean pick: the 42 paths changed from
57f59e0c3 to c771a36f4 share none with this row's 9.
- `scripts/case1-pins.sh` (new, 0644): `PIN_PROFILES_VERSION`, `PIN_PROFILE_SHA256`, `PIN_BUBBLEWRAP_VERSION`,
r6 section 0's values.
- `scripts/case1-hostcheck.sh` (new, 0755): the seven items and `--check`, `--enforce`, `--upstream`.
- `packages/cli/systemd/mosaic-hostcheck.service` (new): the boot oneshot.
- `packages/cli/tests/case1-hostcheck.test.mjs` (new, 16 tests). The cli suite's `tests/*.test.mjs` glob picks
it up.
- `docs/guides/host-run-sheet.md` section 4: the install lines that replace "Install them from row 240's run
sheet lines once it lands", plus rollback, drift recovery and `--upstream` before a re-pin window. Sections
3, 6, 8 and 10 for the I3 carry items below.
- `scripts/stack-upgrade.sh` and `packages/cli/tests/stack-upgrade.test.mjs`: I3 N1 below.
- `docs/TOOLS.md`: a new subsection "Root's host check (`scripts/case1-hostcheck.sh`, row 240)" after the
upgrade driver's.
Brief line 257 said I3's `stack-upgrade.sh` doesn't change. Sage's ruling sage-1727 widened the row to it for
N1 alone. Its default `STACK_UPGRADE_HOSTCHECK=/usr/local/lib/mosaic-stack/hostcheck.sh` is where the run
sheet installs this script.
## Carried from I3
Filbert's row 233 approve (Gitea 29922) left five notes. Sage ruled N1, N4 and N5 into this row and N2 and N3
to DEFERRED.md (sage-1727).
| Note | Change |
|---|---|
| N1: `stack-upgrade.sh` exits 1 `args-failed` when the args move after A5 fails, but exit 1 means the old copy runs | The move's failure exits 5 `args-failed`. The header, TOOLS.md's step 5 and exit line, and run sheet section 6's exit list name exit 5 and what root does: move the new file the result line names into place. Test `row 240: I3 N1: a failed args move after A5 exits 5 ...` makes `/etc/mosaic-stack` read-only at confirm and checks exit 5, the step names, `args rc=1`, the result line, both units on the new copy, the old args file's bytes and the new file beside it. The step log regex takes `exit=5`. Mutant M8. |
| N4: section 3's `npm install` has no npm on root's PATH, and the `--claude` path is unnamed while `node_modules/.bin/claude` is a symlink `case2-unit.sh:140` refuses | The install line runs with section 2's pinned node first on PATH (the postinstall runs `node` too). The `--claude` path is `node_modules/@anthropic-ai/claude-code/bin/claude.exe`, and a `--version` line catches the postinstall's 500-byte stub. |
| N5: sections 8 and 10 lack precheck lines | Each gets "Precheck line: none" and its missing line. |
Exit 5 is new rather than 2 because 2 means both units are stopped, and here both run. Exit 4 stays usage.
The `claude.exe` path comes from the 2.1.304 package itself: `npm view` gives `bin.claude` as `bin/claude.exe`,
and its `install.cjs` hardlinks the platform binary over that placeholder, or copies it across devices, then
sets 0755. I downloaded the tarball into `/mnt/storage/scratch/tmp/r240/cc304` and read it; nothing ran. The
run sheet says a newer pin needs the same check.
## The brief, item by item
| Brief line | Where |
|---|---|
| 247 `case1-pins.sh` holds the three pins | `scripts/case1-pins.sh`; the pins test checks the values against r6's |
| 248-249 seven items, `--check` exits 0 or 1 | `items()` and the final dispatch; the check tests |
| 250-251 `--enforce`: `apparmor_parser -R`, `root:root 0700`, `DRIFT`, a log line, exit 0 | `enforce()` and `stop_bwrap()`; the four enforce tests |
| 252 `--upstream` compares the current deb to the pin | `upstream()`; the upstream test |
| 253 the boot oneshot | `mosaic-hostcheck.service`; the boot unit test |
| 254-255 fakes for every host tool, r6's seven case groups, seven mutants | the test file; Tests and Mutants below |
| 257 the I3 run sheet item points here | run sheet section 4 |
## Tests
Fakes on PATH, each logging its argv to `$HC/calls.log`: `aa-status`, `dpkg`, `dpkg-statoverride`,
`apparmor_parser`, `systemd-run`, `stat`, `chown`, `chmod`, `apt-get`, `dpkg-deb`. `host(t)` builds a scratch
root (`/etc`, `/proc/sys`, `/usr/bin/bwrap`, `/var/log`) and a lib dir with a copy of the script and fixture
pins that hold the fixture profile's sha256. Every run passes `--root`.
r6's case groups (:843-856) against the tests:
| r6 case | Tests |
|---|---|
| `--check` exits 0 with all seven, 1 with each failing alone, its line naming it | `check: exits 0 ...`, `check: exits 1 with each item failing alone ...`, plus one test per item for the item's edge cases |
| item 3: a second file naming `bwrap` as a word, `/{usr/,}bin/bwrap` included; passes with the profile alone | `check: item 3 ...` |
| item 4: `unpriv_bwrap (enforce)` or a trailing space fails; the argv carries `-p NoNewPrivileges=yes` and the 2a' properties | `check: item 4 ...` checks the whole argv |
| item 7: the floor at 732 fails | `check: item 6 ...; item 7 ...` (733 passes as the control) |
| `--enforce` exits 0 on a pass and every failure; unload, close, `DRIFT`, one log line; `DRIFT` present with every item passing still closes | `enforce: a pass ...`, `enforce: each failure ...`, `enforce: with DRIFT present ...` |
| `--enforce` never calls `dpkg-statoverride` with `--update`, `--add` or `--remove` | `enforce: no unload ...; never writes the statoverride` (every call is `--list /usr/bin/bwrap`) |
| a bubblewrap version other than the pin is a line, not a failure | `check: a bubblewrap version other than the install pin ...` |
| `--upstream` against a differing deb prints the difference, exits 0 | `upstream: ...` (also: a match, a deb with no profile, a failed download) |
| the unit: `Type=oneshot`, `After=apparmor.service`, `[email protected]`, runs `--enforce` | `boot unit: ...` |
The last test also checks that the repo script reads `scripts/case1-pins.sh` beside it, that bad usage exits
2, and that a non-root run without `--root` refuses.
Singles are the two files that run a changed script: `case1-hostcheck.test.mjs` and `stack-upgrade.test.mjs`.
Sage asked (sage-1759) whether `stack-release.test.mjs` or `stack-release-external.test.mjs`, which share
`stack-release-fixture.mjs`, run `stack-upgrade.sh` or `case1-pins.sh`. They don't. `grep -cF` for
`stack-upgrade`, `case1-pins`, `case1-hostcheck` and `hostcheck` gives 0 in all three files; the control
`stack-release.sh` gives 2 in the fixture and 8 in `stack-release.test.mjs`. `scripts/stack-release.sh` names
`stack-upgrade.sh` once, in a comment (:2061). The gate's cli suite runs both files anyway.
## Mutants
r6's seven (:858-860) and one for I3 N1, scripted in `~/darkwing-scratch/r240/mutants.sh` with r233's method: a checked backup,
an exact replacement that must match once, `bash -n`, the named killer by `--test-name-pattern`, the restore
and a sha256 check. A dry run matched all eight once each. Results below.
| Mutant | Change | Killer |
|---|---|---|
| M1 | `--enforce` exits 1 after the stop | `enforce: each failure exits 0 ...` |
| M2 | `[ ! -e "$DRIFT" ] \|\| had=1` becomes `true \|\| had=1` | `enforce: with DRIFT present ...` |
| M3 | item 1's `dpkg -S` becomes `owner=""; rc=1` | `check: item 1 fails when a package owns ...` |
| M4 | item 3's `grep -rlw bwrap` becomes `grep -rlF /usr/bin/bwrap` | `check: item 3 ...` |
| M5 | item 4's `[ "$label" = "$LABEL" ]` becomes a substring match | `check: item 4 ...` |
| M6 | item 4 drops `-p NoNewPrivileges=yes` | `check: item 4 ...` (argv) |
| M7 | item 7's `-gt` becomes `-ge` | `check: item 6 ...; item 7 fails with the floor at 732 ...` |
| M8 | `stack-upgrade.sh`'s `finish 5 args-failed` becomes `finish 1 args-failed` (I3 N1) | `row 240: I3 N1: a failed args move ...` |
## Deviations
- Repo name `scripts/case1-hostcheck.sh` (r6), installed as `/usr/local/lib/mosaic-stack/hostcheck.sh`, the
name in brief line 239 and I3's `stack-upgrade.sh` default. r6's apt hook line says `case1-hostcheck.sh`;
the run sheet's hook uses the installed name.
- `stat` is a fake too. r6 lists seven fakes; a test can't make a root-owned file, so item 5's `stat` reads a
fixture.
- Item 7 with no Console unit installed reads the port as 1023 and says so ("1023, because no Console unit
is installed"). Until I2 installs the unit the floor must be above 1023. A unit with no `--port` fails.
- Item 4 runs under `timeout 60`. A hung `systemd-run` would otherwise hang the apt hook and the boot unit.
## Decisions
- Fail closed where r6 is silent:
- pins with no 64-hex sha fail item 1;
- `dpkg -S` exit 1 (no owner) passes, exit 0 fails with the owner, any other exit fails with the code;
- a failed `aa-status` fails item 2 and counts as loaded, so `--enforce` tries the unload.
- `--root DIR` is the test seam (r6 :840). Root refuses it; a non-root user needs it. Both exit 2. Root runs
with `PATH=/usr/sbin:/usr/bin:/sbin:/bin`.
- `DRIFT` is append-only (`<stamp> <failed line>`), and `--enforce` never removes it. The log line is written
only when the stop runs: `<stamp> enforce stop items=<ids or none> drift-before=<0|1> unload=<rc=N|no-file|skipped> chown=N chmod=N`.
- `--upstream` exits 1 when the download or the unpack fails, since it then compared nothing. A deb without
the profile file is a difference (exit 0), per r6 :302-303.
- The bubblewrap note reads the version from `dpkg -s bubblewrap`.
- The run sheet's recovery steps (`chown root:mosaic`, `chmod 0750`, `apparmor_parser -r`, then `--check`
before root removes `DRIFT`) are mine. r6 says only that root removes `DRIFT` in a reviewed step.
## Run environment
Worktree `/mnt/storage/scratch/darkwing-r240b`, branch `darkwing/row240-r3` on c771a36f4, with `node_modules`
linked to the canonical checkout's. Node v26.8.1, kernel 7.2.2-1-cachyos, 16 CPUs, TMPDIR
`/mnt/storage/scratch/tmp/r240`, compose project `darkwing-r240b`. Sage's slot (sage-1773, 06:16Z). Each run is
one chain behind `busy.sh`, launched from `/`: `~/darkwing-scratch/r240/run-all.sh` checks the HEAD pin and a
clean tree, runs the singles, then the mutants (8 killed, none surviving, the tree clean after), then the gate
(the cli suite and every `scripts/test-*.sh`), each stopping the chain on failure, then sweeps `/proc` cwd links
for anything left in the r240 trees.
## Results
Run r1, 06:17:10Z on 47ec9fe2f, failed at the singles. `case1-hostcheck.test.mjs` passed 16/0/0, and
`stack-upgrade.test.mjs` went 20 pass, 1 fail. The failure was the N1 test's own `t.after` hook, which ran
`chmodSync(e.etc, 0o755)` after the fixture's cleanup had already removed the scratch root, so it threw ENOENT.
Every assertion in the test body had passed, exit 5 included. The fixture's cleanup runs `chmod -R u+w` on the
root before `rmSync`, so the 0555 dir never needed the hook. b45852daa drops it and changes nothing else. The
mutants and the gate didn't run. The sweep found nothing left. Sage counted r1 as this slot's one failure
(sage-1788).
Run r2, 06:20:41Z to 06:33:28Z on b45852daa, passed. The counts come from awk over the .out files
(`~/darkwing-scratch/r233/sum.sh`):
| Step | Result |
|---|---|
| `case1-hostcheck.test.mjs` | 16/0/0 |
| `stack-upgrade.test.mjs` | 21/0/0 |
| c0 control | exit 0 |
| M1-M8 | 8/8 killed, none survived, every restore's sha256 matched, tree clean after |
| `packages/cli` suite | 639/0/0 |
| `scripts/test-*.sh` | auth 15, conductor 17, config 31, discord 78, extension-package 18, foundation 44, land-work 86, queue 156 node + 28 selftest, release 14, task 98; 585 pass, 0 fail |
| Gate total | 11 suites, 1224 pass, 0 fail |
| Sweep | nothing left in the r240 trees; the compose network was removed |
This commit adds only these BUILD.md lines. No test reads BUILD.md.
+310
View File
@@ -0,0 +1,310 @@
# Row 234 (#436a row a, Gitea #1740): host enrollment, store records and bus schema 3i to 3j
Gizmoduck, 2026-10-21. Reviewer Duckworth, gate owner Sage. Lands by 10-25, before the .53 and .54 installs
(mos-1134).
- Brief: `docs/plans/2026-10-20_host-credentials.md`, section "Machine grant row a: enrollment, store records
and migration 3i-3j", blob 64451be5 (queue rev 2617).
- Design: `agents/gizmoduck/work/machine-grant/DESIGN.md` r6, f9039682e on `gizmoduck/machine-grant-design`,
approved by Duckworth (duckworth-0064, REVIEW-r6.md at 8ea792256). Not reopened here. Cites "r6:" are lines
of that file.
- Grant event rule: #436b section 7, fa5658941 on `megavolt/row199`,
`agents/megavolt/work/436b/DESIGN.md:431-490`, file sha256
96510b83778e79a71b33d0d5f6fd0c806dfe74f6de315ff4a9f1fbe5620f6c69, approved by Duckworth (duckworth-0067).
- Base: origin/refactor 8fede5fc6, with rows 199 (cb5fdb064) and 210 (f5753c0e8) landed. Branch
`gizmoduck/row234`. Code cites are `path:line` at 8fede5fc6 unless they say otherwise.
## Names: r6 against section 7
r6's `grant.*` and `host.enroll.*` names are `body.action` values on `action.allowed` events, not event
kinds. The `events` kind CHECK isn't rebuilt. Four r6 names move under `grant.`:
| r6 | This row |
|---|---|
| `host.enroll.step` | `grant.host.enroll.step` |
| `host.enrolled` | `grant.host.enrolled` |
| `host.checked` | `grant.host.checked` |
| `host.removed` | `grant.host.removed` |
Only the consume record carries `decision`: `{operation: 'grant.host-enroll', decision}`. Every other
guarded body names the decision `grantDecision`, so r6's `decision` field in step, enrolled and failed bodies
is `grantDecision` here. The actor of every guarded event is `actor_role` NULL with `actor_run` 'grant',
and `body.by` names the caller the broker checked.
## Split (r6 section 12 Q-S4)
Row a builds:
1. **Schema 3i to 3j.** `migrations/3i-3j.sql` adds `hosts` and `host_grants` (r6 S1, primary keys
(business, host) and (business, agent, host)) and the guard trigger: an `action.allowed` whose
`body.action` or `body.operation` GLOBs `'grant.*'` aborts unless the actor is (NULL, 'grant').
`SCHEMA_VERSION` 3j, a migration test with a step that fails partway.
2. **Grant event writer.** A private session-less broker writer for guarded events. `#event` doesn't change.
`recordEvent` refuses `reserved-event` for the guarded namespace.
3. **The `grant` raise frame.** `decision.raise` takes `grant` only with `credential.mint`, only from the chief
(`grant-chief-only`), and in row a only with `method: 'host-enroll'`. Any other method refuses
`invalid-request` until row b adds `host`. E1's checks, the `ssh-keyscan` set compare before the raise
transaction, and the rendered block.
4. **Enrollment executor.** `#consumeGrant`, the start on the human's cli yes (X1), E2 steps 1 to 4, the
installer key at `<configDir>/secrets/hosts/<business>/<hostId>/installer`, the failure cleanup, and the
start reconcile for enrollments: the `registry` step undo, and a resolved-yes unspent enrollment ending
`grant.failed interrupted` (X3).
5. **`hosts.check` and `hosts.remove`** (E4), with `mosaic hosts status|check|remove`.
6. **`tools/host/`**: `mosaic-keys`, `mosaic-keys-write`, `mosaic-keys.sudoers`, `mosaic-keys.sshd.conf` (E3).
7. **`host.run` on the store** (X4 first and third bullets): it reads the `hosts` and `host_grants` records
and the Approve record check, and at start the runtime gives `Credentials` a reference per `host_grants`
record. The loader refuses `hosts` at the top level and in a role (S1). Row 210's tests move to store
fixtures.
8. **Reader filter** (duckworth-0067 N1): every grant-evidence reader in this row (status, the start
reconcile, `openGrantDecisions`) filters `kind = 'action.allowed' AND actor_role IS NULL AND actor_run =
'grant'`, with a test that an `action.refused` look-alike and a session-written one are ignored.
`hosts.remove`'s `hostLineLeft` reader is one of them (duckworth-0079 M4).
Row b keeps the `host` method's raise binding (R4), the grant executor (X2), the grant rows of X3, X5,
`Credentials.register`/`claim`/`abortClaim`, revoke (section 6) and `sweep`. So row a has no way to make a
`host_grants` record outside a test fixture, and from its landing to row b's no agent holds a host grant
(r6 N9, the run sheet strips row 210's keys).
Tests in row a (r6 section 10): G1 and G5 for the `host-enroll` raise, G6 and G10 for the enrollment
executor, G11's enrollment cases, G14, G15, G16, G17, G18, G20, G21 (its `host-line-left` case reads an
event fixture until row b writes `hostLineLeft`), G25, the migration test, the trigger cases, the reader
filter test, and row 210's T-tests on store fixtures. Row b: G2 to G4, G7 to G9, G11's grant cases, G12,
G13, G19, G22 to G24, G26 to G28.
## Early check items (duckworth-0079, REVIEW-build-split.md 10672772e on `duckworth/reviews`)
Moves into row a:
- M1. G4 for the `host-enroll` block (r6:240-242): a question can't forge the block, and the 4096-character
limit holds with the block appended. G4 for the `host` block stays in row b.
- M2. The tag function (r6:265-266), because E2 step 2 tags the installer line. G24's first sentence moves
here: names of 64 characters with `.` and `_` give distinct tags of the fixed length. The `sweep` half
stays in row b.
- M3. A store-fixture test that `host.run` refuses `host-not-granted` for no record, an unresolved decision,
a decision resolved by an arbiter, the tester or an agent or not via cli, a consume record with operation
`grant.host-enroll` in place of `grant.host`, and an unspent decision (X4 first bullet, r6:593-596). A
restart test that a `host_grants` fixture gives `Credentials` a reference (X4 third bullet).
G21's `host-line-left` case:
- F1. The fixture goes through this row's private grant writer in the real shape: `action.allowed`, actor
(NULL, 'grant'), `body.action` 'grant.failed', `body.host`, `body.hostLineLeft`. No raw SQL around the
trigger.
- F2. `hosts.remove` reads `body.hostLineLeft` from any guarded body, not only `grant.failed`.
- F3. Decided: a guarded body that carries `hostLineLeft` also carries `host`. Row b adds `host` to
`grant.revoke.closed`, so its body is `{tag, host, hostLineLeft}` (r6:716 has `{tag, hostLineLeft}`).
The reader matches on `body.host`, and doesn't map tags back to hosts through `grant.step`. A direct
field is one equality in SQL, and a tag lookup would add a second reader that the N1 filter also has to
cover.
- F4. Row b carry: "no later `sweep` cleared it" (r6:403) needs `grant.sweep`, which only row b writes. Row
b adds the sweep clear to `hosts.remove` with a G21 case, and its G8 or G13 shows `hosts.remove`
refusing after a real `hostLineLeft` write, not only after the fixture. Until then row a's check is "any
guarded `hostLineLeft` for this host refuses".
## Mutants (r6:838-883, numbered here)
Row a, killed on this row's tree:
| # | Mutant |
|---|---|
| A1 | drop the chief check |
| A2 | start the executor on a `credential.mint` with no `grant` |
| A3 | start it on an arbiter resolve |
| A3b | start it on a `cli` yes by someone other than the human (duckworth-0083 N8) |
| A4 | start it on `no` |
| A5 | skip the keyscan compare |
| A6 | compare only one key |
| A7 | write the private key into the event body |
| A8 | drop the "not yet enrolled" check in E2 step 1 |
| A9 | drop the `hosts` primary key |
| A10 | skip the start check for a resolved, unspent enrollment decision |
| A11 | `host.run` reads the business file instead of the grant record |
| A12 | build the tag without the business |
| A13 | drop `from=` in the writer |
| A14 | let the writer touch a line outside the tag pattern |
| A15 | drop `O_NOFOLLOW` on the user file |
| A16 | drop one group from the writer's refused list |
| A17 | drop the user pattern check before the path use |
| A18 | treat a `privileged` entry as enough without `users` |
| A19 | let the writer accept a sudo user not in `privileged` |
| A20 | trigger: drop the `body.operation` arm, so the consume record passes from a session |
| A21 | trigger: accept actor_role NULL with any actor_run |
| A22 | a grant reader drops the `kind = 'action.allowed'` or actor filter. The runner splits it: A22a drops the kind, and G21's `action.refused` look-alikes kill it. A22b drops the actor, which the trigger can't stand in for, because 3i-3j adds the trigger without checking old rows. Its killer is the grant-migration test "A22b, sage-1703": a session-written `grant.host.removed` with `hostLineLeft` and a `grant.host-enroll` row from 3i, migrated to 3j, where `hosts.remove` still removes the host and `reconcileGrants` ends nothing |
| A23 | `host.run` skips the Approve record check (any `host_grants` record is enough) |
Row b keeps the rest of r6's list: the `ready` check, the three hash compares, the definition check, the
install and record order, `remove` on a verify failure, both start sweeps, the reconcile's remote `remove`,
the `grant.step` before install, revoke leaving the record, `register` before `issue`, the abort check and
the `await` before `issue`, revoke and an unreferenced claim, `sweep` and any `mosaic-` line, revoke's host
step order, claim before consume, the resolved unspent grant decision, an async `commit`, `hosts.revoke`
through `credential-verbs.mjs`, the revoke marker reconcile, the upsert, the undo past `grant.revoked`, the
abort after step 5 and a missing `bus.tester` as dev.
## Broker functions this row touches (sage-1677)
For row 236 (Megavolt), which rebuilds on this landing. Old lines are `packages/bus/src/broker.mjs` at
8fede5fc6, new lines are at the last commit that changed this file (they moved by 10 after `runEnrollment`
went in). The planned list is kept in this file's history (827b89ea6).
Changed:
- `constructor` (:279): one more option, `hostGrant`, the object `host-grant.mjs` builds.
- `recordEvent` (old :1243-1251, new :1606): one more refusal. An `action.allowed` whose `body.action` or
`body.operation` is a `grant.*` name refuses `reserved-event`.
- `request()` (old :1362, new :1726): two routes ahead of the sync path, a `decision.raise` with `grant` to
`#raiseGrant` and the `hosts.*` verbs to `#requestHosts`. Both are async.
- `requestHost` (old :1712-1795, new :2079): the business-file lookups (old :1730-1736) become one
`#hostAccess` call, in the same transaction as `#agent`. `commands` and `entry` come from it.
- `#dispatch` (old :1796, new :2160) gets a fifth parameter, `enroll`, which only `#raiseGrant` passes.
- `decision.raise` (new :2244): `grant` joins the allowed keys, and `grant` without `enroll` (or the
reverse) refuses `invalid-request`. The `host.sudo` block reads `#hostAccess` in place of `b.hosts`
and the role's `hosts`, and an undefined `target` still refuses `host-unknown`. `validArgv` and
`validStdin` now run after the access check, so a malformed argv for an ungranted host refuses
`host-not-granted` first. The enrollment block is appended to the question before `string(question)`,
so the 4096 cap counts it (M1). The decision's raise body carries `grant: enroll`.
- `decision.resolve` (new :2409): when `op` is `resolved`, `#approvedGrant` reads the decision, and a
`host-enroll` grant starts `#runEnrollment` from `setImmediate`, after the transaction commits. The
result adds `grant: 'started'`.
New, all private but two:
- `#grantEvent` (:657), the writer of guarded events. It runs the secret check, then `writeGrantEvent`.
- `#approvedGrant` (:667), `#consumeGrant` (:678). `#consumeGrant` sits after `#approvedGrant`, not next
to `#consumeAuthority` as planned.
- `#hostRecord` (:685), `#hostAccess` (:692), `#grantNotice` (:708), `#enrollFailed` (:736),
`#runEnrollment` (:750), `#openGrantDecisions` (:823).
- `runEnrollment` (:745), public, a trusted entry to `#runEnrollment` for G10. No verb reaches it.
- `reconcileGrants` (:833), public, which `runtime.mjs` calls once at start.
- `#raiseGrant` (:866), `#hostsCaller` (:886), `#requestHosts` (:900), `#hostLinesLeft` (:964).
Module level, new (:184-230): `GRANT_RUN`, `grantName`, `GRANT_EVIDENCE` (the N1 filter as SQL),
`registrySha256`, `writeGrantEvent` (exported, the one writer of guarded events, which test fixtures use
for F1), `HOSTS_VERBS`, `fingerprintOf`, `INSTALLER_COMMENT`.
Unchanged: `#event`, `#spent`, `#consumeAuthority`.
Other `src` files:
- `bus/src/host-grant.mjs`, new: the keyscan, the installer key pair, and `hosts.check`'s `list`.
- `bus/src/runtime.mjs`: builds `hostGrant`, calls `reconcileGrants` before the server starts, and adds
the `host_grants` references before `new Credentials` (X4 third bullet).
- `bus/src/host-run.mjs`: `runHost` takes `command`, sent as is in place of the sudo argv, for the
installer's `list`.
- `bus/src/server.mjs`: the `hosts.*` verbs and a raise with `grant` go down the async path.
- `bus/src/store.mjs`: the 3i-3j step, `SCHEMA_VERSION` 3j. `bus/src/credentials.mjs` exports
`privateDir`.
- `business/src/business.mjs` and `business/src/hosts.mjs`: the loader refuses `hosts` (S1).
`checkHosts` and `checkHostGrants` are gone.
- `cli/src/hosts.mjs`, new, routed from `cli.mjs` and `scripts/mosaic`.
## Decisions taken in the build
- **`#hostAccess` binds the agent and the host.** X4 says the Approve record check passes when the
decision is an approved `host` grant with a `grant.host` consume. The check also requires the grant's
`agent` to equal the session's role and its `host` to equal the requested host. Without that, an
approved grant for one agent and host would vouch for any `host_grants` row that names its decision.
Cost: none for row b, whose executor writes the row from the same grant.
- **Store fixtures are written the real way.** `bus/tests/host-fixtures.mjs` inserts the decision rows by
SQL, but every guarded event goes through `writeGrantEvent`, so the trigger sees it (F1). An enrollment
fixture writes `grant.host.enrolled`, or the start reconcile reads it as interrupted.
- **The resolver test for host kinds is gone.** `business/tests/hosts.test.mjs` lost its D5 case, because
the loader now refuses `host.<id>` credential refs. The `isHostKind` filter in `business/src/resolve.mjs`
can't be reached now. I left it in, since row b may want it back, and it's one line.
- **Store before Credentials.** The runtime already opened the store before `Credentials`. The
`host_grants` references depend on that order, and the comment there says so.
- **CLI.** `mosaic hosts` prints tab lines, and `--json` prints the bus rows. Usage errors exit 4 and bus
refusals exit 2, as `mosaic credentials` does. There is no enroll command: enrollment is the chief's raise.
- **Host tools tests live in the bus suite.** `bus/tests/host-tools.test.mjs` loads the writer by path
through a Python harness under `python3 -B -I`. The harness passes its own key dir, config dir, owner
uid and user, group and sudo lookups, and runs `mosaic-keys` from a copy with stub `sudo` and `logger`.
The gate runs the bus suite, so G16 and G25 run there. The real `pwd`, `grp` and `sudo -l` paths are
checked on the #433 VM, not here. The test also runs `visudo -c` on the sudoers file and `sshd -T` on
the drop-in.
- **Writer details the design left open.** The `from` file must hold exactly one value matching
`[0-9A-Za-z.:/*?!_-]+(,[0-9A-Za-z.:/*?!_-]+)*`, so it can't close the quote. `users`, `privileged` and `from` must be
regular, owned by the owner uid and not group or world writable, and are opened without following a
symlink. Lines starting with `#` are comments. A refusal exits 1 with its code on stderr and in syslog.
`add` of a tag that is already there exits 0 and writes nothing.
- **`runEnrollment` is public for G10 (duckworth-0081 B1).** G10 needs a second start for a spent
decision, and the resolve path can't give one: a second resolve refuses `decision-closed`
(broker.mjs:2416) before the executor runs. So
`#runEnrollment` returns its code (`enrolled`, `host-enrolled` or the refusal), and a public
`runEnrollment` calls it, as `reconcileGrants` is public. The test checks a spent
decision gives `decision-consumed`, and an open or agent-resolved one gives `not-authorized`, with
nothing written. That second case also reaches the `via` and `by` checks in `#approvedGrant`, so A3 is
no longer vacuous (N1).
Who can call it (sage-1692): only code holding the `Broker` object, which is the bus process and the
tests. The one `new Broker` in `src` is `runtime.mjs:126`, and no `src` file calls `runEnrollment`;
the resolve path calls the private `#runEnrollment` from `setImmediate` (broker.mjs:2445). A seat
holds no `Broker`. It reaches the bus only over `broker.sock`, where `server.mjs` calls a fixed set of
methods by name (`request` at :134 and :140, `identity`, `disconnect`). `request()` routes on the verb
through the `#dispatch` switch, and neither file looks a method up by name (no `this[` in either), so no
verb string can reach `runEnrollment`.
- **A `sudo -l` that times out refuses `user-sudo` (N2).** `_sudo` returns an empty answer on
`TimeoutExpired`, which isn't the expected line, so `main` logs the refusal and exits 1.
- **Keyscan comment (N3).** A scan that can't start refuses `host-failed`. One that runs and finds no key
returns no lines, and the raise refuses `host-key-mismatch`. The comment in `host-grant.mjs` now says so.
- **No tag on in-run `grant.failed` (N4).** r6:578 tags only the `interrupted` one, so the code follows it.
- **Run sheet (N5).** The writer refuses `config-privileged` when the file is missing, so each host needs an
empty, root-owned `/etc/mosaic-keys/privileged` (0644) before the first enrollment.
## Mutant results (Sage's slot, 2026-10-21)
All 25 killed: A1 to A23, with A3b added for duckworth-0083 N8 and A22 split into A22a and A22b. The
runner is `mutants.py` outside the repo. Each mutant gets its own session, a 420 s limit with a group kill,
and a byte restore checked against git status. None hung. Run 1 (04:24Z, on 95f966b1c) killed A1 to A22a
and stopped at A22b, which survived. Run 2 (04:43Z, on 097892741) killed A22b and A23.
- Each kill comes from a named assertion in the mutant's own test files, except A9. Dropping the `hosts`
primary key leaves `host_grants`' foreign key with no parent key, so the store refuses to open
("foreign key mismatch") and 28 of 29 tests fail at setup. That's a structural kill, not the S1 test.
The schema can't exist without the key (sage-1709).
- A22b drops the actor filter from `GRANT_EVIDENCE`. The trigger can't stand in for it, because a 3i store
can hold a session-written grant row that the 3i-3j step keeps. The grant-migration test "A22b,
sage-1703" kills it (see the A22 row above).
Follow-up, not in row a: G18's control calls `runHost` directly, so nothing runs `hg.check` end to end
with a good pin (duckworth-0083 N9). The #433 VM run covers it.
## Status at 097892741
Done: every Split item, 1 to 8, duckworth-0081 B1 to B3, duckworth-0083 N8, and all 25 mutants killed.
Single files in the slot, all exit 0, 178 pass and 0 fail by an awk sum over 23 logs: bus grant-enroll
24, host-run 24, grant-migration 6, host-tools 6, account-migration 3, chief-of-staff 18,
comment-migration 5, hard-waits 13, name-migration 1, project-runtime 3, spec-migration 1,
thread-migration 3, usage-migration 1, project-files 8, spec-socket 6, spec-review 12, land 17, spec 4;
harness spec-tools 2, raise-domain 2; business hosts 4; cli hosts 5; seat sandbox 10.
Left: Sage runs the whole bus, business and cli suites and every `scripts/test-*.sh` at the landing gate,
after Duckworth approves.
## Round 3 (sage-1742): two tests that count the schema
The landing gate failed `packages/bus/tests/schema.test.mjs:522`. It counts the tables and triggers that
`schema.sql` creates, and row a adds two tables and a trigger, so the counts go from 16 to 18 tables and
from 70 to 71 triggers. My singles didn't run it because I took it for a test of other rows' tables.
I grepped `packages/*/tests/*` for `sqlite_master` and `schema.sql`, with `schema.test.mjs:517` as the
positive control. That turned up one more test that would fail: T46 in
`packages/cli/tests/stack-release.test.mjs:910`. It puts every schema table in exactly one of four lists, and
hosts and host_grants were in none. I'd left the stack-release tests out of the r2 singles on the claim that
they don't load the bus, but T46 reads `schema.sql`. Both tables go in its untimed list (Sage's ruling,
sage-1745: the message_threads pattern). Neither has an `at` column. Row a writes hosts at five lines,
and each runs in one store transaction with a `grant.*` events row, which the restore guard counts.
`#grantEvent` (broker.mjs:657) writes that row through `writeGrantEvent` (:201, INSERT INTO events at
:212).
- :737 DELETE, in `#enrollFailed`, with grant.failed at :738. Both callers wrap it in a transaction,
:816 and :849.
- :763 INSERT, in the step 1 transaction (:754), with grant.host.enroll.step at :761.
- :783 UPDATE installer_fingerprint, in the step 4 transaction (:780), with grant.host.enrolled at :784.
- :932 DELETE, in the hosts.remove transaction (:927), with grant.host.removed at :933.
- :950 UPDATE state ready, in the hosts.check transaction (:944), with grant.host.checked at :951.
Row a writes no host_grants rows. It only reads them (broker.mjs:695, :930, runtime.mjs:103). Row b adds
the writes, and each must share a transaction with a `grant.*` event the same way. That goes on row
235's carry list. The other hits pin an older schema
(account, comment, name, spec, thread and usage migrations, chief-of-staff 3c) or count only their own
tables (state.test.mjs:106 reads every table but pins no list, threads.test.mjs:129).
hosts and host_grants are mutable by design, so the refused-labels loop above `schema.test.mjs:517` gets no
entry for them. hosts.check updates a host to ready. hosts.remove and a failed enrollment delete the
record. Row b updates and deletes grant records. Neither table has append-only triggers. The one new
trigger, events_grant_actor, guards events. grant-migration.test.mjs tests it, and it killed mutants
A20 and A21.
+104
View File
@@ -0,0 +1,104 @@
# Row 230: C2-13a unit render and host precheck
Gitea #1732. Vikunja #416 (track T2). Brief: docs/plans/2026-10-20_case2-rows.md, section "C2-13a",
blob f6b4933f0f47 (01b40d357, sage-1562). Base: 00d4cc1f6 (row 190). Reviewer: Webby. Gate: the cli
suite plus every scripts/test-*.sh.
## What ships
`scripts/case2-unit.sh` renders `[email protected]` and `mosaic-webui.service` from the two existing
templates into the system unit directory, for the default instance only. It writes each unit through
a temp file at mode 0644, runs `daemon-reload`, enables both units, starts the bus and then the
Console. If the bus fails to start, the script refuses and leaves the Console unstarted. Both units
get `WantedBy=multi-user.target`, and neither gets `After=` or `Wants=`. The script never links a unit
file into place. A re-install refuses unless each installed unit passes the same checks, both units
name one copy, and each equals the render apart from the copy id.
`--profile library` (the default) refuses `--pm`, `--claude`, `--tester-port` and `--sso`, and needs
the preview proxy flags plus the proof pair. `--profile factory` refuses the six proxy flags and takes
`--pm` and `--claude`. Both refuse `--checkout` and `--board`.
`scripts/case2-precheck.sh --business ID [--profile factory|library]` runs as `<user>`. It prints one
`ok` or `REFUSED` line per check and exits 1 if any check refused. It prints variable names only,
never values.
`packages/cli/README.md:713` now says nothing creates the session env file, only the Case 2 precheck
reads it, and the precheck prints only variable names (webby-0034 N2, webby-0035).
## Deviations for review
Sage accepted all six for the build (sage-1570). Webby rules on each.
1. The precheck takes `--business ID`. The brief names no other way to find the business file.
2. A run root `<dataRoot>/launches/<business>` that doesn't exist passes, with a note that the first
launch creates it at 0700 (session.mjs:47 and newRun). A symlink refuses, and so does an existing
entry that isn't a 0700 directory.
3. The claude marker check passes when the reply holds the marker and input plus cache tokens are at
most 2000. **The 2000 ceiling is unmeasured.** The tests use fakes at 40 and 9000 tokens. Measuring
it on a real claude belongs to row 195's install run, not this row.
4. A non-root test can't create a root-owned proof file. For the "proof not owned by the user" case,
the fake getent reports another uid for the user, so the user's own file reads as someone else's.
5. The unit script refuses `%h` left in any unit, rendered or installed. A system unit expands `%h`
to root's home. The render replaces every `%h`, so the test edits an installed unit to reach it.
6. With `--auth-header X-Authentik-Uid`, the allowed user must be 64 lower-case hex characters (lead
decision 101). The test covers both the refusal and the pass.
## Tests (packages/cli/tests/case2-unit.test.mjs, 30 cases after round 2)
The tests use a scratch unit directory and a scratch home, plus fake systemctl, getent, sudo, id,
node, bwrap and claude. They never touch /etc/systemd, ~/.mosaic-dev, ~/.config/mosaic-dev or a
live unit. Every precheck case asserts its output doesn't hold the fixture key.
- Unit: the clean library render, PATH for a node outside the default list and for /usr/bin/node, the
systemctl order, a failed bus start, no `systemctl link`, factory `--pm` and `--claude` rendered and
absent, re-install to another copy, edits to an installed unit, template restrictions,
EnvironmentFile placement, unknown tokens, PermissionsStartOnly and `+`/`-+` Exec prefixes,
symlinked unit paths, bad `--node`, re-install drift, a split copy pair, bad copy ids, every library
and factory flag refusal, and bad `--claude`.
- Precheck: a good host, node older than 24, more than one group, sudo succeeding, XDG_DATA_HOME,
HOME mismatch, a run root mode, a bad config, bwrap failing, the port floor at or below the Console
port, a missing Console unit, bus.tester per profile, profile-library-business, the claude check
(no route, replaces, appends, missing, the unit's MOSAIC_CLAUDE_BIN beating PATH), and the session
env file (missing, 0644, symlink, empty, emptied by a later line, quoted).
- Bind (mos-1100): inside `binds()` plus `sandboxArgv()`, a bind on port 980 gets EACCES, and a bind
on a free port of 1024 or above succeeds. The test fails if the host's floor is at or below 980.
## Verification
Round 1 singles, run before the gate: case2-unit.test.mjs passes 28/28 with no skips, and host.test.mjs (it
reads the cli README) passes 42/42. No test pins a hash of the README. No other test reads the two
scripts.
## Round 2 (webby-0046, sage-1589, sage-1590, sage-1591)
Base: origin/refactor (rebased from 00d4cc1f6; the 15 commits between touch none of the five paths
or the two unit templates). Branch gosalyn/c2-13a-r2.
B1. The precheck now has a case for each refusal Webby named: the user missing from passwd (:77),
a business file that is unparsable, missing or fails the schema (:137), a run root that is a symlink
to a 0700 directory (:151), a Console ExecStart with no --port (:179), an unreadable port floor
(:180), a claude reply without the marker (:223) and a claude reply that isn't JSON (:224). The fake
claude gained `nomarker` and `text` kinds. The no-claude-code case and the missing-claude case share
a PATH without `/usr/bin/claude` (`pathWithoutClaude`), so the no-claude-code case now shows a host
with no claude passes.
N1 (ruled in for this round). Before writing, `case2-unit.sh` asks `systemctl is-active` for both
units. For each unit that was running, it prints that the unit is still on its old copy, never
"started", and then that the bus and Console must restart together to reach the new copy. A unit
that wasn't running gets "started ... on copy". A fresh install prints the round 1 line. The script
still restarts nothing. The new test covers both units running and only the Console running.
N4. The README line at :713 is rewrapped.
Mutants, run in Sage's slot on a scratch worktree, one branch each: :77, :137, :151, :179, :180,
:223, :224, a precheck that refuses with no claude on PATH (for the no-claude-code case), and an
install that prints "started" for a running unit (sage-1591).
## Carry items for row 195
- N1's restart. Row 195 restarts the bus and Console together after a re-install over running
units, as the install line now asks.
- N2. A system drop-in check like `bus-service.sh:122-145`, so a drop-in under
`[email protected]/` or `mosaic-webui.service.d/` can't change what the checked unit runs.
- N3. The precheck finds claude on the shell PATH. The bus unit's `PATH=` is what a session uses
when `MOSAIC_CLAUDE_BIN` is unset, so the check should search that PATH.
+294
View File
@@ -0,0 +1,294 @@
# Row 197 execution ledger
Plan: MANIFEST.md r2, approved by Webby ca6b32d66.
Build grant: sage-1390. Suites, image builds and paired proof are not granted.
Rebased five documentation commits onto origin/refactor aa1bd209e before code.
Existing worktree herb/row197 and plain node_modules symlink retained.
- T1 preflight/steps: in progress.
- T2 locks/status/recovery: pending.
- T3 image/Docker: pending.
- T4 fixtures/timing: pending.
- T5 evidence/acceptance: historical timing done; implementation pending.
Preflight interfaces: T1 RunPaths supplies T2 ownership and T3 image checks.
T2 run receipt supplies T4 lane/port state and T5 attribution. T3 image identity
is checked before Docker-bearing steps. T4 timings supply T5 pair summaries.
No interface conflict found. No product API or assertion changes are authorized.
Ruling: use this approved BUILD.md as the execution ledger, rather than adding
skill workspace paths outside the manifest. This preserves the reviewed path
boundary. Missing ledger detail would cost recovery time, not product behavior.
## Implementation checkpoint 2026-10-20T20:11:58Z
T1-T5 source is present within the approved manifest. Full acceptance is pending.
Fresh single-file checks: preflight/supervisor 6/6; state/recovery 11/11; image and
Docker guard 4/4; port leases/attribution/proof summary 3/3. No skips or failures.
Existing files checked singly: board serve; WebUI serve, preview, proof-cli,
tester-cli and service; harness pi and Claude. Stack-release R1 selected single
passed 1/1. WebUI service also passed 31/31 with lane-a leases and a 30-byte root.
All existing assert lines are unchanged. Shell syntax and diff checks pass.
Observed defects fixed during the single-file cycles: cancellation incorrectly
returned zero when a TERM handler exited zero; recovery did not supersede an
unclean terminal; status omitted the lock-before-receipt window. Evidence logs
retain the failing and passing observations. Source still has no commit because
applicable suites have not run. No image build, proof, push or live operation.
Ruling: use an instrumented single-lane author gate on a frozen dirty-source
clone before committing source. The new wrapper requires a clean candidate and
proven image receipt, while repository rules require green suites before source
commits. The author gate retains every suite and assertion, caps concurrency at
two, uses own port/state/temp/project roots, and guards Docker against builds.
Cost if wrong: the author gate fails; clean-candidate wrapper baselines and the
paired proof remain mandatory after source is verified and committed.
Ruling: publish the initial run directory atomically after the start receipt is
complete. This avoids a second lane observing a partially written start. A
crash before publication has started no test or mutable Docker operation.
Cost if wrong: a lane refuses or quarantines; no protected state is bypassed.
## Implementation review corrections, 2026-10-20T20:33Z
Webby-0029 reviewed the first implementation packet at f942bd7d4.
B1 identified missing manifest tests. B2 identified a test that leased both real
bands through a private lock namespace. The old author-gate clear was withdrawn
in herb-0019. SOURCE.json, BUILD.patch and gate.sh still describe the old frozen
clone. They are not a clear for the edited working tree. No author gate ran.
B2 correction: the helper test uses only the active lane and MOSAIC_GATE_STATE.
It compares the other band arithmetically and has no unasserted listener. The
occupied-port probe first obtains its own real lease, binds that port, and
asserts that its sentinel listener survives refusal. No test reserves another
lane's band. Exhaustion narrows only the test process's band to one owned port.
B1 coverage added within the existing approved paths. No case is dropped:
| Manifest case | Added check |
| --- | --- |
| T1 canonical and builder checkout, existing output | gate-lane.test: direct validateRun refusals |
| T1 92-byte socket and over-107-byte socket | exact design example with a distinct gt- test root; 108-byte refusal |
| T1 both candidates carry tooling | validateTooling compares each launcher file, including missing and changed cases |
| T1 duplicates and NODE_TEST_CONTEXT | repeated package/script fixture is retained; sanitized environment is exercised by both supervised children |
| T2 one-winner race and inherited FDs | two actual shell wrappers race; a controlled supervisor verifies held FDs, and its child checks that no FD points to either lock file |
| T2 same checkout and legacy claim refusal | activeConflicts uses synthetic process observations with a live lane and a sibling landing gate |
| T2 surviving child and interrupted recovery | locked recovery kills a child proven by the private root, preserves a foreign child, is killed during fake Docker cleanup, leaves quarantine, and resumes under a new fixture grant |
| T3 missing receipt and missing image | verifyImage refuses absent provenance and failing inspection |
| T3 tag changed mid-gate and at gate end | runGate now uses the same executePinnedSteps boundary exercised by the tests; the next step never runs after a changed ID |
| T3 mutable-tag rebuild | fake preparation returns a new image ID with identical input digest; the old receipt refuses it |
| T3 cold and warm counters | preparation records one build and its time; warm execution records zero builds; repeated verification never builds |
| T4 parallel test-file leases | two generated Node test files hold ports in the same lane/state; locks are checked during child execution and shutdown |
| T4 exhausted band, occupied port and foreign listener | a one-port process-local band refuses both a held lease and an existing sentinel listener; the listener survives |
| T4 owner death | a killed allocator releases only its own holder; a foreign lease remains held |
| T4 lease through child shutdown | parent probes the lock while the fixture test runs and after its child has stopped |
| T4 fixtureCacheEnv | replacement child environment writes only under separate tmp, jiti and cache directories |
| T5 unrelated session append | a separate process waits for a running-step barrier, appends under a fixture HOME, and remains unrelated in the resulting metadata attribution |
N1 removes the unused closed binding. All leases reserve an unbound endpoint;
existing closed options need no call-site change. N2 makes child TMPDIR a tmp
subdirectory. N3 normalizes ss interface scopes before checking port 9. N4 uses
gt- for unit run roots. N5 documents the canonical node_modules dependency.
N6 is recorded: row 200 also edits stack-release.test.mjs; the second landing
must rebase and rerun that file singly. Sage was told in herb-0019.
Before Sage's hold, expanded preflight tests passed 11/11, state tests 14/14,
image tests 7/7, and the pi/Claude singles passed 8/8 each with separate tmp
subdirectories. Later source/test edits still need verification. In particular,
the new parallel test-file probe had a cleanup-order defect and is unverified.
Its root cleanup ran before child shutdown. Cleanup now stops children before
removing their root. Output is retained for a useful failure report. The initial
FD probe also assumed Node did not reopen FD numbers; it now checks the actual
lock targets. The early passing fixture file covered child allocators, before
they were changed to actual generated test files. It is not acceptance of the
latest file.
Sage-1448 stopped all node --test runs at 20:30Z for Rocko's browser slot.
The remaining owned process tree was stopped at 20:30:32Z. Inspection found no
remaining owned test processes afterward. Edits and static inspection continue.
20 module syntax checks, shell syntax and git diff checks pass. Existing-file
assertion lines remain identical to HEAD. No test is run during the hold.
Next: after Sage passes Rocko's clear, verify the corrected files singly,
including a lane-env helper-file check. Then refresh the frozen clone and all
three packet artifacts, send their hashes, and request a new author-gate clear.
Suites, image builds and paired proofs still need their own grants. No source
commit, push, suite, real Docker build or paired proof has run in this phase.
## Refrozen review packet, 2026-10-20T20:41Z
Sage-1454 lifted the single-file hold. Fresh runs passed without failures or
skips: review-t1-resume.log 11/11, review-t2-resume.log 14/14,
review-t3-resume.log 8/8, review-t4-resume.log 8/8. The helper file also passed
8/8 with lane-a environment in review-t4-lane-a.log. Its retained state had only
an a port-lock namespace, no b directory, and every lock was released. Generated
fixture Node test entries explicitly use --test-concurrency=2. The child-test
probe now finishes and cleans up before removing its root. All 41 new tests pass.
B1 and B2 fixes are ready for Webby's review. No manifest case was dropped.
All 25 source paths were copied to a new frozen clone and compared by mode and
SHA-256. This supersedes the earlier withdrawn source packet:
- SOURCE.json: 91ba149f2fc039a8bed0e6d1ad5da44b80fb8cbec84d0fa4e38616040138596b
- gate.sh: 48e06e23e45d3484755f8cd79ad5b6dfde5ef4f960081e7c455a2540e707fed1
- BUILD.patch: 0b3871bd9848ca223c7cda50266debc04643e5d986eb7f0fece096aaf25ff43e
- Frozen checkout: /mnt/storage/scratch/mg197-a-d22dacd92735/wt
- Output: /mnt/storage/scratch/mg197-a-d22dacd92735/out
- Temporary root: /mnt/storage/scratch/ga-VPXhG7 (30 bytes)
- Compose project: mg197-a-d22dacd92735
After an explicit author-gate grant, run this command with Sage's actual ID:
```sh
env MOSAIC_GATE_GRANT=ACTUAL_SAGE_GRANT_ID bash /home/jwoltje/.t3/worktrees/mosaic-stack/herb-row197/agents/herb/work/queue-197/gate.sh > /mnt/storage/scratch/mg197-a-d22dacd92735/out/gate.log 2>&1
```
No gate or suite has started. Queue order from sage-1454 is 192, 201, 204,
200, Gander, then 197. No branch deletion is authorized or attempted. Any later
rebase uses a new branch name, and pr-merge.sh -d stays off under #426(a).
Whichever of rows 197 and 200 lands second must rerun stack-release singly.
Clean-candidate wrapper baselines, image preparation and paired proofs remain
separately gated acceptance work. There is still no source commit or push.
## Implementation approval and final notes, 2026-10-20T20:47Z
Webby-0031 approved r2 in REVIEW-impl-r2.md at 1f404c973. Their static review
verified every source hash/mode and patch result, and all B1/B2 cases. No queue
review request comment exists yet; Sage owns creating it if needed.
Folded all three optional notes into the same approved paths. The helper file's
standalone fallback now shares /mnt/storage/scratch/mosaic-gate-lanes with real
lane-a leases and leaves its stable lock files in place. Band assertions use the
imported BANDS. Both T5 polling children fail after five seconds if their barrier
is missed. README records the shared standalone namespace and deadlines.
Fresh single-file logs review-r3-fixtures.log and review-r3-lane-a.log both pass
8/8 with zero skips. The lane-a run has no b lock directory, and every lock was
released. Syntax and diff checks pass. No existing test assertions or timeouts
changed. Remaining source matches the 11/14/8-file checks from r2.
Refreshed all 25 source rows in the existing frozen checkout. New digests replace
the preceding r2 packet. gate.sh and its command are unchanged:
- SOURCE.json: 3584763642506e81a532c839bdd07b111d25daa87e35dbe441d8e8c01a2c2aab
- gate.sh: 48e06e23e45d3484755f8cd79ad5b6dfde5ef4f960081e7c455a2540e707fed1
- BUILD.patch: 55fe390da17ab926a0593d39e79561abe52756a1b1a2c756ec57f45d90f10fde
Author gate remains unstarted and queued for Sage's explicit grant. No source
commit, suite, image build, paired proof, push or branch deletion ran.
## R3 approval receipt
Webby-0033 approved r3 in REVIEW-impl-r3.md at a5643f9bd. Static review verified
the three packet hashes, all 25 source rows, and the patch result. Their review
accepts N1-N3; no additional hang-guard test is requested. No queue review request
comment exists yet, so Webby has not recorded a queue review.
Read-only verification after receipt confirms that the approved packet hashes
and all 25 worktree/frozen source hashes and modes still match. Source and gate
script stay frozen. The author gate has not started and still needs Sage's grant.
## Queue review round 1
Sage-1476 authorized moving row 197 to in-review before the author gate.
Created CANDIDATE.sha256 from the same 25 reviewed SOURCE.json rows. Its digest
is 6318f0d0bba9ee90ccaa3503cac8c2e04c6397f2ef6c3564f687fbc2092989e3.
Ran the queue move from the canonical checkout with op herb-197-review-r3.
The move succeeded at revision 2395, opening round 1 on #1695. The request post
failed before send because MOSAIC_GITEA_CREDENTIAL_FILE is not set; revision 2396
records the failed outcome. Exit code 1 describes the failed post, not a failed
row transition. Read-back confirms state in-review and the correct candidate.
Sage will post as jarvis and give Webby the request comment number. No credential
lookup, alternate post or retry was attempted here.
SOURCE.json, BUILD.patch, gate.sh and all 25 frozen source rows remain unchanged.
Latest slot order is row 190 r2, 201, 204, 200, Gander, then Herb. No gate starts
before Sage sends an explicit grant ID.
## Follow-on assignment
Sage-1503 assigns row 215, Case 2 C2-04, to Herb after row 197 (queue rev 2455,
Gosalyn design r11). Read docs/plans/2026-10-20_case2-rows.md, C2-04 section,
at 5b8def2f1 when starting that row. Its required base is origin/refactor
305857c8f. This assignment does not replace row 197 or authorize its gate.
Keep row 197's approved source frozen while awaiting Sage's grant. Start row
215 only after row 197, using a new branch name under the existing fleet rule.
## Round 2 merge preparation on row 233
Sage-1757 replaces history replay with one candidate commit on 57f59e0c3.
The attempted history replay on herb/row197-r2-233 had already finished when
that instruction arrived. Its only conflict was the append-only BUILD-LOG tail.
That branch remains for history; none of its replayed commits is in this round.
The current branch is herb/row197-r3-233, directly from
57f59e0c399ce29b1bb85ca6af686fdc10eb195d.
Stash correction: shared refs/stash was used incorrectly. The exact entry
2767b88549dfd8c7e810908b5416876fa50cb571 is now kept on
herb/row197-r3-wip. After verifying stash@{0} still named it, only that entry was
dropped. The two other entries remain. No further stash creation, force push,
branch deletion or refs/stash mutation is planned. A separate working-copy
backup is /mnt/storage/scratch/herb197-merge-W4oiLg.
Sage-1760 requires preserving every landed hunk on every changed manifest path.
Checked each of the 25 r1 paths with git diff aa1bd209e 57f59e0c3 -- PATH.
Exactly three changed upstream. Their r2 diff against 57f59e0c3 is captured in
r2-merge-only.patch:
| Merged path | Only row 197 changes against landed base | Landed content retained |
| --- | --- | --- |
| packages/cli/tests/stack-release.test.mjs | leasePort and laneAfter imports; freePort now calls the lease helper | All landed tests, including row 233's tests after R15 and BACKUP_STORE checks |
| packages/webui/tests/service.test.mjs | leasePort and laneAfter imports; closedPort now calls the lease helper | SEND_REFUSAL permits --sso; default unit rendering removes the @CONFIG_ENV@ line |
| scripts/test-discord.sh | fixture PI_CODING_AGENT_DIR setup; optional two-worker nested Node cap | Row 190's absent scratch MOSAIC_CONFIG selection and caller-config restoration |
The other 22 paths equal the r1 reviewed source bytes and modes. r2-MERGE.json
lists every path, its r1/r2 digest, mode, upstream-change flag and equality result.
Source copies came from the hash-verified r1 frozen checkout. Original r1 digest:
6318f0d0bba9ee90ccaa3503cac8c2e04c6397f2ef6c3564f687fbc2092989e3.
No existing assertion or timeout line differs from the landed base. Static
checks only: 20 module syntax checks, four shell syntax checks and git diff check
pass. BUILD-LOG.md equals 57f59e0c3 byte for byte and is outside this commit.
Sage explicitly requested the local candidate SHA before granting tests. This
commit is an untested preparation checkpoint, not gate evidence. No tests or
push are authorized yet. r2-TEST-FILES.txt lists the requested grant scope:
16 Node test files singly and scripts/test-discord.sh, test-foundation.sh and
test-queue.sh. The four stack-release consumers are stack-release.test.mjs,
stack-release-external.test.mjs, stack-upgrade.test.mjs and stack-instances.test.mjs.
The shared stack-release-fixture.mjs is exercised by its importing test files,
not run as a standalone test. This covers all 13 touched Node test files plus
three additional stack-release consumers and all three touched shell suites.
Current slot order from sage-1760: Gizmoduck, Launchpad, Darkwing, then Herb,
around 06:30Z. Wait for an explicit grant. After granted checks and authorized
push, build the new candidate manifest from that pushed tip and open the
merge-scoped round 2. Old SOURCE.json, CANDIDATE.sha256 and gate.sh remain r1
historical artifacts and are not a clear to run the merged candidate.
## Round 2 rebuilt on row 234
Sage-1776 names landing c771a36f4bd0b6c359282425c668996a90072f93.
Built a new branch, herb/row197-r4-234, directly from that landing. No history
replay, stash, force push, branch deletion, test run or push is involved.
The prior candidate a3b7cbde8 remains untested and is not the queued candidate.
Checked git diff --name-only 57f59e0c3 c771a36f4 against all 25 source paths.
Only packages/cli/tests/stack-release.test.mjs overlaps; the other 24 do not.
The full comparison is in r2-on234-OVERLAP.txt. The stack-release file starts
from the landing and keeps both T46 untimed entries, host_grants and hosts.
Its only changes against the landing are the leasePort and laneAfter imports
and freePort's call to the lease helper. r2-on234-merge-only.patch records those
hunks. Existing assertions and timeouts match the landing exactly.
Every other source path equals a3b7cbde8 byte for byte and retains its mode.
This includes the previously merged service and test-discord files, preserving
all landed --sso, CONFIG_ENV and scratch-config handling. The earlier evidence
r2-MERGE.json still records those three pre-234 merge cases. The new
r2-on234-MERGE.json records each current digest, prior digest, mode and equality
result. BUILD-LOG.md equals the new landing byte for byte and is excluded.
Static check of the changed Node file and the candidate diff pass. No tests
were run. The scope stays r2-TEST-FILES.txt: 16 Node files singly plus the three
shell suites. No manifest or queue round is rebuilt until granted checks and
authorized push. Old SOURCE.json, CANDIDATE.sha256 and gate.sh remain historical
r1 evidence. The test slot follows Darkwing 240 and needs Sage's explicit grant.
+241
View File
@@ -0,0 +1,241 @@
# Two-lane proof command and schedule
This evidence stays outside r5's 32 manifest paths. No proof or image build has
run. The driver and example config are committed on herb/row197-proof, rooted
at 9aa6d52d9. Sage runs the committed copy from their own checkout.
Driver: `agents/herb/work/queue-197/proof/run-proof.mjs`.
The commit message to Sage carries its exact SHA-256. Check it with
`git show <proof-commit>:agents/herb/work/queue-197/proof/run-proof.mjs | sha256sum`.
After Sage fills config.json from config.example.json and grants the whole block:
```sh
MOSAIC_PROOF_GRANT=ACTUAL_SAGE_GRANT_ID node /SAGE_PROOF_CHECKOUT/agents/herb/work/queue-197/proof/run-proof.mjs /SAGE_OWNED_CONFIG/config.json
```
The example config pins the launcher and candidate A to 197 r5:
9aa6d52d9431efd215aa31d748d7e273eb321474. The launcher is a separate clean
checkout owned by Sage, so later row 215 work cannot change it. Fill candidateB with
Sage's approved combined 212+197 commit, imageReceipt with the granted pinned
image-provenance receipt, and runRoot with a fresh private scratch path. Both
candidates must carry identical lane tooling and image inputs and use the same
pinned image ID. The driver refuses missing parameters, equal candidate SHAs,
dirty launcher, changed tooling, mismatched image inputs or image ID.
D5 order is exactly two solo gates, then three pairs:
| Wave | Candidate A | Candidate B |
| --- | --- | --- |
| 1 | solo, lane a | idle |
| 2 | idle | solo, lane b |
| 3 | paired, lane a | paired, lane b |
| 4 | paired, lane b | paired, lane a |
| 5 | paired, lane a | paired, lane b |
Every invocation gets its own clean clone, output, short temp root and random
Compose project. The wrapper discovers all package and shell steps, with two
Node test-file workers per lane. The summary requires three overlaps, the lane
swap, successful clean receipts and zero warm image builds. Raw logs and per-step
receipts remain available. No failed gate or series is retried automatically.
A live monitor stops both gates on the first failure line, records lane, step,
source file when the runner emits it, test name and preserved log, then enriches
that report after shutdown. If the runner gives no source path, the report says
unresolved and points to the log rather than guessing. It stops earlier than the
two-failure limit. TERM/INT/HUP stop owned supervisors; cleanup waits for them.
Container and network names come from Compose's random recorded project. The
project and service configuration have no fixed global names. Container event
streams filter the exact project label; network streams match the exact project_
name prefix. Both record create/destroy IDs and names, including --rm containers.
An exit trap inventories names/labels, removes only exact-project objects with
the approved cleanup helper, verifies emptiness and stops its own event streams.
The driver's Docker inspection never reads Config.Env or credential contents, and never builds, pulls,
retags or prunes. Missing resource-name evidence, an ended event stream, unknown
ownership, leftover objects or quarantined lanes fail the proof. A SIGKILL cannot
run an exit trap; durable wrapper receipts then require granted recovery.
Run this as its own whole-slot phase. A still-running legacy landing.mjs gate or
gate.sh is LEGACY BUSY, including a parent process. The driver does not suppress
that refusal. The ordinary landing gate must exit before this proof phase starts.
Planning runtime from sage-1837 is 12-15 minutes per wave, so reserve 60-75 minutes
for all five waves, plus any separately granted image preparation. This is an
estimate, not a measurement. Measured proof runtime is unavailable until this
first run. Historical row 189 full gate was 659 seconds; the r4 focused 19-file
run was 761.33 seconds and is not a full solo baseline. The resulting summary
records actual per-gate times, pair makespans, slowdowns and total wall time.
No image provenance receipt or combined integration SHA is available to Herb yet.
The driver remains unexecuted. Sage will read the exact hashed driver and request
Webby's static review before the landing/proof grant.
Static node --check passed after sage-1839 single cleared. This is syntax-only;
the driver has not run a proof or Docker command. Its runtime behavior awaits
Sage and Webby review and the separately granted proof block.
## Driver review fixes, sage-1857
Webby's first review was CHANGES, B1, at bba870ee9. The revised driver takes
tempRoot only from immutable start records for the plan's checkouts. After
every owned gate exits, Docker cleanup and the final status/process checks
run. The driver requires the exact short ga-/gb- scratch spelling, a matching
lane, an owned real directory and no live owner, child group or process with
the run paths in its argv before removal. It also requires a clean terminal
receipt. A successful gate's root is removed recursively. A failed gate's
root stays for diagnosis; an unverified cleanup also stays and fails the
proof. docker-summary.json records removed, kept and error entries. Any
cleanup error makes the driver fail. No unrelated scratch directory is read.
Signal handlers are installed before configuration and preparation. Clone
preparation is inside the try/finally, and yields between synchronous clones
so a pending signal stops it before another invocation. A preparation signal
writes failure evidence and reaches the cleanup path. Partial clones remain
under runRoot as permitted by Sage; they are never confused with lane roots.
SIGKILL cannot be trapped and still needs granted recovery.
At 2026-10-21T08:26:41Z, Herb removed four earlier roots recorded in their
own packet: ga-VPXhG7, gb-XeTAUS, ga-35Zo9Y and ga-R5IOT3. Each was an owned
real directory with no process argv/cwd reference before removal. Names
ga-AMtfI3 and ga-Vd9Ef2 were visible in the scratch directory listing but had
no ownership evidence in Herb's packet; neither was opened or changed.
## N4 read-only trace, pre-existing and out of scope
Sage ruled in sage-1859 that N4 does not block 197. Legacy suites already
reach Compose's read-only host auth mount, and the lane adds no exposure.
Sage owns a DEFERRED entry for fixture PI_AUTH_FILE coverage. No fix is made
in 197, and this procedure does not claim that the full gate is secret-free.
At 9aa6d52d9:
- gate-lane.mjs:109-111 keeps HOME real but neither keeps nor sets
PI_AUTH_FILE. compose.yaml:43 therefore selects its host auth-file default.
- script-task is discovered by gate-lane.mjs:36. test-task.sh has no
PI_AUTH_FILE assignment. Calls at :149-150 (mock), :444, :460, :480, :483
and :520 (live) reach run-task. mosaic-task.mjs:321 copies the environment
and :395-400 invokes compose run mosaic-agent without changing that path.
- script-release calls release activate at test-release.sh:69,85,93.
release.sh:41-54 health_check calls run-task with no PI_AUTH_FILE override,
then reaches the same Compose call. These tests use scratch MOSAIC_CONFIG;
that does not override the separate auth-file bind mount.
- test-auth.sh:48-92 sets explicit fixtures, but calls auth.sh instead of
Compose. Package runs/task-cli.test.mjs exercises list/show, not run.
This was a static source trace only. No auth file was opened, no credential
value was read, and no Docker command, image build or proof was executed.
## Proof4 watcher change (sage-2029, mos-1174)
Proof3's eight gates passed and cleaned up, but Docker29 network events do
not carry Compose project labels, so the driver's combined label filter
missed their creation. Only this proof packet changes; none of the37
c45ddeb6b candidate paths changes.
Container events retain the label filter. Network events use a type filter
and a local exact project_ name-prefix match, accepting default and other
owned network names. The existing recorded-name project guard stays.
The same exported watcher/parser powers unit tests, the proof and a daemon
preflight before wave1. Importing the module does not execute a proof.
Direct execution still installs signal handlers before preparation.
The preflight creates one named and labelled mg197 probe network and one
create-only container from the pinned imageId, --pull never, network none,
no mounts or start. Exact-project label-checked cleanup removes both,
including on failure. All four create/destroy observations must arrive
within5000ms. Docker commands share that deadline; cleanup has a separate
3000ms cap. Watcher shutdown is bounded. Failure prevents all gate waves.
Probe metadata and outcomes go into docker-summary.json and summary.json.
No Config.Env, credential file contents or raw foreign events are saved.
Tests cover network events with name/type only, all project_ network names,
foreign names/labels, recorded-name ownership, fragmented streams, early
exit, missing create/destroy observations, deadline and cleanup failures.
Sage-2034 accepted this scope. Grant sage-2035 supplied the runtime evidence below.
## Granted verification, sage-2035
On 2026-10-21, the whole events.test.mjs passed 8/0, with zero skipped or
cancelled. node --check passed on run-proof.mjs and events.test.mjs. One
exported probeDockerWatchers call used the supplied proof3 image ID and
cleanupProject from the unchanged r8 launcher. No container was started;
no pull, build, lane or wave ran. Both filtered post-probe Docker listings
were empty, and the owned /proc cwd/project scan was empty. The slot was
returned at 2026-10-21T15:16:52Z.
Driver SHA-256:
`998f41948355ea86169c1711324a3c9e6cf4bdbe5157dcae9e78e414c1c16342`.
Sage updates their proof-phase.sh digest pin to this value.
The complete standalone probe output was:
```json
{
"result": {
"project": "mg197-a-724f1800af2c",
"imageId": "sha256:1b83521d092a3f005ea2192e9e6268099e6e0cdab85d41a5d107b4fb824131a6",
"timeoutMs": 5000,
"names": {
"container": "mg197-a-724f1800af2c-watcher-probe",
"network": "mg197-a-724f1800af2c_default"
},
"events": [
{
"project": "mg197-a-724f1800af2c",
"kind": "network",
"id": "930b1c80596fc93b4dc104ab1e278e5b1e1b2bc6373a5cb9cba7bba46f230ef8",
"name": "mg197-a-724f1800af2c_default",
"action": "create",
"observedMs": 38.03536499999999
},
{
"project": "mg197-a-724f1800af2c",
"kind": "container",
"id": "8fcdf9d06c437e162698095b2234402ce75fd9744669ced4017f823b5a99fc83",
"name": "mg197-a-724f1800af2c-watcher-probe",
"action": "create",
"observedMs": 123.93732700000001
},
{
"project": "mg197-a-724f1800af2c",
"kind": "container",
"id": "8fcdf9d06c437e162698095b2234402ce75fd9744669ced4017f823b5a99fc83",
"name": "mg197-a-724f1800af2c-watcher-probe",
"action": "destroy",
"observedMs": 297.033782
},
{
"project": "mg197-a-724f1800af2c",
"kind": "network",
"id": "930b1c80596fc93b4dc104ab1e278e5b1e1b2bc6373a5cb9cba7bba46f230ef8",
"name": "mg197-a-724f1800af2c_default",
"action": "destroy",
"observedMs": 297.07703999999995
}
],
"errors": [],
"cleanup": {
"clean": true,
"removed": [
{
"kind": "container",
"id": "8fcdf9d06c43"
},
{
"kind": "network",
"id": "930b1c80596f"
}
]
},
"ok": true,
"missing": [],
"wallMs": 308.567205
},
"inventories": {
"containers": "",
"networks": ""
}
}
```
@@ -0,0 +1,10 @@
{
"repo": "/mnt/storage/src/mosaic-stack",
"launcherRoot": "SAGE_OWNED_CLEAN_LAUNCHER_CHECKOUT",
"launcherCandidate": "9aa6d52d9431efd215aa31d748d7e273eb321474",
"candidateA": "9aa6d52d9431efd215aa31d748d7e273eb321474",
"candidateB": "SAGE_APPROVED_212_PLUS_197_COMMIT",
"candidateDifference": "A is approved 197 r5; B must be the approved combined 212+197 integration commit",
"imageReceipt": "SAGE_PINNED_IMAGE_PROVENANCE_RECEIPT",
"runRoot": "/mnt/storage/scratch/mg197-proof-REPLACE_WITH_UNIQUE_NONCE"
}
@@ -0,0 +1,60 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { PassThrough } from 'node:stream';
import { dockerEvent, watchDockerProject, probeDockerWatchers } from './run-proof.mjs';
const project='mg197-a-0123456789ab', imageId='sha256:'+'a'.repeat(64);
const ids={container:'b'.repeat(64),network:'c'.repeat(64)};
const event=(kind,action,name=kind==='network'?project+'_default':project+'-probe')=>({Type:kind,Action:action,Actor:{ID:ids[kind],Attributes:{name,type:'bridge',...(kind==='container'?{'com.docker.compose.project':project}:{})}}});
function streams(){
const children=new Map(),calls=[];
const spawnImpl=(binary,args)=>{const kind=args.includes('type=network')?'network':'container';calls.push(args);const child=new EventEmitter();child.stdout=new PassThrough();child.stderr=new PassThrough();child.kill=()=>{queueMicrotask(()=>child.emit('close',0));return true;};children.set(kind,child);return child;};
const emit=(kind,action,name)=>children.get(kind).stdout.write(JSON.stringify(event(kind,action,name))+'\n');
return {children,calls,spawnImpl,emit};
}
test('network create/destroy with only name and type match every exact project_ network',()=>{
for(const name of [project+'_default',project+'_other_network'])for(const action of ['create','destroy']){
const value=dockerEvent(project,'network',event('network',action,name));
assert.deepEqual(value,{project,kind:'network',id:ids.network,name,action});
}
for(const name of ['foreign_default',project+'x_default',project+'-other',project])assert.equal(dockerEvent(project,'network',event('network','create',name)),null);
assert.equal(dockerEvent(project,'network',event('network','connect')),null);
});
test('container labels and recorded name ownership remain strict',()=>{
assert.equal(dockerEvent(project,'container',event('container','create')).id,ids.container);
const foreign=event('container','create');foreign.Actor.Attributes['com.docker.compose.project']='other';assert.equal(dockerEvent(project,'container',foreign),null);
assert.throws(()=>dockerEvent(project,'container',event('container','create','foreign-container')),/name outside/);
const invalid=event('network','create');invalid.Actor.ID='not-an-id';assert.throws(()=>dockerEvent(project,'network',invalid),/ownership metadata/);
});
test('two real watcher shapes decode fragmented lines and network stream has no label filter',async()=>{
const s=streams(),seen=[],errors=[];const watcher=watchDockerProject(project,{docker:'/fixture/docker',spawnImpl:s.spawnImpl,onEvent:e=>seen.push(e),onError:e=>errors.push(e)});
const text=JSON.stringify(event('network','create'))+'\n';s.children.get('network').stdout.write(text.slice(0,17));s.children.get('network').stdout.write(text.slice(17));s.emit('network','destroy');s.emit('container','create');s.emit('container','destroy');
assert.equal(seen.length,4);assert.ok(s.calls.find(a=>a.includes('type=container')).includes('label=com.docker.compose.project='+project));assert.ok(!s.calls.find(a=>a.includes('type=network')).some(a=>a.startsWith('label=')));
await watcher.stop();assert.deepEqual(errors,[]);
});
test('an event watcher ending early reports an error',async()=>{
const s=streams(),errors=[];const watcher=watchDockerProject(project,{docker:'/fixture/docker',spawnImpl:s.spawnImpl,onEvent:()=>{},onError:e=>errors.push(e)});s.children.get('network').emit('close',1);assert.match(errors[0],/ended early/);await watcher.stop();
});
function probeFixture({missing=null,commandFails=false,cleanupFails=false}={}){
const s=streams(),calls=[];let cleaned=false;
const invoke=async args=>{calls.push(args);const kind=args[0];if(commandFails)throw Error('fixture daemon error');if(missing!==kind+'.create')s.emit(kind,'create');return ids[kind]+'\n';};
const cleanupProject=run=>{assert.equal(run.project,project);cleaned=true;if(cleanupFails)throw Error('fixture cleanup refusal');for(const kind of ['container','network'])if(missing!==kind+'.destroy')s.emit(kind,'destroy');return {clean:true,removed:Object.entries(ids).map(([kind,id])=>({kind,id}))};};
return {options:{docker:'/fixture/docker',imageId,project,spawnImpl:s.spawnImpl,invoke,cleanupProject,timeoutMs:80},calls,get cleaned(){return cleaned;}};
}
test('preflight uses the same watchers, creates only pinned no-pull objects, removes both and sees all four events',async()=>{
const f=probeFixture(),result=await probeDockerWatchers(f.options);assert.equal(result.ok,true);assert.equal(f.cleaned,true);assert.deepEqual(result.missing,[]);assert.equal(result.events.length,4);
const create=f.calls.find(a=>a[0]==='container');assert.equal(create[1],'create');assert.equal(create[create.indexOf('--pull')+1],'never');assert.ok(create.includes(imageId));assert.ok(create.includes('--network')&&create.includes('none'));assert.ok(f.calls.every(a=>!a.includes('start')&&!a.includes('build')));
});
test('missing either create or destroy fails within the bounded wait and still cleans up',async()=>{
for(const missing of ['container.create','container.destroy','network.create','network.destroy']){
const f=probeFixture({missing}),start=performance.now(),result=await probeDockerWatchers(f.options);assert.equal(result.ok,false);assert.ok(result.missing.includes(missing));assert.ok(performance.now()-start<1500);assert.equal(f.cleaned,true);
}
});
test('probe command and cleanup failures refuse and retain their own result',async()=>{
for(const options of [{commandFails:true},{cleanupFails:true}]){const f=probeFixture(options),result=await probeDockerWatchers(f.options);assert.equal(result.ok,false);assert.ok(result.errors.length);assert.equal(f.cleaned,true);}
});
test('late observations cannot turn a missed deadline into success',async()=>{
const f=probeFixture(),invoke=f.options.invoke;f.options.invoke=async args=>{await new Promise(r=>setTimeout(r,100));return invoke(args);};
const result=await probeDockerWatchers(f.options);assert.equal(result.ok,false);assert.ok(result.missing.includes('network.create'));assert.equal(f.cleaned,true);
});
@@ -0,0 +1,264 @@
// Evidence driver only. Runs no test without Sage's whole-slot grant.
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, realpathSync, openSync, closeSync, symlinkSync, readdirSync, statSync, lstatSync, readSync, rmSync } from 'node:fs';
import { resolve, join, dirname, relative, isAbsolute } from 'node:path';
import { pathToFileURL } from 'node:url';
import { execFileSync, execFile, spawn } from 'node:child_process';
import { randomBytes } from 'node:crypto';
const projectPattern=/^mg197-[ab]-[a-f0-9]{12}$/;
export function dockerEvent(project,kind,event){
if(!projectPattern.test(project)||!['container','network'].includes(kind))throw Error('invalid Docker ownership metadata');
if(event.Type!==kind||!['create','destroy'].includes(event.Action))return null;
const attrs=event.Actor?.Attributes??{},name=typeof attrs.name==='string'?attrs.name.replace(/^\//,''):null;
if(kind==='network'){
if(!name?.startsWith(project+'_'))return null;
if(attrs['com.docker.compose.project']&&attrs['com.docker.compose.project']!==project)throw Error('foreign project event');
}else if(attrs['com.docker.compose.project']!==project)return null;
if(!name?.startsWith(project))throw Error('Docker name outside its recorded project');
const id=event.Actor?.ID;if(typeof id!=='string'||!/^[a-f0-9]{12,64}$/.test(id))throw Error('invalid Docker ownership metadata');
return {project,kind,id,name,action:event.Action};
}
export function watchDockerProject(project,{docker,onEvent,onError,spawnImpl=spawn}){
if(!projectPattern.test(project))throw Error('invalid Docker ownership project');
const streams=[];
for(const kind of ['container','network']){
// Network events on Docker29 have name/type but no Compose labels. Filter
// their exact project_ name prefix locally, never by a missing label.
const args=['events','--since',String(Math.floor(Date.now()/1000)-5),'--filter','type='+kind];
if(kind==='container')args.push('--filter','label=com.docker.compose.project='+project);
args.push('--filter','event=create','--filter','event=destroy','--format','{{json .}}');
const child=spawnImpl(docker,args,{stdio:['ignore','pipe','pipe']}),stream={child,stopping:false,buffer:'',closed:false};streams.push(stream);
child.stdout.on('data',bytes=>{stream.buffer+=bytes;let end;while((end=stream.buffer.indexOf('\n'))>=0){const line=stream.buffer.slice(0,end);stream.buffer=stream.buffer.slice(end+1);if(!line)continue;try{const event=dockerEvent(project,kind,JSON.parse(line));if(event)onEvent(event);}catch(error){onError(error.message);}}});
child.stderr.resume();stream.done=new Promise(resolve=>{child.once('error',error=>{stream.closed=true;onError(error.message);resolve();});child.once('close',code=>{stream.closed=true;if(!stream.stopping)onError('Docker '+kind+' event stream ended early: '+code);resolve();});});
}
return {async stop(){
for(const stream of streams){stream.stopping=true;if(!stream.closed)stream.child.kill('SIGTERM');}
let timer;await Promise.race([Promise.all(streams.map(s=>s.done)),new Promise(resolve=>{timer=setTimeout(resolve,1000);})]);clearTimeout(timer);
for(const stream of streams)if(!stream.closed){stream.child.kill('SIGKILL');onError('Docker event watcher did not stop promptly');}
}};
}
export async function probeDockerWatchers({docker,imageId,cleanupProject,project='mg197-a-'+randomBytes(6).toString('hex'),timeoutMs=5000,spawnImpl=spawn,invoke}){
if(!projectPattern.test(project)||!/^sha256:[a-f0-9]{64}$/.test(imageId??''))throw Error('invalid watcher probe ownership or image');
const start=performance.now(),deadline=start+timeoutMs,events=[],errors=[],names={container:project+'-watcher-probe',network:project+'_default'};
const result={project,imageId,timeoutMs,names,events,errors,cleanup:null,ok:false};
const watcher=watchDockerProject(project,{docker,spawnImpl,onEvent:e=>events.push({...e,observedMs:performance.now()-start}),onError:e=>errors.push(e)});
const remaining=()=>{const ms=Math.ceil(deadline-performance.now());if(ms<=0)throw Error('Docker watcher probe deadline');if(errors.length)throw Error('Docker watcher probe stream failed');return ms;};
const call=invoke??((args,timeout)=>new Promise((resolve,reject)=>execFile(docker,args,{encoding:'utf8',timeout,killSignal:'SIGKILL',maxBuffer:65536},(error,stdout)=>error?reject(Error('Docker probe '+args.slice(0,2).join(' ')+' failed: '+error.code)):resolve(stdout))));
try{
for(const args of [
['network','create','--label','com.docker.compose.project='+project,names.network],
['container','create','--pull','never','--name',names.container,'--label','com.docker.compose.project='+project,'--network','none',imageId,'/bin/true'],
]){const id=(await call(args,remaining())).trim();if(!/^[a-f0-9]{12,64}$/.test(id))throw Error('invalid Docker probe create ID');}
}catch(error){errors.push(error.message);}
finally{
// Label-checked cleanup also covers an uncertain create result. Each CLI
// call shares a separate bounded cleanup budget and never reads Config.Env.
const cleanupDeadline=performance.now()+3000;
try{result.cleanup=cleanupProject({project},args=>{const timeout=Math.ceil(cleanupDeadline-performance.now());if(timeout<=0)throw Error('probe cleanup deadline');return execFileSync(docker,args,{encoding:'utf8',stdio:['ignore','pipe','pipe'],timeout,killSignal:'SIGKILL',maxBuffer:65536});});if(!result.cleanup?.clean)throw Error('probe cleanup not confirmed');}
catch(error){errors.push('probe cleanup: '+error.message);}
}
const missing=()=>['container.create','container.destroy','network.create','network.destroy'].filter(key=>!events.some(e=>e.kind+'.'+e.action===key&&e.observedMs<=timeoutMs));
while(!errors.length&&missing().length&&performance.now()<deadline)await new Promise(resolve=>setTimeout(resolve,10));
result.missing=missing();if(result.missing.length)errors.push('missing probe events: '+result.missing.join(', '));
await watcher.stop();result.wallMs=performance.now()-start;result.ok=!errors.length&&result.cleanup?.clean===true;
return result;
}
export async function runProof(){
const proofStart=performance.now();
let interrupted=false,abortOnSignal=()=>{};
const signal=()=>{interrupted=true;abortOnSignal();};
for(const name of ['SIGINT','SIGTERM','SIGHUP'])process.on(name,signal);
const fail=m=>{throw Error(m);};
const within=(root,path)=>{const p=relative(root,path);return p===''||!p.startsWith('..')&&!isAbsolute(p);};
const grant=process.env.MOSAIC_PROOF_GRANT;
if(!/^sage-[0-9]+$/.test(grant??''))fail('explicit whole-slot MOSAIC_PROOF_GRANT required');
const config=JSON.parse(readFileSync(process.argv[2],'utf8'));
for(const key of ['candidateA','candidateB'])if(!/^[a-f0-9]{40}$/.test(config[key]??''))fail('full approved candidate SHA required: '+key);
if(config.candidateA===config.candidateB)fail('two distinct approved candidates required');
if(!config.candidateDifference?.trim())fail('record why the approved candidates differ');
const launcher=realpathSync(config.launcherRoot),repo=realpathSync(config.repo),receipt=realpathSync(config.imageReceipt);
const git=(root,...args)=>execFileSync('git',['-C',root,...args],{encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim();
if(git(launcher,'rev-parse','HEAD')!==config.launcherCandidate)fail('launcher pin differs');
if(git(launcher,'status','--porcelain').split('\n').some(l=>l&&l!=='?? node_modules'))fail('launcher checkout dirty');
const root=resolve(config.runRoot);
if(existsSync(root)||!within('/mnt/storage/scratch',realpathSync(dirname(root))))fail('fresh scratch runRoot required');
const {validateTooling}=await import(pathToFileURL(join(launcher,'scripts/testing/gate-lane.mjs')));
const {verifyImage}=await import(pathToFileURL(join(launcher,'scripts/testing/lane-image.mjs')));
const {runRecords,status,processInventory,sameProcess}=await import(pathToFileURL(join(launcher,'scripts/testing/lane-state.mjs')));
const {summarizePairs}=await import(pathToFileURL(join(launcher,'scripts/testing/lane-evidence.mjs')));
const {cleanupProject}=await import(pathToFileURL(join(launcher,'scripts/testing/lane-docker.mjs')));
const docker=execFileSync('which',['docker'],{encoding:'utf8'}).trim();
const image=JSON.parse(readFileSync(receipt,'utf8'));
mkdirSync(root,{mode:0o700});const state=join(root,'state');mkdirSync(state,{mode:0o700});
const put=(name,value)=>writeFileSync(join(root,name),JSON.stringify(value,null,2)+'\n',{mode:0o600,flag:'wx'});
const waves=[
[['solo-a','a',config.candidateA]],
[['solo-b','b',config.candidateB]],
[['pair1-a','a',config.candidateA],['pair1-b','b',config.candidateB]],
[['pair2-b','b',config.candidateA],['pair2-a','a',config.candidateB]],
[['pair3-a','a',config.candidateA],['pair3-b','b',config.candidateB]],
];
const plan=waves.map(w=>w.map(([label,lane,candidate])=>({label,lane,candidate,dir:join(root,label),checkout:join(root,label,'wt'),out:join(root,label,'out')})));
put('plan.json',{grant,...config,plan,image,started:new Date().toISOString()});
const active=new Set(),watchers=new Map(),objects=new Map();let firstFailure=null;
const eventError=[];
const knownRuns=new Map();let abortTimer;
const ownedRuns=()=>runRecords(state).flatMap(r=>r.records.filter(e=>e.kind==='start'&&plan.flat().some(p=>p.checkout===e.checkout)));
function logObject(project,kind,id,name,action){
if(!/^mg197-[ab]-[a-f0-9]{12}$/.test(project)||!['container','network'].includes(kind)||!/^[a-f0-9]{12,64}$/.test(id))fail('invalid Docker ownership metadata');
if(name&&!String(name).replace(/^\//,'').startsWith(project))fail('Docker name outside its recorded project');
const key=kind+':'+id,old=objects.get(key);const value={project,kind,id,name:name??old?.name??null,created:old?.created||action==='create',destroyed:old?.destroyed||action==='destroy'};
objects.set(key,value);appendFileSync(join(root,'docker-objects.jsonl'),JSON.stringify({...value,action,at:new Date().toISOString()})+'\n',{mode:0o600});
}
function ensureWatchers(final=false){
if(final||[...active].some(j=>!knownRuns.has(j.run.checkout)))for(const run of ownedRuns())knownRuns.set(run.checkout,run);
for(const run of knownRuns.values())if(!watchers.has(run.project)){
const watcher=watchDockerProject(run.project,{docker,onEvent:event=>logObject(event.project,event.kind,event.id,event.name,event.action),onError:message=>eventError.push(message)});
watchers.set(run.project,watcher);
}
}
function inventory(project){
const found=[];
for(const kind of ['container','network']){
const query=kind==='container'?['ps','-aq']:['network','ls','-q'];
const ids=execFileSync(docker,[...query,'--filter','label=com.docker.compose.project='+project],{encoding:'utf8'}).trim().split(/\s+/).filter(Boolean);
for(const id of ids){
if(!/^[a-f0-9]{12,64}$/.test(id))fail('invalid inventory ID');
// Read only name and ownership label, never Config.Env or credentials.
const labels=kind==='container'?'Config.Labels':'Labels';
const format='{"name":{{json .Name}},"project":{{json (index .'+labels+' "com.docker.compose.project")}}}';
const item=JSON.parse(execFileSync(docker,[kind,'inspect',id,'--format',format],{encoding:'utf8'}));
if(item.project!==project)fail('foreign Docker object');
logObject(project,kind,id,item.name,'inventory');found.push({kind,id,name:item.name});
}
}
return found;
}
function failureDetails(job){
const events=runRecords(state).find(r=>r.records.some(e=>e.kind==='start'&&e.checkout===job.run.checkout))?.records??[];
const steps=events.filter(e=>e.kind==='step');
const paths=firstFailure?.gate===job.run.label&&firstFailure.log?[firstFailure.log]:existsSync(job.run.out)?readdirSync(job.run.out).filter(n=>n.endsWith('.log')).map(n=>join(job.run.out,n)):[];
for(const log of paths){
const text=readFileSync(log,'utf8');const bad=text.match(/^(?:\s*✖ (?!failing tests:)|not ok \d+ - |FAIL\s+)(.+)$/m);if(!bad)continue;
const detail=text.match(/✖ failing tests:[\s\S]*$/)?.[0]??text;
const file=detail.match(/^test at (.+?\.test\.(?:mjs|js|cjs))(?::\d+)+/m)?.[1]??detail.match(/file:\/\/([^\s)]+\.test\.(?:mjs|js|cjs))/)?.[1];
return {lane:job.run.lane,gate:job.run.label,step:log.split('/').at(-1).slice(0,-4),file:file??'unresolved; inspect preserved log',test:bad[1],log};
}
const failed=steps.find(e=>e.rc||e.requiredSkips?.length);
return {lane:job.run.lane,gate:job.run.label,step:failed?.name??'preflight or cleanup',file:'see gate.log',test:failed?.requiredSkips?.join('; ')??'wrapper failed',log:join(job.run.dir,'gate.log')};
}
function abortActive(){
for(const job of active)job.child.kill('SIGTERM');
if(!abortTimer){abortTimer=setTimeout(()=>{for(const job of active)job.child.kill('SIGKILL');},30000);abortTimer.unref();}
}
abortOnSignal=abortActive;
function newFailure(job){
if(!existsSync(job.run.out))return null;job.logs??=new Map();
for(const name of readdirSync(job.run.out).filter(n=>n.endsWith('.log'))){
const log=join(job.run.out,name),st=statSync(log),cursor=job.logs.get(log)??{size:0,tail:''};if(st.size===cursor.size)continue;
const fd=openSync(log,'r'),bytes=Buffer.alloc(st.size-cursor.size);try{readSync(fd,bytes,0,bytes.length,cursor.size);}finally{closeSync(fd);}
const text=cursor.tail+bytes.toString('utf8'),match=text.match(/^(?:\s*✖ (?!failing tests:)|not ok \d+ - |FAIL\s+)(.+)$/m);
job.logs.set(log,{size:st.size,tail:text.slice(-8192)});
if(match)return {lane:job.run.lane,gate:job.run.label,step:name.slice(0,-4),file:'awaiting runner failure details',test:match[1],log};
}
return null;
}
function monitor(){
try{
ensureWatchers();
for(const job of active){const detail=newFailure(job);if(detail&&!firstFailure){firstFailure=detail;process.stderr.write('FIRST_FAILURE '+JSON.stringify(detail)+'\n');abortActive();}}
if(eventError.length&&!firstFailure){firstFailure={kind:'Docker evidence',errors:[...eventError]};abortActive();}
}catch(error){eventError.push(error.message);abortActive();}
}
function launch(run){
const fd=openSync(join(run.dir,'gate.log'),'wx',0o600);
const child=spawn('bash',[join(launcher,'scripts/testing/gate-lane.sh'),'run','--lane',run.lane,'--candidate',run.candidate,'--checkout',run.checkout,'--out',run.out,'--state-root',state,'--image-receipt',receipt,'--grant',grant],{stdio:['ignore',fd,fd]});closeSync(fd);
const job={child,run};active.add(job);
job.done=new Promise(resolve=>{child.once('error',error=>{active.delete(job);resolve({rc:127,error:error.message});});child.once('exit',(code,signal)=>{active.delete(job);resolve({rc:code??128,signal});});});
return job;
}
function gateReceipt(run){
const item=runRecords(state).find(r=>r.records.some(e=>e.kind==='start'&&e.checkout===run.checkout));
if(!item)fail('missing start receipt: '+run.label);
const terminal=item.records.filter(e=>e.kind==='terminal').at(-1);
if(!terminal||terminal.rc!==0||!terminal.clean||terminal.imageBuilds!==0)fail('gate failed or unclean: '+run.label);
if(item.records.some(e=>e.requiredSkips?.length))fail('required prerequisite skipped: '+run.label);
return {...terminal,label:run.label,lane:run.lane,candidate:run.candidate,receiptDir:item.dir};
}
async function stop(){
const jobs=[...active];for(const j of jobs)j.child.kill('SIGTERM');
const timer=setTimeout(()=>{for(const j of jobs)j.child.kill('SIGKILL');},30000);timer.unref();
await Promise.all(jobs.map(j=>j.done));clearTimeout(timer);
}
const measured=[],start=proofStart;
const monitorTimer=setInterval(monitor,100);
let summary,watcherProbe=null;
try{
// Preparation is inside the cleanup path. Yield between synchronous clones
// so a pending signal stops preparation before another invocation is made.
for(const run of plan.flat()){
await new Promise(resolve=>setImmediate(resolve));
if(interrupted)fail('proof interrupted during preparation');
mkdirSync(run.dir,{mode:0o700});
execFileSync('git',['clone','--quiet','--no-hardlinks','--local',repo,run.checkout],{stdio:'pipe'});
execFileSync('git',['-C',run.checkout,'checkout','--quiet','--detach',run.candidate],{stdio:'pipe'});
symlinkSync('/mnt/storage/src/mosaic-stack/node_modules',join(run.checkout,'node_modules'));
validateTooling(run.checkout);verifyImage(image,run.checkout);
}
const probeProject='mg197-a-'+randomBytes(6).toString('hex');
put('watcher-probe-plan.json',{project:probeProject,container:probeProject+'-watcher-probe',network:probeProject+'_default',imageId:image.imageId});
watcherProbe=await probeDockerWatchers({docker,imageId:image.imageId,cleanupProject,project:probeProject});
if(!watcherProbe.ok)fail('Docker watcher preflight failed: '+JSON.stringify(watcherProbe));
for(const wave of plan){
if(interrupted)fail('proof interrupted');
const observed=status(state);
if(observed.legacy.length||observed.a.state!=='CLEAR'||observed.b.state!=='CLEAR')fail('proof requires both lanes clear and no legacy gate');
const jobs=wave.map(launch);
for(const j of jobs)j.done.then(result=>{if(result.rc){firstFailure??=failureDetails(j);abortActive();}}).catch(error=>{eventError.push(error.message);abortActive();});
const results=await Promise.all(jobs.map(j=>j.done));
if(firstFailure?.gate){const failedJob=jobs.find(j=>j.run.label===firstFailure.gate);if(failedJob)firstFailure=failureDetails(failedJob);}
if(interrupted||firstFailure||eventError.length||results.some(r=>r.rc))fail('failed wave: '+wave.map(r=>r.label).join(',')+' '+JSON.stringify(firstFailure));
measured.push(wave.map(gateReceipt));
const end=status(state);
if(end.legacy.length||end.a.state!=='CLEAR'||end.b.state!=='CLEAR')fail('unclean wave status');
}
const solos=measured.slice(0,2).flat(),pairs=measured.slice(2);summary={grant,watcherProbe,solos,pairs,summary:summarizePairs(solos,pairs),wallMs:performance.now()-start,finished:new Date().toISOString()};
}catch(error){
await stop();put('failed.json',{grant,message:error.message,firstFailure,measured,at:new Date().toISOString()});process.stderr.write(error.message+'\n');process.exitCode=1;
}finally{
clearInterval(monitorTimer);clearTimeout(abortTimer);await stop();const cleanupErrors=[];
try{ensureWatchers(true);}catch(error){cleanupErrors.push({message:error.message});}
for(const run of knownRuns.values())try{inventory(run.project);cleanupProject({project:run.project},docker);if(inventory(run.project).length)fail('Docker resources remain');}catch(error){cleanupErrors.push({project:run.project,message:error.message});}
await Promise.all([...watchers.values()].map(w=>w.stop()));
let finalStatus;try{finalStatus=status(state);if(finalStatus.legacy.length||finalStatus.a.state!=='CLEAR'||finalStatus.b.state!=='CLEAR')cleanupErrors.push({message:'lane status not clear',status:finalStatus});}catch(error){cleanupErrors.push({message:error.message});}
// Start receipts establish which short roots belong to this proof. Never
// discover roots by scanning scratch or follow a substituted root symlink.
const tempRoots=[];let procs;
try{procs=processInventory();}catch(error){cleanupErrors.push({message:'process inventory failed: '+error.message});}
for(const run of knownRuns.values()){
const record={gate:plan.flat().find(p=>p.checkout===run.checkout)?.label,lane:run.lane,tempRoot:run.tempRoot};
try{
if(!/^\/mnt\/storage\/scratch\/g[ab]-[A-Za-z0-9]{6}$/.test(run.tempRoot??'')||!run.tempRoot.startsWith('/mnt/storage/scratch/g'+run.lane+'-'))fail('invalid recorded temp root');
const st=lstatSync(run.tempRoot);
if(!st.isDirectory()||st.isSymbolicLink()||st.uid!==process.getuid()||realpathSync(run.tempRoot)!==run.tempRoot)fail('temp root is not an owned real directory');
const receipt=runRecords(state).find(r=>r.records.some(e=>e.kind==='start'&&e.checkout===run.checkout));
const groups=new Set(receipt?.records.filter(e=>e.kind==='child').map(e=>e.group));
if(!procs||sameProcess(run.owner)||procs.some(p=>p.state!=='Z'&&p.pid!==process.pid&&(groups.has(p.group)||p.argv.some(a=>a.includes(run.tempRoot)||a.includes(run.checkout)))))fail('run process still present or inventory unavailable; temp root retained');
const terminal=receipt?.records.filter(e=>e.kind==='terminal').at(-1);
if(!terminal?.clean){record.action='kept';record.reason='run cleanup unverified';cleanupErrors.push({...record});tempRoots.push(record);continue;}
if(terminal.rc!==0||firstFailure?.gate===record.gate){record.action='kept';record.reason='failed gate diagnosis';}
else{rmSync(run.tempRoot,{recursive:true});record.action='removed';}
}catch(error){record.action='error';record.message=error.message;cleanupErrors.push({...record});}
tempRoots.push(record);
}
put('docker-summary.json',{watcherProbe,objects:[...objects.values()],eventError,cleanupErrors,finalStatus,tempRoots});
if(cleanupErrors.length||eventError.length||[...objects.values()].some(o=>!o.name))process.exitCode=1;
if(summary&&!process.exitCode){
for(const run of knownRuns.values())for(const kind of ['container','network'])if(![...objects.values()].some(o=>o.project===run.project&&o.kind===kind&&o.created)){process.exitCode=1;process.stderr.write('missing Docker creation evidence: '+run.project+' '+kind+'\n');}
if(!process.exitCode){put('summary.json',summary);process.stdout.write('PROOF_PASSED '+join(root,'summary.json')+'\n');}
}
for(const name of ['SIGINT','SIGTERM','SIGHUP'])process.off(name,signal);
}
}
if(process.argv[1]&&import.meta.url===pathToFileURL(process.argv[1]).href)await runProof();
+207
View File
@@ -0,0 +1,207 @@
{
"r1Base": "aa1bd209e",
"r1Candidate": "6318f0d0bba9ee90ccaa3503cac8c2e04c6397f2ef6c3564f687fbc2092989e3",
"r2Base": "57f59e0c399ce29b1bb85ca6af686fdc10eb195d",
"rows": [
{
"path": "packages/cli/tests/fixtures/gate-lane-child.mjs",
"r1Sha256": "209682b968e929da4b46dc97c64fcd7aa68c490f3c6be44595f5d85f8d2e1483",
"r2Sha256": "209682b968e929da4b46dc97c64fcd7aa68c490f3c6be44595f5d85f8d2e1483",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "packages/cli/tests/gate-lane-fixtures.test.mjs",
"r1Sha256": "29afa181804a724c695678eb9459c156e00f1084647c704e814c5bee156c0c25",
"r2Sha256": "29afa181804a724c695678eb9459c156e00f1084647c704e814c5bee156c0c25",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "packages/cli/tests/gate-lane-image.test.mjs",
"r1Sha256": "0253621a49c2e77ffcdbf32beee7e1f773ec2c52c8487dcc5aa254e61807af7c",
"r2Sha256": "0253621a49c2e77ffcdbf32beee7e1f773ec2c52c8487dcc5aa254e61807af7c",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "packages/cli/tests/gate-lane-state.test.mjs",
"r1Sha256": "948d62c76447d8269f03518562a261564c202ec9207461fb21d2b1c11c9bb9fc",
"r2Sha256": "948d62c76447d8269f03518562a261564c202ec9207461fb21d2b1c11c9bb9fc",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "packages/cli/tests/gate-lane.test.mjs",
"r1Sha256": "240bf0c15025ad6f88b251917a3c8d72bd21c2e5df6771e1ceb83a1d90b24a51",
"r2Sha256": "240bf0c15025ad6f88b251917a3c8d72bd21c2e5df6771e1ceb83a1d90b24a51",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "packages/cli/tests/stack-release.test.mjs",
"r1Sha256": "8ca733a600d5f5534d7e14d8533e1b16d710ebd43080ef8421238b5dd145fade",
"r2Sha256": "b95c0c125b6a283961e24adc07d43150bcbd05d2f11005180b836043015c52a6",
"mode": "0o644",
"landedPathChanged": true,
"r1BlobEqual": false
},
{
"path": "packages/control-board/tests/serve.test.mjs",
"r1Sha256": "4838cb4609da28fc921370c5542a82402fbc9ceb469692a36f0eafc713bb56c8",
"r2Sha256": "4838cb4609da28fc921370c5542a82402fbc9ceb469692a36f0eafc713bb56c8",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "packages/harness/tests/claude-session.test.mjs",
"r1Sha256": "0a09b0255e9144a039428eb8e9f56181f94ccb2d187e474ea13392b485fdd008",
"r2Sha256": "0a09b0255e9144a039428eb8e9f56181f94ccb2d187e474ea13392b485fdd008",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "packages/harness/tests/pi-session.test.mjs",
"r1Sha256": "6db5c3b7063baf933061e91e8bdf66740717dbb3066be3b70ac779fb17022094",
"r2Sha256": "6db5c3b7063baf933061e91e8bdf66740717dbb3066be3b70ac779fb17022094",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "packages/webui/tests/preview.test.mjs",
"r1Sha256": "ada49ec100d75b3e739263593164b2798c378f3df0f38d73d80cf011ef16e234",
"r2Sha256": "ada49ec100d75b3e739263593164b2798c378f3df0f38d73d80cf011ef16e234",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "packages/webui/tests/proof-cli.test.mjs",
"r1Sha256": "d3cec62f6eb8e2c1e0747f2a1bf1fc3f1a2122e45870d5a416d1830d3b6182cb",
"r2Sha256": "d3cec62f6eb8e2c1e0747f2a1bf1fc3f1a2122e45870d5a416d1830d3b6182cb",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "packages/webui/tests/serve.test.mjs",
"r1Sha256": "36ecdfcb3d0d9a2411eec751421f770b5bb41ec2fbf9236c5794fb241fd3196b",
"r2Sha256": "36ecdfcb3d0d9a2411eec751421f770b5bb41ec2fbf9236c5794fb241fd3196b",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "packages/webui/tests/service.test.mjs",
"r1Sha256": "c915239f0c223209baf84c96ec9f5ae2845fce7dd98ad46b073a96033aa7d6df",
"r2Sha256": "845313239547ddbc478090b29f96385f0b8853e5087c572393be328e7075f578",
"mode": "0o644",
"landedPathChanged": true,
"r1BlobEqual": false
},
{
"path": "packages/webui/tests/tester-cli.test.mjs",
"r1Sha256": "8bf4491f7982399b4162cfbe36d0b68888e99ceb2cbaefefc2361732dd95e0c8",
"r2Sha256": "8bf4491f7982399b4162cfbe36d0b68888e99ceb2cbaefefc2361732dd95e0c8",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "scripts/test-discord.sh",
"r1Sha256": "c10af53b0717344026cb22462964f253c7401c5082ac6ef3722a89d9dfa5c661",
"r2Sha256": "a5dcf783c51a5d5d184a357f91ebf9fa1c4e054f0cb84a3d1d7668f6156e025e",
"mode": "0o755",
"landedPathChanged": true,
"r1BlobEqual": false
},
{
"path": "scripts/test-foundation.sh",
"r1Sha256": "2e11038e7f784ec4f539eb768f63bab0fab9c9940334388a1fe2d47ab9e2b008",
"r2Sha256": "2e11038e7f784ec4f539eb768f63bab0fab9c9940334388a1fe2d47ab9e2b008",
"mode": "0o755",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "scripts/test-queue.sh",
"r1Sha256": "c270f181d1fa8b6d42ff4131c339beba786a8fd620bb66f8afdcec05e8b10868",
"r2Sha256": "c270f181d1fa8b6d42ff4131c339beba786a8fd620bb66f8afdcec05e8b10868",
"mode": "0o755",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "scripts/testing/README.md",
"r1Sha256": "845db994e648f29f10efb5e6a4ddcf4be4e28798fc27f1eddf9d150f0e8b5319",
"r2Sha256": "845db994e648f29f10efb5e6a4ddcf4be4e28798fc27f1eddf9d150f0e8b5319",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "scripts/testing/gate-lane.mjs",
"r1Sha256": "2e5decf42a546b1441c199337c9a3162693051f2299878f617dd039d6231f226",
"r2Sha256": "2e5decf42a546b1441c199337c9a3162693051f2299878f617dd039d6231f226",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "scripts/testing/gate-lane.sh",
"r1Sha256": "41804d964c55dd07b6224855aae9dc44037f4e80c515c10f98fe04d6d3fa0084",
"r2Sha256": "41804d964c55dd07b6224855aae9dc44037f4e80c515c10f98fe04d6d3fa0084",
"mode": "0o755",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "scripts/testing/lane-docker.mjs",
"r1Sha256": "fbe54165439923a852f64d69a6188ef29d237755289e9e21ddbfdab00e9ef933",
"r2Sha256": "fbe54165439923a852f64d69a6188ef29d237755289e9e21ddbfdab00e9ef933",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "scripts/testing/lane-evidence.mjs",
"r1Sha256": "51d4dcff4ab01730b923e0a59c218f9a3d33e6ba729be1def03703bc457fb37e",
"r2Sha256": "51d4dcff4ab01730b923e0a59c218f9a3d33e6ba729be1def03703bc457fb37e",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "scripts/testing/lane-fixtures.mjs",
"r1Sha256": "8634070d68bca694c8a58911eda9576991ed0eca1eb055a2c1bbf10261305939",
"r2Sha256": "8634070d68bca694c8a58911eda9576991ed0eca1eb055a2c1bbf10261305939",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "scripts/testing/lane-image.mjs",
"r1Sha256": "2259045913b4e3042b7afd3ff6a0fb9c2cc9b4cf359f943e4dce9b8658b91982",
"r2Sha256": "2259045913b4e3042b7afd3ff6a0fb9c2cc9b4cf359f943e4dce9b8658b91982",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
},
{
"path": "scripts/testing/lane-state.mjs",
"r1Sha256": "c37b3d5ec3025948dd8150d18d4ccc4eeb3539739ca37e4d86edede5bcafd1a8",
"r2Sha256": "c37b3d5ec3025948dd8150d18d4ccc4eeb3539739ca37e4d86edede5bcafd1a8",
"mode": "0o644",
"landedPathChanged": false,
"r1BlobEqual": true
}
]
}
@@ -0,0 +1,19 @@
packages/cli/tests/gate-lane-fixtures.test.mjs
packages/cli/tests/gate-lane-image.test.mjs
packages/cli/tests/gate-lane-state.test.mjs
packages/cli/tests/gate-lane.test.mjs
packages/cli/tests/stack-instances.test.mjs
packages/cli/tests/stack-release-external.test.mjs
packages/cli/tests/stack-release.test.mjs
packages/cli/tests/stack-upgrade.test.mjs
packages/control-board/tests/serve.test.mjs
packages/harness/tests/claude-session.test.mjs
packages/harness/tests/pi-session.test.mjs
packages/webui/tests/preview.test.mjs
packages/webui/tests/proof-cli.test.mjs
packages/webui/tests/serve.test.mjs
packages/webui/tests/service.test.mjs
packages/webui/tests/tester-cli.test.mjs
scripts/test-discord.sh
scripts/test-foundation.sh
scripts/test-queue.sh
@@ -0,0 +1,45 @@
diff --git a/packages/cli/tests/stack-release.test.mjs b/packages/cli/tests/stack-release.test.mjs
index 8788158c3..a78f52062 100644
--- a/packages/cli/tests/stack-release.test.mjs
+++ b/packages/cli/tests/stack-release.test.mjs
@@ -0,0 +1,2 @@
+import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
+import { after as laneAfter } from 'node:test';
@@ -1777 +1779 @@ const TESTER='7499';
-const freePort=async()=>{const s=createServer();s.listen(0,'127.0.0.1');await once(s,'listening');const p=String(s.address().port);await new Promise((r)=>s.close(r));return p};
+const freePort=async()=>String(await leasePort({after:laneAfter},{closed:true}));
diff --git a/packages/webui/tests/service.test.mjs b/packages/webui/tests/service.test.mjs
index a6e66608a..adb460d07 100644
--- a/packages/webui/tests/service.test.mjs
+++ b/packages/webui/tests/service.test.mjs
@@ -0,0 +1,2 @@
+import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
+import { after as laneAfter } from 'node:test';
@@ -48,9 +50 @@ function filled(flags, root = REPO) {
-async function closedPort() {
- const server = createServer();
- server.listen(0, '127.0.0.1');
- await once(server, 'listening');
- const { port } = server.address();
- server.close();
- await once(server, 'close');
- return port;
-}
+async function closedPort() { return leasePort({after:laneAfter}, {closed:true}); }
diff --git a/scripts/test-discord.sh b/scripts/test-discord.sh
index 114994936..4daf0d143 100755
--- a/scripts/test-discord.sh
+++ b/scripts/test-discord.sh
@@ -73,0 +74,2 @@ TOOLS_JSON="{\"roots\":[{\"name\":\"docs\",\"path\":\"$SANDBOX/toolroot\"}],\"ma
+export PI_CODING_AGENT_DIR="$SANDBOX/pi-agent"
+mkdir -p "$PI_CODING_AGENT_DIR"
@@ -145 +147,8 @@ fi
-node_tests() { env -u NODE_TEST_CONTEXT node --test "$@"; }
+node_tests() {
+ local lane_concurrency=()
+ if [ -n "${MOSAIC_TEST_CONCURRENCY:-}" ]; then
+ [ "$MOSAIC_TEST_CONCURRENCY" = 2 ] || return 2
+ lane_concurrency=(--test-concurrency=2)
+ fi
+ env -u NODE_TEST_CONTEXT node --test "${lane_concurrency[@]}" "$@"
+}
@@ -0,0 +1,235 @@
{
"base": "c771a36f4bd0b6c359282425c668996a90072f93",
"priorCandidate": "a3b7cbde8ef51c206a83769a7c272e4f9a1de5bc",
"originalCandidate": "6318f0d0bba9ee90ccaa3503cac8c2e04c6397f2ef6c3564f687fbc2092989e3",
"overlap": [
"packages/cli/tests/stack-release.test.mjs"
],
"rows": [
{
"path": "packages/cli/tests/fixtures/gate-lane-child.mjs",
"r1Sha256": "209682b968e929da4b46dc97c64fcd7aa68c490f3c6be44595f5d85f8d2e1483",
"priorCandidateSha256": "209682b968e929da4b46dc97c64fcd7aa68c490f3c6be44595f5d85f8d2e1483",
"newSha256": "209682b968e929da4b46dc97c64fcd7aa68c490f3c6be44595f5d85f8d2e1483",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "packages/cli/tests/gate-lane-fixtures.test.mjs",
"r1Sha256": "29afa181804a724c695678eb9459c156e00f1084647c704e814c5bee156c0c25",
"priorCandidateSha256": "29afa181804a724c695678eb9459c156e00f1084647c704e814c5bee156c0c25",
"newSha256": "29afa181804a724c695678eb9459c156e00f1084647c704e814c5bee156c0c25",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "packages/cli/tests/gate-lane-image.test.mjs",
"r1Sha256": "0253621a49c2e77ffcdbf32beee7e1f773ec2c52c8487dcc5aa254e61807af7c",
"priorCandidateSha256": "0253621a49c2e77ffcdbf32beee7e1f773ec2c52c8487dcc5aa254e61807af7c",
"newSha256": "0253621a49c2e77ffcdbf32beee7e1f773ec2c52c8487dcc5aa254e61807af7c",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "packages/cli/tests/gate-lane-state.test.mjs",
"r1Sha256": "948d62c76447d8269f03518562a261564c202ec9207461fb21d2b1c11c9bb9fc",
"priorCandidateSha256": "948d62c76447d8269f03518562a261564c202ec9207461fb21d2b1c11c9bb9fc",
"newSha256": "948d62c76447d8269f03518562a261564c202ec9207461fb21d2b1c11c9bb9fc",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "packages/cli/tests/gate-lane.test.mjs",
"r1Sha256": "240bf0c15025ad6f88b251917a3c8d72bd21c2e5df6771e1ceb83a1d90b24a51",
"priorCandidateSha256": "240bf0c15025ad6f88b251917a3c8d72bd21c2e5df6771e1ceb83a1d90b24a51",
"newSha256": "240bf0c15025ad6f88b251917a3c8d72bd21c2e5df6771e1ceb83a1d90b24a51",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "packages/cli/tests/stack-release.test.mjs",
"r1Sha256": "8ca733a600d5f5534d7e14d8533e1b16d710ebd43080ef8421238b5dd145fade",
"priorCandidateSha256": "b95c0c125b6a283961e24adc07d43150bcbd05d2f11005180b836043015c52a6",
"newSha256": "071ea2803b8c7e0450bb4c86ac9b49ae7c180af1599dca52bfce6d0274ca205b",
"unchangedFromPrior": false,
"overlaps234": true,
"mode": "0o644"
},
{
"path": "packages/control-board/tests/serve.test.mjs",
"r1Sha256": "4838cb4609da28fc921370c5542a82402fbc9ceb469692a36f0eafc713bb56c8",
"priorCandidateSha256": "4838cb4609da28fc921370c5542a82402fbc9ceb469692a36f0eafc713bb56c8",
"newSha256": "4838cb4609da28fc921370c5542a82402fbc9ceb469692a36f0eafc713bb56c8",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "packages/harness/tests/claude-session.test.mjs",
"r1Sha256": "0a09b0255e9144a039428eb8e9f56181f94ccb2d187e474ea13392b485fdd008",
"priorCandidateSha256": "0a09b0255e9144a039428eb8e9f56181f94ccb2d187e474ea13392b485fdd008",
"newSha256": "0a09b0255e9144a039428eb8e9f56181f94ccb2d187e474ea13392b485fdd008",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "packages/harness/tests/pi-session.test.mjs",
"r1Sha256": "6db5c3b7063baf933061e91e8bdf66740717dbb3066be3b70ac779fb17022094",
"priorCandidateSha256": "6db5c3b7063baf933061e91e8bdf66740717dbb3066be3b70ac779fb17022094",
"newSha256": "6db5c3b7063baf933061e91e8bdf66740717dbb3066be3b70ac779fb17022094",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "packages/webui/tests/preview.test.mjs",
"r1Sha256": "ada49ec100d75b3e739263593164b2798c378f3df0f38d73d80cf011ef16e234",
"priorCandidateSha256": "ada49ec100d75b3e739263593164b2798c378f3df0f38d73d80cf011ef16e234",
"newSha256": "ada49ec100d75b3e739263593164b2798c378f3df0f38d73d80cf011ef16e234",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "packages/webui/tests/proof-cli.test.mjs",
"r1Sha256": "d3cec62f6eb8e2c1e0747f2a1bf1fc3f1a2122e45870d5a416d1830d3b6182cb",
"priorCandidateSha256": "d3cec62f6eb8e2c1e0747f2a1bf1fc3f1a2122e45870d5a416d1830d3b6182cb",
"newSha256": "d3cec62f6eb8e2c1e0747f2a1bf1fc3f1a2122e45870d5a416d1830d3b6182cb",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "packages/webui/tests/serve.test.mjs",
"r1Sha256": "36ecdfcb3d0d9a2411eec751421f770b5bb41ec2fbf9236c5794fb241fd3196b",
"priorCandidateSha256": "36ecdfcb3d0d9a2411eec751421f770b5bb41ec2fbf9236c5794fb241fd3196b",
"newSha256": "36ecdfcb3d0d9a2411eec751421f770b5bb41ec2fbf9236c5794fb241fd3196b",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "packages/webui/tests/service.test.mjs",
"r1Sha256": "c915239f0c223209baf84c96ec9f5ae2845fce7dd98ad46b073a96033aa7d6df",
"priorCandidateSha256": "845313239547ddbc478090b29f96385f0b8853e5087c572393be328e7075f578",
"newSha256": "845313239547ddbc478090b29f96385f0b8853e5087c572393be328e7075f578",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "packages/webui/tests/tester-cli.test.mjs",
"r1Sha256": "8bf4491f7982399b4162cfbe36d0b68888e99ceb2cbaefefc2361732dd95e0c8",
"priorCandidateSha256": "8bf4491f7982399b4162cfbe36d0b68888e99ceb2cbaefefc2361732dd95e0c8",
"newSha256": "8bf4491f7982399b4162cfbe36d0b68888e99ceb2cbaefefc2361732dd95e0c8",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "scripts/test-discord.sh",
"r1Sha256": "c10af53b0717344026cb22462964f253c7401c5082ac6ef3722a89d9dfa5c661",
"priorCandidateSha256": "a5dcf783c51a5d5d184a357f91ebf9fa1c4e054f0cb84a3d1d7668f6156e025e",
"newSha256": "a5dcf783c51a5d5d184a357f91ebf9fa1c4e054f0cb84a3d1d7668f6156e025e",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o755"
},
{
"path": "scripts/test-foundation.sh",
"r1Sha256": "2e11038e7f784ec4f539eb768f63bab0fab9c9940334388a1fe2d47ab9e2b008",
"priorCandidateSha256": "2e11038e7f784ec4f539eb768f63bab0fab9c9940334388a1fe2d47ab9e2b008",
"newSha256": "2e11038e7f784ec4f539eb768f63bab0fab9c9940334388a1fe2d47ab9e2b008",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o755"
},
{
"path": "scripts/test-queue.sh",
"r1Sha256": "c270f181d1fa8b6d42ff4131c339beba786a8fd620bb66f8afdcec05e8b10868",
"priorCandidateSha256": "c270f181d1fa8b6d42ff4131c339beba786a8fd620bb66f8afdcec05e8b10868",
"newSha256": "c270f181d1fa8b6d42ff4131c339beba786a8fd620bb66f8afdcec05e8b10868",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o755"
},
{
"path": "scripts/testing/README.md",
"r1Sha256": "845db994e648f29f10efb5e6a4ddcf4be4e28798fc27f1eddf9d150f0e8b5319",
"priorCandidateSha256": "845db994e648f29f10efb5e6a4ddcf4be4e28798fc27f1eddf9d150f0e8b5319",
"newSha256": "845db994e648f29f10efb5e6a4ddcf4be4e28798fc27f1eddf9d150f0e8b5319",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "scripts/testing/gate-lane.mjs",
"r1Sha256": "2e5decf42a546b1441c199337c9a3162693051f2299878f617dd039d6231f226",
"priorCandidateSha256": "2e5decf42a546b1441c199337c9a3162693051f2299878f617dd039d6231f226",
"newSha256": "2e5decf42a546b1441c199337c9a3162693051f2299878f617dd039d6231f226",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "scripts/testing/gate-lane.sh",
"r1Sha256": "41804d964c55dd07b6224855aae9dc44037f4e80c515c10f98fe04d6d3fa0084",
"priorCandidateSha256": "41804d964c55dd07b6224855aae9dc44037f4e80c515c10f98fe04d6d3fa0084",
"newSha256": "41804d964c55dd07b6224855aae9dc44037f4e80c515c10f98fe04d6d3fa0084",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o755"
},
{
"path": "scripts/testing/lane-docker.mjs",
"r1Sha256": "fbe54165439923a852f64d69a6188ef29d237755289e9e21ddbfdab00e9ef933",
"priorCandidateSha256": "fbe54165439923a852f64d69a6188ef29d237755289e9e21ddbfdab00e9ef933",
"newSha256": "fbe54165439923a852f64d69a6188ef29d237755289e9e21ddbfdab00e9ef933",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "scripts/testing/lane-evidence.mjs",
"r1Sha256": "51d4dcff4ab01730b923e0a59c218f9a3d33e6ba729be1def03703bc457fb37e",
"priorCandidateSha256": "51d4dcff4ab01730b923e0a59c218f9a3d33e6ba729be1def03703bc457fb37e",
"newSha256": "51d4dcff4ab01730b923e0a59c218f9a3d33e6ba729be1def03703bc457fb37e",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "scripts/testing/lane-fixtures.mjs",
"r1Sha256": "8634070d68bca694c8a58911eda9576991ed0eca1eb055a2c1bbf10261305939",
"priorCandidateSha256": "8634070d68bca694c8a58911eda9576991ed0eca1eb055a2c1bbf10261305939",
"newSha256": "8634070d68bca694c8a58911eda9576991ed0eca1eb055a2c1bbf10261305939",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "scripts/testing/lane-image.mjs",
"r1Sha256": "2259045913b4e3042b7afd3ff6a0fb9c2cc9b4cf359f943e4dce9b8658b91982",
"priorCandidateSha256": "2259045913b4e3042b7afd3ff6a0fb9c2cc9b4cf359f943e4dce9b8658b91982",
"newSha256": "2259045913b4e3042b7afd3ff6a0fb9c2cc9b4cf359f943e4dce9b8658b91982",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
},
{
"path": "scripts/testing/lane-state.mjs",
"r1Sha256": "c37b3d5ec3025948dd8150d18d4ccc4eeb3539739ca37e4d86edede5bcafd1a8",
"priorCandidateSha256": "c37b3d5ec3025948dd8150d18d4ccc4eeb3539739ca37e4d86edede5bcafd1a8",
"newSha256": "c37b3d5ec3025948dd8150d18d4ccc4eeb3539739ca37e4d86edede5bcafd1a8",
"unchangedFromPrior": true,
"overlaps234": false,
"mode": "0o644"
}
]
}
@@ -0,0 +1,47 @@
Compared 57f59e0c3 to c771a36f4bd0b6c359282425c668996a90072f93
agents/gizmoduck/work/queue-234/BUILD.md
docs/SESSIONS.md
packages/bus/README.md
packages/bus/schema.sql
packages/bus/src/broker.mjs
packages/bus/src/credentials.mjs
packages/bus/src/host-grant.mjs
packages/bus/src/host-run.mjs
packages/bus/src/migrations/3i-3j.sql
packages/bus/src/runtime.mjs
packages/bus/src/server.mjs
packages/bus/src/store.mjs
packages/bus/tests/account-migration.test.mjs
packages/bus/tests/chief-of-staff.test.mjs
packages/bus/tests/comment-migration.test.mjs
packages/bus/tests/fixtures/schema-3i.sql
packages/bus/tests/grant-enroll.test.mjs
packages/bus/tests/grant-migration.test.mjs
packages/bus/tests/hard-waits.test.mjs
packages/bus/tests/host-fixtures.mjs
packages/bus/tests/host-run.test.mjs
packages/bus/tests/host-tools.test.mjs
packages/bus/tests/name-migration.test.mjs
packages/bus/tests/project-runtime.test.mjs
packages/bus/tests/schema.test.mjs
packages/bus/tests/spec-fixtures.mjs
packages/bus/tests/spec-migration.test.mjs
packages/bus/tests/thread-migration.test.mjs
packages/bus/tests/usage-migration.test.mjs
packages/business/src/business.mjs
packages/business/src/hosts.mjs
packages/business/tests/hosts.test.mjs
packages/cli/README.md
packages/cli/src/cli.mjs
packages/cli/src/hosts.mjs
packages/cli/tests/hosts.test.mjs
packages/cli/tests/stack-release.test.mjs
scripts/mosaic
tools/host/mosaic-keys
tools/host/mosaic-keys-write
tools/host/mosaic-keys.sshd.conf
tools/host/mosaic-keys.sudoers
Overlap with row 197 source manifest:
packages/cli/tests/stack-release.test.mjs
Other 24 source paths: no overlap.
@@ -0,0 +1,10 @@
diff --git a/packages/cli/tests/stack-release.test.mjs b/packages/cli/tests/stack-release.test.mjs
index daae6efb7..910785bc4 100644
--- a/packages/cli/tests/stack-release.test.mjs
+++ b/packages/cli/tests/stack-release.test.mjs
@@ -0,0 +1,2 @@
+import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
+import { after as laneAfter } from 'node:test';
@@ -1779 +1781 @@ const TESTER='7499';
-const freePort=async()=>{const s=createServer();s.listen(0,'127.0.0.1');await once(s,'listening');const p=String(s.address().port);await new Promise((r)=>s.close(r));return p};
+const freePort=async()=>String(await leasePort({after:laneAfter},{closed:true}));
+216
View File
@@ -0,0 +1,216 @@
# Row 199 (#1697): per-agent credential store, each agent's own git identity, publish to its fork
Builder: Megavolt. Brief: `docs/plans/2026-10-20_agent-credentials.md`. Design: `DESIGN.md` r6 at
4d3007ec9, approved by Duckworth in duckworth-0027 (REVIEW-r6.md at 640011518). Authority model ruled
by Mos in mos-1027. Branch: megavolt/row199. Reviewer: Duckworth.
Status: candidate on megavolt/row199-on-190, rebased on origin/refactor 00d4cc1f6 (sage-1555). The
full gate and the section 11 mutants ran in Sage's slot; results under "Runs".
## Commits so far
- 286bf598c business: agent identity, the publish grant, the project upstream with the D9c parse, store
credential references; git.push.remote as a hard wait.
- a2c387dba bus: broker publish authority (checkPublish, recordPublish, publishRefused, pushCommit on
the raise); the D9c parse moves to `business/src/vocabulary.mjs`; pm.md and chief-of-staff.md list
git.push.remote among the hard waits.
- 609dc9364 bus: the per-agent credential store in `credentials.mjs`.
- 504336797 bus: runtime wiring, the store root and the four publish entries in the op table.
- a18fc6cf8 tasks: request calls use `useFor` keyed from the session; the start probe keeps `use`. The
D5 test in `credential-store.test.mjs` pins the `use(` call sites to `credentials.mjs` (useFor's own
call) and `tasks/src/adapter.mjs` (the host's @sync and start probe).
- aa740cc71 bus: the lifecycle verbs `credentials.issue`, `credentials.revoke` and `credentials.delete`
in `credential-verbs.mjs`, the broker branch, the runtime wiring; `organizationWriter` gets a
`detail` on `write` and a receipt-only `record`.
- 0cdace415 cli: `mosaic credentials status|issue|rotate|revoke|delete`, the scripts/mosaic route, docs.
## Deviations from DESIGN.md r6
1. **Identity is optional on an agent.** D8 reads as identity on every agent. Today's business files
carry none, so identity stays optional and `publish` requires it (N10's load refusal). An agent
without identity commits as E7 (T6).
2. **Revoke of a `{file}` or `{env}` reference.** Section 6 describes revoke for store references.
For a reference outside the store, revoke forgets the token in memory and never unlinks the file;
the lifecycle verb then turns the business file's reference into `store`, so a restart finds
nothing. The test: `credential-store.test.mjs`, "revoke of a {file} reference".
3. **Issue order.** Issue writes the token file (tmp, fsync, rename, dir fsync), then runs the
caller's commit (the business file's reference becomes `store`), then sets memory. A failed commit
leaves the file on disk and the token out of memory, so the agent stays `not-issued` until the
issue is run again. The test: "a failed commit leaves the token on disk but not in memory".
4. **The D9c parse lives in `business/src/vocabulary.mjs`, not `business.mjs`.** The broker reads
`forkProblem` and `forkUrl`, and `project-runtime.test.mjs` pins the broker's import closure to
files that import nothing. `business.mjs` wraps `parseGiteaUrl` with the loader's refusal
messages. The pin test names the two new imports.
5. **`withGitea` takes the publish ticket, not a cap (D9b).** The key is the session checkPublish
checked with `#agent` (a run holding its claim; never a tester, reader or human), read through
`broker.publishSession(ticket)`. The token is usable only between checkPublish and recordPublish
or publishRefused. Its refusals are filed once, by publishRefused on the task's trail, and not
also by `refusing()`. The test: `publish.test.mjs`, "runtime: withGitea keys from the open ticket
only".
6. **Role contracts.** `workflow-roles.test.mjs` requires the hard waits listed in `roles/pm.md` and
`roles/chief-of-staff.md` to equal GATED_ONLY. Adding git.push.remote to the vocabulary made it
fail, so both lists now name git.push.remote. Prose only; no hash pin reads either file. Duckworth
should review this hunk because `roles/` is reviewed authority.
7. **Delete takes a reason.** Section 6 lists `delete AGENT` with no reason. The broker requires
`reason` on delete as on revoke, so every receipt that ends a credential says why.
8. **Rotate is `credentials.issue`.** There are three bus verbs, not four. An issue over an entry the
bus holds is recorded as `rotate` in the receipt and the answer. The CLI's `rotate` reads the
status first and refuses before reading stdin when nothing is issued.
9. **Issue covers entries the bus loaded.** The business file must already name the kind for the
agent, and the bus must have loaded that reference at start; otherwise `credential-unknown`. An
agent added after the bus started needs the restart row 165 already asks for. Row 194's human
`create` is where a new agent's `store` references come from.
10. **No Console page yet.** Section 6 puts the verbs in the Console's agent page too. This branch has
the bus verbs and the CLI. The Console page is a separate piece (packages/webui, its `BUS_VERBS`
and `STATUS` pins); I raise the split with Sage before the manifest. Ruled in sage-1421: the
Console page is out of row 199 and becomes a follow-up row with Webby as reviewer. The manifest
holds bus, business, tasks, cli and seat.
11. **The push runs in the bus child, and the git socket polls for it (D9b).** The git socket runs in
the host process. The broker, the runtime and the token are in the bus child that
`packages/cli/src/host.mjs` forks, and host ops cross that IPC one at a time with a 10 s answer
limit. ls-remote and the push can run longer than that, so `withGitea(ticket, fn)` can't wrap a
host-side git. The split: the socket runs D9 steps 3 and 4 (tip and identity under the lock,
`working.publish`), then op `publishPush`, which starts `publishRemote` in the bus child under
`withGitea` and answers `{state: 'running'}`. The socket polls op `publishResult` every 200 ms
until it reads `pushed` or `refused`. Fork, user, source and destination come from the ticket
(`broker.publishTarget`), never from the host's message. `recordPublish` also refuses `ref-moved`
when the pushed ref isn't the ticket's destination. A second `publishPush` on a running ticket
refuses `publish-busy`. The token reaches git on fd 3 only (`runGit` option `pass`), read once
by a one-shot credential helper that answers `get` for https on the fork's exact host and port.
On close, a running publish's git isn't awaited; git's own time limit ends it, and the ticket
goes with the process. The 10 s IPC limit stays as it is (sage-1440). The poll has a bound
(sage-1440): `publishDeadlineMs`, by default four times git's limit and grace (publishRemote runs
at most four git commands) plus 30 s. Past it the socket refuses `publish-timeout`, filed once by
publishRefused, and logs that the bus process's push may still reach the fork. A failure that is not a refusal is filed as `git-failed` and logged on
the bus's stderr by error name and code only. Tests: `seat/tests/publish.test.mjs` (11),
`bus/tests/publish.test.mjs` (the process ops), `cli/tests/publish-socket.test.mjs` (7, end to
end through the socket, the in-process runtime, the store and a TLS fake Gitea; one is the
publish-timeout outcome).
12. **No harness tool for publish.** `packages/harness/src/git-tools.mjs` has push, fetch and land
clients. A runner tool that calls the socket's `publish` op is in packages/harness, outside the
manifest scope sage-1421 set. Today a publish needs a socket client; the tool is a follow-up.
13. **The bind rule's `git.push.remote` clause has no test of its own.** D11 adds it and the
launcher has it. A role can publish only a push its own role made (`work-not-yours`), and
`recordPush` needs `git.push.working`, which already binds the socket. No role reaches the
clause alone, so dropping it changes no outcome today. It matters once a role can publish work
it didn't push. Ruled in sage-1440: keep it; its mutant goes in the section 11 list as
equivalent, with this reasoning, and Duckworth judges it.
14. **A push between checkPublish and the git push refuses; it never lands a different commit.**
T3 says such a push "doesn't change the commit that lands". Under the hard wait it refuses:
`working.publish` checks the work ref's tip against the ticket's source under the lock
(`ref-moved`, `seat/tests/publish.test.mjs`), and `recordPublish` checks `pushCommit` again
(`decision-mismatch`, `bus/tests/publish.test.mjs` "r4 B4"). The fork gets only the ticket's
oid either way. N9's routing-commit test still applies. Ruled in sage-1440: T3's wording in
DESIGN.md now reads "refuses with `ref-moved` or `decision-mismatch`", and an end-to-end test
asserts each code mid-flight in `cli/tests/publish-socket.test.mjs`: a push after checkPublish
refuses `ref-moved` with no request to the fork; a push recorded while the bus process pushes
refuses `decision-mismatch`, filed once, and the fork holds only the ticket's oid.
15. **Fix: rotate on a due gitea key (gizmoduck-0050, sage-1494).** The CLI's `ISSUED` set
(`packages/cli/src/credentials.mjs`) listed valid, expiring and expired, but the bus's `status()`
gives a gitea key past its rotate-by date `rotation-due`. So `credentials rotate` refused the one
case it exists for. `ISSUED` now names every state status() gives a held token. The test:
`cli/tests/credentials.test.mjs`, "rotate goes ahead on each held state". Its mutant (the old set)
fails that test and goes in the section 11 list.
16. **The bus process refuses a boot config carrying `publishTls` (M26, sage-1563).** process.mjs
used to drop `publishTls` from the boot config before `startBroker`, and no test could see the
drop. The host's boot config (`packages/cli/src/config.mjs`) never carries it; it is the tests'
scratch CA for an in-process runtime only. A boot that carries it now refuses
`invalid-host-request` and the process exits 2, rather than trusting it or dropping it quietly.
The test: `bus/tests/publish.test.mjs`, "the bus process answers the publish ops from their own
lines". Every test file that reads process.mjs ran singly, green (see "Runs").
17. **M17 is equivalent: `#authorityFor`'s pushCommit comparison is also made by `#publishFacts`.**
A git.push.remote raise with no recorded push refuses `no-push` (`broker.mjs` raise,
`pushCommit = ... ?? fail('no-push')`), so every publish decision carries a pushCommit.
`#publishFacts` takes `source = result.pushCommit` and refuses `decision-mismatch` when it isn't
`#latestPush(task_ref).commit`, the same two values `#authorityFor` compares. The check stays as
defense in depth, for a decision whose context lacks pushCommit, which no code path writes today.
Duckworth judges it, as with M27.
## N9, for the routing commit (sage-1388)
Today git.push.remote is a hard wait, so every publish carries a decision, and the decision's
`pushCommit` fixes the commit. If a later commit routes git.push.remote within role, a publish has
no decision and no `pushCommit`. `#publishFacts` then takes the commit from `#latestPush`, and the
git socket's tip check under the lock is the only thing that holds it. That commit needs a test
that a push between checkPublish and the git push doesn't change what lands.
## R1, the backup set
Checked at 504336797. Row 185's `backup_store` (`scripts/stack-release.sh:372-392`) copies only
`<dataRoot>/bus/bus.sqlite`, plus its sums, modes, listing and stamps, into
`<dataRoot>/backups/stack/<activation>/`. Nothing under `<configDir>` is copied, so
`<configDir>/secrets/agents` isn't in the backup set. `rehearse` copies the live business file
through `R_BUSINESS_JS` (`:1268-1288`). Its `swap` replaces every credential reference, a `store`
one included, with a placeholder file in the rehearsal's own dir. The guard then refuses
`credential-ref-live` unless every reference is such a placeholder (`:1329-1337`), so a rehearsal
never reads a store token. A backup tool outside this repository that copies `<configDir>` would
copy tokens. That is R1's open half, and it is Jason's to check on each VM.
## T1 limit
A wrong owner on a store dir or file can't be set up without root, so T1 covers modes, symlinks and
dirs. The owner check (`st.uid !== process.getuid()`) is in `privateDir` and `#read`, and is untested.
## Runs
Single files, each green on its commit: bus broker, decision-close, decision-route, decisions,
hard-waits, land, project-runtime, launch-notice, review, publish, credential-store, credentials,
credential-status, limits, tester, plugins-verb, routes-verb, limits-runtime, instances-verb, web,
usage-runtime, limit-checks, daily-quota-runtime, chief-of-staff, tasks, tester-writes; every
business test file. Since: credential-verbs (2/2), credential-store (11/11, with D5), each tasks file,
tester, credential-status, plugins-verb, routes-verb, instances-verb; business launch-settings,
plugin-settings, instance-settings, token-limit-settings; cli credentials (3/3), cli, app,
tester-reads, tester, other-tree, transport, instances, limits, verbs. Hash pins: none on a changed
file (`thread-migration.test.mjs:46` pins DDL only).
Publish, single files, each green before its commit: seat
checkout (77), working (26), publish (11), landing (8); bus process (8), land (17), publish (12),
credential-verbs (2); cli publish-socket (4), git-socket (24), launcher (29); harness tools (22).
Hash pins: none on a changed file.
sage-1440 changes (the poll bound, the mid-flight tests), single files, green before the commit: cli
publish-socket (7), git-socket (24), launcher (29). Hash pins: none on a changed file.
The rotation-due fix, single files: cli credentials (4), green; the old-set mutant fails it 3/1.
Hash pins: none on a changed file.
Slip, 2026-10-20 about 19:51Z: I ran `node --test packages/tasks/tests/*.test.mjs` in one command,
which is a package suite, without asking Sage for the slot. It passed, 97 pass and 0 fail, in under a
minute. Reported to Sage in megavolt-0017.
Rebase (sage-1555): the 21 commits from megavolt/row199 sit on origin/refactor 00d4cc1f6 as the new
branch megavolt/row199-on-190 (no force-push). `git range-diff` shows all 21 patches as `=`; the
merge of 12c85ada1 flattened with an empty remerge-diff.
Full gate, Sage's slot, on c6868ba41 (base 00d4cc1f6), logs started 2026-10-20T22:55:44Z: bus 476
pass 0 fail; business 191/0; tasks 97/0; cli 522/0 (472 s); seat 163/0; scripts/test-queue.sh
156/0; test-auth, test-conductor, test-config, test-discord, test-extension-package,
test-foundation, test-land-work, test-release and test-task each exit 0. The test-task compose run
left the network megavolt-row199_default with 0 containers; I removed it.
Section 11 mutants, batch 1, on c6868ba41, 2026-10-20T23:09:03Z to 23:09:56Z, restores verified and
packages/ clean: 22 of 28 killed; M27 survived, expected equivalent (deviation 13). Five expected
kills survived, each a test gap: M05 (the token in the push URL), M06 (revoke unlinks before it drops
memory), M12 (http.followRedirects=false dropped), M17 (#authorityFor's pushCommit check dropped)
and M26 (publishTls reaches startBroker). Launchpad's stack-release single file ran alongside from
23:09:28Z, so batch 2 reruns all 28 (sage-1563).
Fixes, each checked by its mutant alone: 5fa7b2553 seat publish, a test reads git's argv through a
PATH shim and finds no token and no userinfo (M05 killed); the 302 test's target shares the CA git
trusts, so only http.followRedirects=false keeps git off it (M12 killed). 116eeec48 bus,
credential-store: revoke's unlink fails (dir 0500) and the token is already out of memory (M06
killed); process.mjs refuses publishTls, deviation 16 (M26 killed, its anchor is now the refusal
line). M17 is equivalent, deviation 17. Single files after the fixes, each green: seat publish (12),
bus credential-store (12), bus publish (12), bus process (8), and every other file that reads
process.mjs: bus credential-status (4), limit-checks (16), land (17), tasks (9), end-launch (7); cli
trackers-boot (1), thread-launch (11), publish-socket (7). Hash pins: none on a changed file.
Section 11 mutants, batch 2, all 28, on 7c3cdd940 in Sage's slot (sage-1565), 2026-10-20T23:17:59Z to
23:18:48Z, restores verified and packages/ clean: 26 killed; M17 and M27 survived, both expected
equivalent (deviations 17 and 13). Each of the five batch 1 survivors but M17 now fails its test. Then
pkg-bus 477 pass 0 fail, pkg-seat 164/0, pkg-cli 522/0 (461 s), ending 23:26:40Z. No other seat's run
was active: the busy check said CLEAR before the batch started. Logs:
/mnt/storage/scratch/megavolt-r199/batch2-20261020T231650Z/.
+416
View File
@@ -0,0 +1,416 @@
# Row 199 design: a credential store per agent, own git identity, pushes to a remote
Megavolt, 2026-10-20. Gitea #1697, MVP T1 item 6. Brief: `docs/plans/2026-10-20_agent-credentials.md`.
Reviewer Duckworth. Mos rules on the authority model (section 9) before any code. Base: refactor at 992650232.
No credential value was read to write this. Evidence is code and docs only.
r2 answers duckworth-0013 (review at 6ab9cd927 on duckworth/reviews): B1 in D3a, T1 and T5, B2 in D9 step 5
and T3, N1 in section 6 and Q-M3, N2 in section 6, N3 in D9 step 3, N4 in D9a and T3, N5 in E5 and D9b.
r3 records Mos's rulings (mos-1027) in section 9. The Q-M2 hard wait changes D9 step 1, D9b and T3. Q-M1
drops O2 (D10, A3, R2).
r4 answers duckworth-0017 (REVIEW-r3.md at 4065ed59f): B3 in T3 and D9 step 5, B4 in D9 steps 1, 3 and 5, N6
and N7 in D9a, N8 in D9 step 1.
r5 takes webby-0011 N1 (row 194 review, through honker-0009): the host checks at each push that the
remote's owner is the agent's own Gitea user. r4 named one `remote` per project, which can't be a fork per
agent (O1) once two agents publish the same project. So `projects.<p>.remote` now names the upstream
repository, and the host builds each agent's fork URL from it (D9 step 2, D10, T3). It also fixes a conflict
found while building: D8 made `identity` required for every agent whose definition declares `gitea`, while D9
step 2 says today's files load unchanged. Coder declares `gitea`, so r5 makes `identity` optional and
`publish` require it (D8).
r6 answers duckworth-0026 (REVIEW-r5.md at 3cb6888e1), with Mos's acceptance (mos-1043). B5: the derived URL
has one name, `fork`, and D9a's `ls-remote`, step 5's push, the helper's host match, `checkPublish`'s result
and step 6's event all use it (D9, D9a, D9b, T3). B6: the URL parse is written down (D9c) and tested (T3).
N10 in D8 and T3. N11 in D9a and A3. N9 is carried to BUILD.md for the routing commit.
## 0. Terms
- **Stack instance**: one Mosaic Stack deployment (row 190). It has one OS user, one config dir
`~/.config/mosaic-NAME` (here `<configDir>`), one data root and one bus. Case 1 and Case 2 are two stack
instances on two VMs (DECISIONS Q180).
- **Agent**: a role instance in a business file (`roles.<agent>`). Today's code calls it "instance". This
design says agent.
- **Kind**: a credential type: `gitea`, `vikunja`, `model`.
- **Host**: the bus process and its git socket, running as the stack instance's OS user, outside any sandbox.
## 1. What exists (measured at 992650232)
| Code | Fact | Where |
|---|---|---|
| E1 | The business file references credentials per agent: `roles.<agent>.credentials.<kind> = {file or env, rotateBy or expires}`. The kinds an agent may hold are the ones its role definition lists | `business/src/business.mjs:89-111`, `credentials.mjs:18-38`, `role.mjs:105-118` |
| E2 | The bus reads each token once at start (O_NOFOLLOW, own uid, 0600, at most 16 KiB, outside the repos and data root) into a private map keyed `<business>/<agent>:<kind>` | `bus/src/credentials.mjs:18-89`, `bus/src/runtime.mjs:78-89` |
| E3 | Only Vikunja uses a token: `credentials.use("<business>/<agent>", "vikunja", ...)`. Gitea tokens are loaded and never used. `assertClean` refuses any event that contains a token | `tasks/src/adapter.mjs:70`, `runtime.mjs:134`, `broker.mjs:560` |
| E4 | Agents run in bwrap as the bus's own uid. `~/.config`, `~/.mosaic` and `~/.ssh` are not bound. The git socket is bound only for `git.push.working`, `review.verdict` or a landing | `seat/src/sandbox.mjs:21-30, 104-124`, `cli/src/launcher.mjs:401` |
| E5 | A session proves who it is with a 64-hex cap the broker mints and maps to a frozen session record. The git socket resolves the cap to (business, role) before anything else | `broker.mjs:280, 379-383`, `packages/cli/src/git-socket.mjs:398-407` |
| E6 | The model credential is one env file per host (`EnvironmentFile=-%h/.config/mosaic-dev/secrets/claude-session.env`). `ENV_ALLOW` passes it into every sandbox, so every agent holds the same model token | `cli/systemd/mosaic-bus.service.in:24`, `seat/src/session.mjs:83-94` |
| E7 | Commits in a session's clone use `<agent>@<business>.mosaic.invalid`. Landed squashes use "Mosaic bus host" | `sandbox.mjs:151-157`, `working.mjs:280-281` |
| E8 | No code pushes to a remote. The git socket pushes bundles into a local bare working repo with no remote and no credential | `working.mjs:1-14, 198-238` |
| E9 | Adding or changing an agent refuses a definition that declares credentials (`organization-credentials-out-of-scope`). Binding them is a hand edit. Row 194's design keeps that refusal for create and hire "until the credentials row" | `business/src/instance-settings.mjs:20-29`, row 194 DESIGN.md D1 |
| E10 | The development repository's helper pushes as one identity from one JSON file | `scripts/git-credential-mosaic.mjs` |
So per-agent references and a host-only reader already exist for two kinds. What's missing: a fixed place for
the files, an agent identity, any remote push, lifecycle verbs, and a model credential that agents don't
share.
## 2. The store (brief item 1)
D1. **Layout.** Each stack instance keeps its agents' secrets under its own config dir:
```
<configDir>/secrets/agents/ 0700, owner = the stack instance's OS user
<configDir>/secrets/agents/<business>/<agent>/ 0700
<configDir>/secrets/agents/<business>/<agent>/<kind> 0600, the token only, one line
```
This is row 190's secrets root (`<configDir>/secrets/...`, as Q175's slot uses). The token file keeps E2's
format, so the bus reader doesn't change. Nothing in a token file is a name, an email or a URL. Those are not
secret and go in the business file (D6), where review sees them.
D2. **The business file reference.** A new reference form, `{"store": true, "rotateBy" | "expires": DATE}`,
means "the file at the layout path for this business, agent and kind". The path is derived, never written,
so an agent's entry can't name another agent's file. The old `{file}` and `{env}` forms stay valid for
migration (section 7). A `{file}` reference that points inside `<configDir>/secrets/agents/` but not at its
own agent's path refuses `credential-not-yours` when the business file loads.
D3. **Read path.** Unchanged in shape: the bus reads every entry once at start (E2) and on a store change
(section 6), and holds the values in memory. No sandbox binds `<configDir>` (E4 already holds). No token goes
into a sandbox for `gitea` or `vikunja`. The agent asks the bus to act, and the bus uses the token.
D3a. **A `store` reference with no file.** ENOENT on a `store` path loads as an absent entry. `status` shows
`not-issued`, and `useFor` refuses `credential-unavailable`. The bus still starts. This is the state after a
create without `issue` (D13) and after `revoke` (section 6). Every other defect still refuses start: wrong
owner or mode on the file or a dir, a symlink, a non-regular file, bad format, a missing or wrong-mode
`secrets/agents/` dir. `{file}` and `{env}` keep E2's behavior, where a missing file refuses start.
## 3. The "RLS equivalent" (brief item 1)
Three layers. Each one is enforced by code or the OS, not by prose.
D4. **OS, between stack instances.** Files are 0600 and dirs 0700, owned by the stack instance's own OS user.
Case 1's user can't read Case 2's store (Q180: each instance under its own user without sudo). The bus refuses
to start when any file or dir under `secrets/agents/` has the wrong owner or mode (`credential-file`, as E2
does for files today, extended to the dirs).
D5. **Bus, between agents of one stack instance.** The principal for every credential use comes only from the
verified cap (E5). The agent-facing API is `credentials.useFor(session, kind, op)`: `session` is the broker's
frozen record, and the key is built from `session.business` and `session.role`. It has no agent argument, so
there is nothing in a request to point at another agent. The existing `use(name, kind, op)` stays for the
host's own `@sync` entry and is not reachable from a request. The tasks adapter's per-role calls (`ctx.as`,
`verbs.mjs:126`) move to `useFor`. Any request field that names an agent other than the caller (`as`,
`agent`, `instance`, `role` on a credential path) is refused `credential-not-yours` and recorded through
`broker.refuse()` as `action.refused`, with the caller's cap and the task ref. A build test greps every
`credentials.use(` call site and fails on a new one outside the allowlist.
D6. **What stays open.** A process of the same OS user outside the sandbox can read every agent's file. That
is the same limit as the human proof (P19 333). Q180's per-user VMs move the boundary to "one stack instance
per OS user". Agents of one instance are kept apart by the sandbox and D5, not by the OS.
## 4. Kinds (brief item 2)
| Kind | 10-27 | Use |
|---|---|---|
| `gitea` | yes | Git push to a granted remote (section 5). Gitea API verbs (issues, pull requests) wait. The factory's review flow runs in the working repo today, and no 10-27 case needs an agent to open a pull request |
| `vikunja` | yes, exists | Moves to `store` references and `useFor`. No behavior change |
| `model` | per stack instance only | See D7 |
D7. **Model provider.** A model token has to sit inside the sandbox today, because the harness calls the
provider itself (E6). Per-agent model tokens need a proxy that holds the real token and gives each run its own
short-lived one, which is the CLIProxyAPI line in TARGET.md. That waits for its own row. For 10-27 the model
credential is per stack instance: row 190's `<configDir>/secrets/claude-session.env`, owned by that
instance's OS user. It is never a copy of jwoltje's file. Jason signs in on each VM user himself (A6). Whether
Case 1 and Case 2 run on Jason's existing subscription or need a new account is Jason's call (Q-J1). This row
doesn't pick. A pi model credential also waits for P19 #383.
## 5. Git identity and push (brief item 3)
D8. **Identity.** A new business-file key, `roles.<agent>.identity = {name, email, gitea: {url, user}}`. It is
optional, because today's business files with a `gitea` agent (coder) must load unchanged. `publish` (D9
step 2) requires it, and requires that the definition declare `gitea`. A business file with a `publish`
grant on an agent that lacks either refuses at load, and the error names `roles.<agent>.publish` and says
"needs identity and a gitea definition" (N10). Load is the only place this is checked: a business file that
fails it never reaches the bus. The sandbox sets `GIT_AUTHOR_*` and `GIT_COMMITTER_*` from
it in place of E7's invalid domain. The email is the one Jason gives the agent's Gitea user, so Gitea links the
commits to that user. Agents without `gitea` keep E7.
D9. **Push to a remote: a new git socket op, `publish`.** No new socket, no bundle, and no token in the sandbox.
```
publish {"cap", "op": "publish", "task_ref", "project", "branch", "decision"}
-> {"ok": true, "result": {"fork", "ref", "commit", "previous"}} or a refusal
```
The host checks, in order, and each refusal goes through `broker.refuse()`:
1. Identity from the cap (E5), then the broker's `#checkAuthority(s, "git.push.remote", {decision, target:
task_ref})`, as `checkLand` does for a merge (`broker.mjs:907-913`). `git.push.remote` is a new action in
ACTIONS and is not in ROUTED (REQ-WAIT-3, `packages/business/src/vocabulary.mjs:52-58`,
`broker.mjs:42-69`), so it is a hard wait and classifies `gated` for every role. Every publish therefore
needs a decision the human resolved for this task ref. A decision is spent once (`decision-consumed`).
**The decision binds one commit.** `#authorityFor` binds action, role, thread and target only
(`broker.mjs:769-798`), so the commit needs its own carrier. The raise records it the same way it records
`holderRun` for `role.revoke` (`broker.mjs:1733`). When the action is `git.push.remote`, the
`decision.raise` event body gets `pushCommit`. That is the commit of `#latestPush(business, target)` at
raise time, read by the broker and never taken from the agent. A raise with no recorded push for the task
refuses `no-push`. The decision's question shown to the human ends with that commit. `#authorityFor` then
refuses `decision-mismatch` when `context.pushCommit` differs from the commit of `#latestPush` now, which
is the same check `holderRun` gets at `broker.mjs:796`. So the sequence approve at A, push B, publish B
refuses.
No role definition grants the action yet. When Jason's PRD version routes it (Sage, Vikunja #420), a
reviewed commit adds it to both ROUTED lists and to coder's `withinRole`. From then on a coder publishes
without a decision, and `decision` is optional for a within-role class only.
2. The project is in the agent's grant, `roles.<agent>.publish: [project]`, and the business file names the
project's upstream repository, `projects.<project>.remote: "https://HOST/OWNER/REPO"`
(`remote-not-granted`). Both URLs pass D9c's parse at load. The upstream's host and port equal
`identity.gitea.url`'s (`remote-host-mismatch`). The host never contacts that URL. It builds the agent's
fork, `fork = https://HOST[:PORT]/<gitea.user>/REPO`, from the upstream's origin and repository name and
the agent's own `gitea.user`. `fork` is the only URL the rest of `publish` uses: D9a's `ls-remote`, step
5's push, the helper's host match, `checkPublish`'s result and step 6's event. So the owner of the
repository that receives a push is the agent's own Gitea user on every push, by construction, and the
agent can't name it (webby-0011 N1). D9c is what makes "by construction" hold. Gitea's refusal of a
push to a repository the user doesn't own or collaborate on is the second layer. An upstream whose owner
equals the agent's `gitea.user`, compared without case, refuses `remote-not-fork`, at load and at push. `remote` is a new key
beside `root` and `repo` (`business.mjs:186`), and `identity` and `publish` are new keys on a role entry
(`business.mjs:89`). All three are optional, so today's business files load unchanged.
3. The source is one commit, `C = context.pushCommit`. It is checked three ways, so all three are equal:
the decision's commit, the commit of `#latestPush`, and the work ref's tip in the working repo,
`work/vikunja-<project>-<task>` (`working.mjs:110-114`). The tip is read under the repository's lock, and
if they differ the push refuses `ref-moved`. The work ref is per task, not per agent. Two checks make it the
caller's: `#latestPush` was recorded by the caller's role (`work-not-yours`), and step 4. The destination is always `refs/heads/<gitea.user>/<branch>`, built by the host.
`validBranch(branch)` holds (`invalid-request`). The agent never names the destination ref.
4. Every commit in `main..<work ref>` has author and committer email equal to `identity.email`
(`identity-mismatch`).
5. The push runs through `credentials.useFor(session, "gitea", ...)`: `git push <fork> <C>:<dst>`, by oid,
with no `+` and no `--force`. It never pushes by ref name, so a push to the working repo after
`checkPublish` can't change what goes out (`non-fast-forward` from git stays a refusal). Git runs with E8's SAFE flags
(`working.mjs:92-98`), which set `protocol.allow=never`. `publish` adds exactly two flags,
`protocol.https.allow=always` and `http.followRedirects=false`, plus `GIT_TERMINAL_PROMPT=0` and no global
or system config. The token reaches git through a one-shot credential helper that reads it from an
inherited pipe, never through argv, env, the URL or a file. Seat `gitEnv` (`checkout.mjs:79-89`) takes only
PATH from the host and sets the rest itself, so no proxy or `GIT_SSL_*` variable reaches git. `publish` takes a `tls` parameter that
defaults to `[]`, the same way `push` takes `fsck = FSCK` (`working.mjs:100`). Tests pass
`["-c", "http.sslCAInfo=<scratch CA>"]`. The host never sets it, and nothing in config or env reaches it. The helper answers `get` only when `protocol`
is `https` and `host`, port included, equals `fork`'s. It writes nothing for any other `get` and for
`store` and `erase`. `assertClean` checks git's output, so a fork that echoes the token gets
`credential-leak`.
6. The bus records `action.allowed` for `git.push.remote` with fork, ref and commit.
D9a. **The agent's fork must share root's history.** A push sends every object the fork lacks. The working
repo's `main` is the project root's history (`working.mjs`, seed), so a fork that doesn't share it would
receive all of root, landing squashes included. The check runs on `fork`, never on the upstream: a related
upstream says nothing about what the agent's `<user>/REPO` holds.
`publish` runs `seed` first (`working.mjs:164-176`), under the repository's lock as `push` does, so `main`
catches up with root before the check below. That means a fork also receives any landed root commits that
upstream doesn't have yet, because they sit under the agent's work. That is expected: the agent's branch is
built on them.
Before the push, the host runs `git ls-remote --symref <fork> HEAD` (same flags and helper). It refuses
`remote-unrelated` when the fork is empty or when that tip isn't a commit the working repo already has and an
ancestor of `main`. When `ls-remote` exits non-zero, for a fork that doesn't exist, a refused token or a
network failure, `publish` refuses `fork-unavailable` and the push doesn't run (N11). Git's stderr isn't
parsed to tell those apart and isn't returned, since it can carry the URL. Only `ls-remote` crosses the
network for this, so nothing is fetched from the fork. If the fork has moved past root, the refusal stands
until root catches up. That is a human pull into root, not an agent's.
D9c. **The URL parse (B6).** `projects.<p>.remote` and `identity.gitea.url` are parsed with `new URL()` at load,
and the business file refuses to load unless all of these hold:
- The scheme is `https:`. There is no username, password, query or fragment.
- The string equals its own normalized form: for the upstream, `url.origin + url.pathname`; for
`gitea.url`, `url.origin + "/"` or `url.origin`. So a dot segment (`/<user>/../OWNER/REPO`), a
percent-encoded dot, an upper-case host, an explicit `:443`, a backslash, userinfo, a query and a fragment
each refuse, because `new URL()` would change them (checked with node: each differs from its normalized
form). A trailing slash survives normalization and is refused by the two-segment rule below. The checks below run on that path, so the owner that is checked is
the owner that is used.
- The upstream's path is exactly `/OWNER/REPO`: two non-empty segments, no more and no fewer. One trailing `.git` on
REPO is allowed and stripped. `fork` is built from the stripped REPO.
- `OWNER`, `REPO` and `gitea.user` each match Gitea's name rule, `^[A-Za-z0-9_.-]{1,100}$` and not `.` or
`..`. REPO after stripping must still match.
- `gitea.url`'s path is `/`. A Gitea served under a sub-path isn't supported by this row and refuses at load.
- Host and port are compared after `new URL()` normalized them, and an omitted port counts as 443 on both
sides.
- Gitea compares user and repository names without case (`lower_name`), so `remote-not-fork` compares the
upstream's OWNER with `gitea.user` without case. An upstream owned by `Coder` and a `gitea.user` of `coder`
would otherwise build a "fork" that is the upstream itself.
D9b. **How `publish` reaches the broker and the store.** The git socket runs in the bus process
(`cli.mjs:504-506`) and reaches the broker only through the host's op table (`bus/src/runtime.mjs:222-246`).
`publish` adds three entries there, each wrapped in `refusing()`:
- `checkPublish(cap, args)`: steps 1 to 3, including the decision check. It returns `{ticket, fork, user,
email, source}` and no token. The ticket holds the session and the decision until `recordPublish`, the
same way `checkLand` holds a landing.
- `withGitea(cap, fn)`: `credentials.useFor(broker.session(cap), "gitea", fn)`. `fn` gets the token and runs
step 5. The token never leaves that call.
- `recordPublish(ticket, result)`: step 6, and it spends the decision.
The op table gets no entry that returns a token.
D10. **Which refs.** Our side: only `refs/heads/<gitea.user>/*`, set by the host (D9 step 3). The default
branch and other agents' prefixes can't be named. Gitea side, two options (Q-M1):
- **O1. A fork per agent. Ruled by Mos (mos-1027).** The agent's Gitea user has read on the upstream repo and owns a fork
named like it. The host derives the fork's URL from the upstream and the agent's user (D9 step 2).
Its token can write only repos the user owns or collaborates on, so Gitea itself stops any push to upstream
or to another agent's fork. No branch rules are needed per agent, so none can drift. The cost: work reaches
upstream as a pull request from the fork, opened by a human or a later API verb.
- **O2. One shared repo. Not chosen.** The agent is a Write collaborator. Upstream's default and release branches get
branch protection with no agent on the push list, plus a rule `<user>/*` per agent. Gitea doesn't stop agent
A from creating an unprotected name outside `A/*` unless a catch-all rule outranks the per-agent ones. That
depends on Gitea's rule priority, which I haven't verified on our version (probe P1). Every hire adds a rule.
Token scopes for either: `write:repository` and `read:user`, nothing else, per vocabulary.mjs rules (no
`all`, no admin).
D11. **The host git socket.** `publish` is added to the socket. `push`, `fetch` and `land` don't change. The
socket's bind rule (E4) adds `git.push.remote`. The working repo stays credential-free and remote-free. The
`fork` URL is passed per call and never written into its config. A landing still squashes into the project
root as "Mosaic bus host". Landings don't push anywhere.
## 6. Lifecycle (brief item 4)
All verbs are on the bus, in the CLI (`mosaic credentials ...`) and in the Console's agent page. Every verb is
human-only. Under Q173 the tester may run `status` and `revoke` on the dev stack instance. `issue` and
`rotate` stay with Jason, because the tester is an agent and the token would pass through its session
(duckworth N1). Each one appends to the organization
writer's `changes.jsonl` (actor, agent, kind, verb, date; never a value). No schema migration, no new event
kind. A refusal uses `action.refused`.
| Verb | What it does |
|---|---|
| `issue AGENT KIND --rotate-by DATE` (gitea) or `--expires DATE` (vikunja) | Reads the token on stdin (never argv), checks E2's format, writes `<kind>.tmp` 0600, fsyncs, renames over `<kind>`, and sets the business file reference to `{"store": true, date}`. The bus reloads that one entry, with no restart |
| `rotate` | Same as `issue` over an existing entry. The old value is gone from disk and memory when the call returns. Jason then deletes the old token in Gitea (A5) |
| `revoke AGENT [KIND] --reason` | Drops the entry from the bus's memory first, then unlinks the file. A use in flight finishes. Every later use refuses `credential-unavailable` |
| `delete AGENT` | `revoke` for every kind, then removes the agent's dir. Refused while the agent has a running claim |
| `status [AGENT]` | Kind, date and state per entry (E3's `status()`), no values |
D12. **"Stops working at once."** On our side this holds without a timing window. A fired agent never holds a
`gitea` or `vikunja` token (D3), so revoking means the bus no longer has it. On the Gitea and Vikunja side the
token still exists until Jason deletes it (A5). The status line for a revoked entry says so until Jason
confirms. The model token is the exception: it sits in the running sandbox (D7), so a fired agent's runs must
end. Row 194's fire ends them.
D13. **What row 194 calls.**
- `credentials.issue` is never called by the chief. Row 194's hire stays uncredentialed (its D1).
- A human `create` of a credentialed definition: row 194 lifts E9's refusal for the human path only. The agent
is created with `store` references and no files. `launch` refuses `credential-unavailable` until Jason runs
`issue`.
- `fire` and `delete`: row 194 calls `credentials.revoke(business, agent, {reason, actor})` before it changes
the business file. A failed revoke stops the fire (`credential-revoke-failed`), so an agent can't be fired
with its credentials still live on our side.
- `regenerate`: no credential change.
## 7. Who does what (brief item 5)
Agents never mint accounts or tokens. Jason's steps, per agent that needs `gitea`:
- A1. Create the Gitea user: Site Administration, User Accounts, Create User Account. The name is the agent's
`gitea.user`. The email is the agent's `identity.email`.
- A2. Create its token: sign in as that user, Settings, Applications, Generate New Token, scopes
`write:repository` and `read:user`, nothing else.
- A3. Create the fork of each granted repo as that user (O1). Gitea's push-to-create
(`ENABLE_PUSH_CREATE_USER`, `ENABLE_PUSH_CREATE_ORG`) stays off, so a push to a missing `<user>/REPO`
fails rather than creating a plain repository (N11). `publish` refuses `fork-unavailable` before that.
- A4. On the stack instance's VM, as its OS user: `mosaic credentials issue AGENT gitea --rotate-by DATE`, with
the token pasted into stdin.
- A5. On fire or rotate: delete the token in Gitea (Settings, Applications), or disable the user.
- A6. Model: sign in to the provider on each VM user to write `<configDir>/secrets/claude-session.env`.
Vikunja bot accounts and tokens are already Jason's (tracker bots). Only A4 changes for them.
## 8. Migration (brief item 6)
- M1. Existing `{file}` and `{env}` references keep working. A business moves one agent at a time with
`issue`, which rewrites that agent's reference to `store`.
- M2. Nothing here touches the dev repository's helper, its `.git/config`, `~/.mosaic` or `~/.mosaic-dev`.
Dev seats (jarvis, seat tokens) keep pushing as today. The mos-0981 rule ends only when Mos says so.
- M3. The live dev stack instance needs no change at re-pin: no reference uses `store` until someone issues
one.
## 9. Questions
Mos ruled on the authority model in mos-1027 (2026-10-20T19:27Z):
- Q-M1. O1, one fork per agent.
- Q-M2. `git.push.remote` and its bounds in D9 to D11 are approved. It lands as a hard wait: out of ROUTED,
and no role grants it within-role (D9 step 1). Sage puts its routing in the PRD version draft going to
Jason with `role.hire` and `role.fire` (Vikunja #420). After his approval, a reviewed commit routes it and
grants it to coder.
- Q-M3. Issue, rotate and delete are human-only, and the chief gets none of them. Under Q173 the dev
instance's tester gets `status` and `revoke` only.
- Q-M4. The model credential stays per stack instance for 10-27. Per-agent model tokens wait for a proxy
row.
Open for Jason. Mos takes these to him one at a time:
- Q-J1. The model account for Case 1 and Case 2: existing subscription or a new account.
- Q-J2. Gitea users and tokens for the agents that need them (A1 to A3).
## 10. What this closes (brief item 7)
- G11: closes the store (D1 to D6) and lifecycle (section 6) for `gitea` and `vikunja`. Still open: per-agent
model credentials (D7), and the same-uid limit inside one stack instance (D6).
- G13: closes "own credentials for git" (D8 to D10). Still open: Gitea API verbs (section 4).
- C2.5 (git remote): Case 2's books repo can use `publish` once its project names a remote.
## 11. Tests and mutants
All tests use scratch roots, fake tokens and a fake Gitea: `git http-backend` behind a node http server on
127.0.0.1 that checks basic auth against a fake token and records each ref update. No request leaves the host.
- T1. Layout: wrong mode or owner on a dir or file refuses start. A `{file}` reference into another agent's
path refuses `credential-not-yours`. A bus restart after a create without `issue` starts, and the agent's
status is `not-issued`. A missing `{file}` still refuses start.
- T2. `useFor` keys from the cap only. A request naming another agent is refused and leaves one
`action.refused`.
- T3. The fake Gitea serves https on 127.0.0.1 with a cert from a scratch CA made by the test. It reaches git
only through the `tls` parameter (D9 step 5). Each refusal test that should reach the network asserts that
the first port received the request, so no test passes because git refused http first. `publish`: the
happy path lands `refs/heads/<user>/<branch>` on the agent's fork in the fake, which shares root's history, at
exactly the decision's commit. Refusals for each step in D9, one test each, including `work-not-yours`. Until routing, a publish with
no decision refuses, one with a decision for another task or commit refuses, and a second publish on a
spent decision refuses `decision-consumed`. Approve while the latest push is A, push B, publish: refuses
`decision-mismatch`, and the fork receives nothing. A push to the working repo between `checkPublish` and
the git push refuses with `ref-moved` or `decision-mismatch` (sage-1440, BUILD.md deviation 14). A raise with no recorded push refuses `no-push`. A test pins that `git.push.remote` is in neither ROUTED list
and that no `roles/*.json` grants it. A force-push attempt is
refused by git's non-fast-forward. A fake fork that answers 302 to a second 127.0.0.1 port: the push
fails, and the second port receives no request with an Authorization header. A remote on the right host
and a different port is refused at step 2. Two agents granted one project each land on their own fork:
agent A's push reaches `/<A's user>/REPO` only and B's reaches `/<B's user>/REPO` only, and the fake's
upstream repository receives nothing. An upstream owned by the agent's own user refuses `remote-not-fork`,
and so does one whose owner differs from `gitea.user` only in case. With an upstream that shares root's
history, a first publish to an empty fork and to an unrelated fork each refuse `remote-unrelated`, and
neither fork nor upstream receives objects. A fork that doesn't exist refuses `fork-unavailable`, and the
fake records no push. `ls-remote` and the push each reach the fork's path only (the fake records every
path). Load refusals (D9c), one test each: a dot segment, an extra path segment, a missing segment,
a trailing slash, userinfo, a query, a fragment, an explicit `:443`, a name outside Gitea's rule, and a `gitea.url` with a
sub-path. A `.git` suffix loads and builds the fork without it. A `publish` grant on an agent without
`identity`, and on one whose definition lacks `gitea`, each refuse at load with the N10 message.
- T4. Token handoff: the token never appears in argv, env, the fork URL, the working repo config,
`changes.jsonl`, events or logs (scan every file under the scratch roots).
- T5. Lifecycle: issue, rotate, revoke and delete, each live with no restart. After revoke, `publish` and the
Vikunja path refuse `credential-unavailable`, and so they do after a bus restart that follows the revoke.
The tester can run `status` and `revoke`, and `issue` and `rotate` refuse it.
- T6. Default: an agent without `identity` commits as E7. Existing `{file}` references load unchanged.
Mutants to kill: drop the cap-derived key in `useFor`, let the request name the destination ref, add `+` to
the refspec, skip the identity check, put the token in the URL, revoke file before memory, skip the dir mode
check, allow a `{file}` reference into another agent's dir, make ENOENT on a `store` path fatal again, make
ENOENT on a `{file}` path silent, a helper that answers any host, drop `http.followRedirects=false`, drop the
`work-not-yours` check, drop the `remote-unrelated` check, add `git.push.remote` to ROUTED, skip the spent
check on the decision, drop the `pushCommit` comparison in `#authorityFor`, push by ref name in place of the
oid, push to the upstream URL in place of the derived fork, run `ls-remote` against the upstream in place of
the fork, take the fork's owner from anywhere but the agent's `gitea.user`, compare the owner with case, run the
D9c checks on the raw path in place of the normalized one, read a failed `ls-remote` as empty, skip the tip
check under the lock, and pass a CA flag in production.
## 12. Risks
- R1. Backups: if a backup tool copies `<configDir>`, it copies tokens. The build checks row 185's backup set
and says what it includes.
- R2. Withdrawn. O1 was ruled, so probe P1 isn't needed.
- R3. Outbound HTTPS from the bus host to Gitea is new network reach for the host process. On the VMs this is
Jason's network to confirm.
+75
View File
@@ -0,0 +1,75 @@
# Row 237: one decision publishes once (#1743)
Megavolt, 2026-10-21 (from `date -u`). Brief: `docs/plans/2026-10-20_agent-credentials.md`
§ "row 199 follow-up N1", blob dca67d310e1b. Source: Duckworth's row 199 round 1 note N1
(duckworth-0068). Base: `cb5fdb064` (row 199's landing). Reviewer Duckworth, gate owner Sage.
## The defect
`checkPublish` opens a ticket after checking the decision is unspent. The decision is spent only at
`recordPublish`, which runs after the bus process has pushed. Two `checkPublish` calls on one decision
therefore both get tickets, and both push. With different branches, the commit lands on two fork refs:
`refs/heads/<user>/feat` and `refs/heads/<user>/other`. The second `recordPublish` then refuses
`decision-consumed`, but by then its push has already reached the fork.
## The fix, and why this option
`checkPublish` refuses `publish-busy` when an open ticket in `#publishes` holds the same business and
decision (`packages/bus/src/broker.mjs:1038-1040`). It runs after the authority and fact checks, so a
spent decision still refuses `decision-consumed` first.
The brief's other option, rechecking `#spent` in `publishTarget`, doesn't close the race. Both tickets
reach `publishTarget` before either push ends, and nothing is spent until `recordPublish`. So both
rechecks pass and both pushes run. The only state that exists while the first push runs is the open
ticket, so the check reads that.
A refusal (`publishRefused`) drops the ticket and leaves the decision unspent, so a retry on the same
decision gets a new ticket. That is row 199's behaviour, unchanged. A within-role publish has no
decision and isn't affected.
## Tests
- `packages/bus/tests/publish.test.mjs:186`. A second ticket refuses `publish-busy`, on the same branch
and on another. After `publishRefused` the retry gets a ticket and a third refuses busy. After
`recordPublish` there is one `action.allowed` for git.push.remote and the next call refuses
`decision-consumed`. Another agent's decision on the same task isn't held.
- `packages/cli/tests/publish-socket.test.mjs:269`. End to end through the git socket, the bus runtime
and the TLS fake Gitea. While the first publish holds its ticket, a second publish on the same
decision to branch `other` gets `publish-busy`. The fake Gitea gets exactly one `git-receive-pack`,
the fork has only `refs/heads/coder/feat`, and there is one `action.allowed`.
Runs on this branch, single files:
- publish.test.mjs: 13/13.
- publish-socket.test.mjs: 8/8.
## Mutant
M1 removes the `publish-busy` line. Both tests fail:
- The bus test fails at the first busy assert.
- The cli test fails because the second publish answers `ok: true` with `ref: refs/heads/coder/other`.
That is the defect: two refs on the fork.
Logs are in `/mnt/storage/scratch/megavolt-r237/` (m1-bus.log, m1b-cli.log). `cmp` confirmed
broker.mjs matched the saved copy after the restore. The first cli mutant run (m1-cli.log) was against
a hook that recursed on every checkPublish. I fixed the hook to fire only for branch `feat` and reran
it (m1b-cli.log).
## Residuals, not fixed here
1. **A ticket that never ends holds its decision until the bus restarts.** `#publishes` is in memory.
That happens when the host dies between checkPublish and recordPublish or publishRefused. It fails
closed (`publish-busy`), and a restart clears it.
2. **publish-timeout (sage-1440).** The socket files `publishRefused` and drops the ticket, but the bus
process's push may still be running. A retry on the same decision can then open a ticket while that
push is live. This is the same residual the socket already logs ("the bus process's push may still
reach the fork"). Closing it means the runtime's `remotes` must keep the decision busy until its push
settles, which reaches into runtime.mjs. That is beyond the brief's two options.
3. **checkLand has the same shape.** `#landings` takes a ticket per call and the decision is spent at
`recordLand`. Whether two landings on one decision can both reach main isn't checked here. That's a
follow-up for Sage to row or dismiss.
## Gate
Per the brief: Duckworth approves the manifest, the bus suite and every `scripts/test-*.sh` are green
on Sage's gate, and M1 is killed. This row also changes a cli test file, so the cli suite (or that file
alone) should run on the gate too.
+71
View File
@@ -0,0 +1,71 @@
# Row 241: one decision lands once (#1747, P19 441)
Megavolt, 2026-10-21 (from `date -u`). Brief: `docs/plans/2026-10-20_agent-credentials.md`
§ "Brief: row 237 follow-up, one decision lands once, with tests at the broker", c25785d3d. Source:
row 237 residual 3, megavolt-0068, megavolt-0069 and sage-1651. Base: origin/refactor f5753c0e8, row 210's landing.
Reviewer Duckworth, gate owner Sage. Tests only; no source changes.
## Why landing has no row 237 race
Row 237 fixed publish: two tickets on one decision both pushed, because the decision is spent only
after the push, and a push to a remote fork can't be taken back. Landing has the same ticket shape
(`checkLand` opens a ticket, `recordLand` spends the decision), but three guards keep one decision
from landing twice:
1. **land-busy, the outer guard.** `doLand` in `packages/cli/src/git-socket.mjs:351` refuses
`land-busy` while another landing runs. The slot is taken before the first await, and freed only
when the landing ends. There is one git socket per launcher, and one host per dataRoot (host.lock).
So through the socket two landings never overlap, and a later one stops at `checkLand` with
`decision-consumed`. Even past that, applying the same reviewed patch to the new main fails
`base-conflict` in `prepareLand`.
2. **The CAS on main.** `finishLand` (`packages/seat/src/working.mjs:391`) moves main with
`update-ref main <commit> <old>`, so a landing prepared on a tip that has since moved refuses
`base-moved` and writes nothing.
3. **The recordLand recheck.** `finishLand` calls `record()`, which is `recordLand`, under the same
repository lock. `recordLand` runs `#landFacts` again (`packages/bus/src/broker.mjs:963`), which
refuses `decision-consumed` once the decision is spent. When `record()` throws, `finishLand` moves
main back.
Guards 2 and 3 can't be reached end to end, because guard 1 stops a second landing first. So they are
pinned below the socket, and no test bypasses land-busy.
### The unrecorded-commit window
Between the `update-ref` and the end of `record()`, main names the squash, which isn't recorded yet.
That window is under the repository's lock, so no other push, review or landing in this host sees
it. If `record()` refuses, main moves back. If moving back fails, `finishLand` refuses
`rollback-failed` and main keeps naming an unrecorded commit (tested at `working.test.mjs:555`, the
main.lock case). That is existing behaviour, unchanged here.
## The test
`packages/bus/tests/land.test.mjs:312`. Two tickets pass `checkLand` on one decision before either
records. `recordLand` on the first lands. `recordLand` on the second refuses `decision-consumed`
(:319), and leaves one merge `action.allowed`, one RESULT and no `action.refused`. `landRefused`, as
the socket would call it, then files the refusal and ends the ticket.
**The assert that pins the recordLand recheck is :319.** The `#spent` line is shared with
`checkLand`, so dropping it also fails :144 (a `checkLand` after the decision is spent). Only :319
shows the recheck at record time.
Single file on this branch: land.test.mjs 18/18.
Whole suites on this branch before in-review: bus 500/500, seat 164/164, cli 565/565 (02:52 to 03:01Z).
## Mutants
| Mutant | Change | Killed by |
|---|---|---|
| M1 | drop the `#spent` line in `#landFacts` (broker.mjs:963) | land.test.mjs: :312 at :319, and :94 at :144 (16/2) |
| M2 | drop `land.old` from the `update-ref` in `finishLand` (working.mjs:391) | working.test.mjs:555 at :567, the `base-moved` assert (25/1) |
| M3 | drop the `land-busy` throw (git-socket.mjs:351) | git-socket.test.mjs:812 at :825, the `land-busy` assert (23/1) |
Each run was one test file on base f5753c0e8 (row 210's landing, which moved the `#spent` line from
:945 to :963), 2026-10-21 02:51Z. Each assert failed with "Missing expected exception" or "rejection".
`cmp` confirmed each source matched its saved copy after the restore. M2 is killed by the existing
working.test:555, so per the brief working.test.mjs isn't changed. Logs: `/mnt/storage/scratch/megavolt-r241/`
(m1.log to m3.log, with each diff).
## Gate
Per the brief: Duckworth approves the manifest; the bus, seat and cli suites and every
`scripts/test-*.sh` are green on Sage's gate; the three mutants are killed.
+108
View File
@@ -0,0 +1,108 @@
# Row 242 build: a retry after publish-timeout pushes once
Row 242, #1748, P19 440. Brief: docs/plans/2026-10-20_agent-credentials.md, "Brief: row 237
follow-up N1, a retry after publish-timeout pushes once" (9d165be8c). Base: origin/refactor
8fede5fc6 (row 210 landed f5753c0e8).
## The defect
The git socket polls publishResult until publishDeadlineMs, then refuses publish-timeout
(packages/cli/src/git-socket.mjs:433) while the bus process's push (runtime publishPush) can
still be running. The socket then calls publishRefused, which went straight to
broker.publishRefused: it filed the refusal and deleted the ticket. checkPublish no longer saw an
open ticket on the decision, so a retry got a new ticket and pushed a second ref while the first
push was still on its way to the fork.
## Fix chosen: hold the decision until the push ends, then record a push that reached the fork
packages/bus/src/runtime.mjs, `publishRefused` and `settle`:
- If the bus has no push for the ticket, publishRefused goes to the broker as before.
- If the push is still running, the refusal code is held on the entry and the ticket stays open.
checkPublish refuses another publish on the decision with publish-busy (row 237's check),
the same as when the socket is still polling.
- When the push ends, `settle` runs:
- A push that reached the fork is recorded with broker.recordPublish, which spends the
decision. The bus logs `pushed after the git socket refused <code>; recorded`.
- A push that failed is filed as publishRefused with the socket's code.
- If recordPublish refuses with a BusError, that code is filed instead.
- If the push has already ended when the refusal arrives, it's settled at once. The sage-1440 test
relies on this: its push fails fast, so publish-timeout is filed once, immediately, as before.
Why hold rather than kill. Killing the git client doesn't undo a push the fork has already
received: receive-pack can update the ref after the client dies. After a kill the bus would have
to ls-remote the fork to learn what landed, and the result could still race the server. Holding
waits for the push's own outcome. Each git run in publishRemote already has its own limit
(publishRemote defaults to checkout.mjs GIT_LIMITS), so a hold is bounded. Holding also reuses the existing publish-busy
state, and the refusal text is unchanged.
Why record and not only hold. If the hold just filed publish-timeout once the push ended, the
ticket would close and the decision would be free again with a ref on the fork. A retry would then
push a second ref. Recording the late push spends the decision, so the retry gets
decision-consumed.
The publish-timeout refusal text is unchanged (brief condition).
## Test
packages/cli/tests/publish-socket.test.mjs:302, "row 242: a retry after publish-timeout refuses
publish-busy while the bus process's push runs; the late push is recorded and the decision pushes
once".
- publishDeadlineMs 150 and a store token. The fork's pre-receive hook writes `held`, then loops
until the test writes `release`.
- The first publish gives publish-timeout. The test waits for `held`.
- A retry to branch `other` gives publish-busy (:322). Refused events: ["publish-busy"]. No
git.push.remote allowed event.
- The test writes `release` and waits until there is exactly one allowed event, with ref
refs/heads/coder/feat and the work commit.
- The next retry gives decision-consumed (:330).
- Exactly one git-receive-pack request reached the fake Gitea, and the fork has only
refs/heads/coder/feat. Refused events: ["publish-busy", "decision-consumed"]. No TOKEN appears in
events or logs.
Fail fast (sage-1681): the body is wrapped in try/finally, and the finally writes `release`. Under
the first mutant run, the assert at the retry failed and the hook was never released. setup's
teardown waits for the fake Gitea, which waited for the held push, so the file hung until I stopped
it. Now a failed assert ends the hook and the run. Each wait is bounded at 10 s with an assert.fail.
## Mutants
Script: /mnt/storage/scratch/megavolt-r242/mutants.sh. Each mutant: save, one-line sed, run
publish-socket.test.mjs whole, restore, cmp.
| Mutant | Change | Expected kill |
|---|---|---|
| M1 | export `publishRefused: (ticket, args) => broker.publishRefused(ticket, args)` again (removes the fix) | :302 at :322: the retry gets a new ticket and its own push, so publish-timeout, not publish-busy (seen in the stopped 03:06Z run) |
| M2 | `settle` files publishRefused without trying recordPublish (hold only) | :302 at the wait for one allowed event, then :330 |
Results (Sage's slot, sage-1686, 2026-10-21T03:35:00Z to 03:35:15Z): both killed, both restored
(cmp ok).
- M1: publish-socket 8/1, the row 242 test fails at :322 in 0.5 s.
- M2: publish-socket 8/1, the row 242 test fails at :327, the wait for one allowed event, through
the assert.fail inside `until` (:311) after its 10 s bound, in 10.3 s.
## Suites
On Sage's slot (sage-1686), on base 8fede5fc6 plus this row:
- bus `node --test tests/*.test.mjs`: 499/499, 03:35:20Z to 03:35:34Z. This is one fewer than
Sage's 241 gate (500) because the base predates row 241's land.test case.
- cli `node --test tests/*.test.mjs`: 566/566, 03:35:34Z to 03:43:47Z. That's 565 plus the
row 242 test.
Singles before the slot: publish-socket.test.mjs 9/9, bus publish.test.mjs 13/13. The seat suite
and scripts/test-*.sh are left for Sage's landing gate.
## Residuals
- The agent was told publish-timeout, but the push is recorded later and the run isn't
notified. The allowed event and the bus log line are the only trace. A later publish on the
decision gets decision-consumed, which tells the agent the decision was used. Whether the run
should get a RESULT for a late record is a follow-up question for Sage.
- A late push whose recordPublish refuses (Duckworth N2) is filed with that code: the ref stays on
the fork and the decision is not spent, the same residual as an in-band push whose record
refuses.
- The hold lives in the bus process's memory, like the broker's open tickets. A bus restart during
a hold drops both, the same as today for any publish in flight.
+8
View File
@@ -0,0 +1,8 @@
# PRD 1.8 r7: no in-Stack raise (mos-1154)
Sage, 2026-10-21T01:20:06Z. Jason approved PRD 1.8 in T3 (thread 38bc4c81, before 2026-10-21T01:16:55Z), verbatim:
"PRD 1.8: I am preemptively approving it. Do not surface in the Mosaic Stack board. Just proceed."
Mos records it as DECISIONS Q192 (mos-1154). The [mos test] raise request drafted here was dropped
unsent, and the template is deleted. Jason closed 3a6c8fb9 himself at 01:05:45Z (no, "superseded by r7").
Scope (Mos's reading): r7 at 47c2cb33c plus 82df17347, 342 lines, sha256
de079281ff2760911039276379ec4b190287b2ce2e4e8a79c9459939727440cd, Duckworth duckworth-0073 (pass, no findings).
@@ -0,0 +1,35 @@
{
"seq": 22,
"id": "3a6c8fb9-a5ab-42f8-9755-902a88375ca2",
"at": "2026-10-20T22:41:14.444Z",
"business": "mosaic-stack",
"project": null,
"raised_by_role": "pm",
"raised_by_run": "rc9e993da24cf",
"class": "gated",
"action": "prd.approve",
"route_to": "human",
"question": "Approve PRD 1.8 exactly as drafted?\n\nFile: agents/honker/work/queue-194/PRD-1.8-DRAFT.md on mosaicstack/stack at commit 762a6711ff7ed049aa73dfde98b75ebf326bdd99 (revision 5), 319 lines, sha256 8eee52470b1d7df139164e3ba5cc128dc469457b2b52f298c12685c3483011a8. Duckworth reviewed it (duckworth-0061, confirmed duckworth-0062) and Mos checked it (mos-1140). The tip of branch honker/row194-b2 holds a later r6 (aba3cbdeb), which is not used here. This approval covers only the r5 commit and sha256 above. The PM copied these values from the request and did not compute or check the sha256.\n\nNotes the approval carries:\n1. Mos's reading: \"the main agent or chief of staff\" in Q10 is one role under two names, the chief of staff. Only the chief raises a grant request; the assistant passes the user's request to the chief. To change it, say so at approval and REQ-GRANT-1 changes first.\n2. Mos's reading: revoke by \"you, Mos or the tester\" means the user, the assistant (\"Mos\", Jason's assistant) for grants in its own organization, and the tester only on a dev instance.\n3. N5: host.sudo keeps the arbiter route version 1.7 set for git.push.remote. A raise for it that cites the spec and isn't within-role goes to the arbiter for its domain, not to the user. In manual mode it comes to the user.\n4. B7 residual: only an Approve creates a grant, but an agent running as the stack's OS user can still write the stack's own store (Q182 on dragon-lin, #423 on .53 and .54). This version doesn't close it.\n5. N6: the broker enforces only that the chief of staff raised a grant request. The rest of the chain of command is the messaging lists and role contracts, not a broker check.\n\nSource: a tester request relayed by the chief (message 6990efb4, tester message d54a190e). It is a test request, not Jason's instruction. It takes the same route as PRD 1.7 (decision d52febb5): non-blocking, no task, uncited, so it goes to the human's Inbox. I recommend yes because two reviewers have passed r5 and the open points are written down above, not hidden.",
"options": [
{
"key": "yes",
"text": "Approve PRD 1.8 exactly as drafted: the r5 file at commit 762a6711ff7ed049aa73dfde98b75ebf326bdd99 with the sha256 below, and only that."
},
{
"key": "no",
"text": "Don't approve. Say what changes, and a new revision comes back for review."
}
],
"recommendation": "yes",
"task_ref": null,
"requirement_ref": null,
"blocking": false,
"supersedes": null,
"spec_path": null,
"spec_commit": null,
"authorization": {
"action": "prd.approve",
"target": null,
"approvalChoice": "yes"
}
}
+37
View File
@@ -0,0 +1,37 @@
[
{
"seq": 22,
"id": "3a6c8fb9-a5ab-42f8-9755-902a88375ca2",
"at": "2026-10-20T22:41:14.444Z",
"business": "mosaic-stack",
"project": null,
"raised_by_role": "pm",
"raised_by_run": "rc9e993da24cf",
"class": "gated",
"action": "prd.approve",
"route_to": "human",
"question": "Approve PRD 1.8 exactly as drafted?\n\nFile: agents/honker/work/queue-194/PRD-1.8-DRAFT.md on mosaicstack/stack at commit 762a6711ff7ed049aa73dfde98b75ebf326bdd99 (revision 5), 319 lines, sha256 8eee52470b1d7df139164e3ba5cc128dc469457b2b52f298c12685c3483011a8. Duckworth reviewed it (duckworth-0061, confirmed duckworth-0062) and Mos checked it (mos-1140). The tip of branch honker/row194-b2 holds a later r6 (aba3cbdeb), which is not used here. This approval covers only the r5 commit and sha256 above. The PM copied these values from the request and did not compute or check the sha256.\n\nNotes the approval carries:\n1. Mos's reading: \"the main agent or chief of staff\" in Q10 is one role under two names, the chief of staff. Only the chief raises a grant request; the assistant passes the user's request to the chief. To change it, say so at approval and REQ-GRANT-1 changes first.\n2. Mos's reading: revoke by \"you, Mos or the tester\" means the user, the assistant (\"Mos\", Jason's assistant) for grants in its own organization, and the tester only on a dev instance.\n3. N5: host.sudo keeps the arbiter route version 1.7 set for git.push.remote. A raise for it that cites the spec and isn't within-role goes to the arbiter for its domain, not to the user. In manual mode it comes to the user.\n4. B7 residual: only an Approve creates a grant, but an agent running as the stack's OS user can still write the stack's own store (Q182 on dragon-lin, #423 on .53 and .54). This version doesn't close it.\n5. N6: the broker enforces only that the chief of staff raised a grant request. The rest of the chain of command is the messaging lists and role contracts, not a broker check.\n\nSource: a tester request relayed by the chief (message 6990efb4, tester message d54a190e). It is a test request, not Jason's instruction. It takes the same route as PRD 1.7 (decision d52febb5): non-blocking, no task, uncited, so it goes to the human's Inbox. I recommend yes because two reviewers have passed r5 and the open points are written down above, not hidden.",
"options": [
{
"key": "yes",
"text": "Approve PRD 1.8 exactly as drafted: the r5 file at commit 762a6711ff7ed049aa73dfde98b75ebf326bdd99 with the sha256 below, and only that."
},
{
"key": "no",
"text": "Don't approve. Say what changes, and a new revision comes back for review."
}
],
"recommendation": "yes",
"task_ref": null,
"requirement_ref": null,
"blocking": false,
"supersedes": null,
"spec_path": null,
"spec_commit": null,
"authorization": {
"action": "prd.approve",
"target": null,
"approvalChoice": "yes"
}
}
]
+13
View File
@@ -0,0 +1,13 @@
[mos test] Please have the PM raise a prd.approve decision for PRD 1.8, the same route as PRD 1.7 (decision d52febb5): blocking false, no task_ref, recommendation yes, gated to the human. The PM must not compute or change the sha256; it copies the values below.
Question: "Approve PRD 1.8 exactly as drafted?"
File: agents/honker/work/queue-194/PRD-1.8-DRAFT.md on mosaicstack/stack at commit 762a6711ff7ed049aa73dfde98b75ebf326bdd99 (revision 5), 319 lines, sha256 8eee52470b1d7df139164e3ba5cc128dc469457b2b52f298c12685c3483011a8. Reviewed by Duckworth (duckworth-0061, confirmed duckworth-0062) and checked by Mos (mos-1140). The branch tip of honker/row194-b2 holds a later r6 (aba3cbdeb) that is not used; the approval covers only the r5 commit and sha256 above.
The decision notes must carry these five items:
1. Mos's reading: "the main agent or chief of staff" in Q10 is one role under two names, the chief of staff. Only the chief raises a grant request; the assistant passes the user's request to the chief. To change it, say so at approval and REQ-GRANT-1 changes first.
2. Mos's reading: revoke by "you, Mos or the tester" means the user, the assistant ("Mos", Jason's assistant) for grants in its own organization, and the tester only on a dev instance.
3. N5: host.sudo keeps the arbiter route version 1.7 set for git.push.remote. A raise for it that cites the spec and isn't within-role goes to the arbiter for its domain, not to the user. In manual mode it comes to the user.
4. B7 residual: only an Approve creates a grant, but an agent running as the stack's OS user can still write the stack's own store (Q182 on dragon-lin, #423 on .53 and .54). This version doesn't close it.
5. N6: the broker enforces only that the chief of staff raised a grant request. The rest of the chain of command is the messaging lists and role contracts, not a broker check.
Reply with the decision id once raised. Do not resolve it.
+8
View File
@@ -0,0 +1,8 @@
sent d54a190e to chief
chief (RESULT, re d54a190e) cc46a7b4:
I passed your request to the pm without changing any of it, and launched the pm so it can act on it. No decision id yet, because the pm hasn't raised the decision.
- **Message to pm:** 6990efb4-8321-4d0e-9b8a-771f65625b83. It says the request comes from a tester and authorizes nothing, and it carries your PRD 1.8 values and the five notes as you wrote them.
- **pm launch:** run rc9e993da24cf.
When the pm answers with the decision id, I'll send it to you.
+5
View File
@@ -0,0 +1,5 @@
trail 3a6c8fb9-a5ab-42f8-9755-902a88375ca2: 4 row(s)
2026-10-20T22:40:47.949Z events#1550 session.launched pm
2026-10-20T22:40:47.969Z role_claims#102 claim pm by rc9e993da24cf
2026-10-20T22:41:14.444Z decisions#22 gated prd.approve → human: Approve PRD 1.8 exactly as drafted? File: agents/honker/work/queue-194/PRD-1.8-DRAFT.md on mosaicstack/stack at commit …
2026-10-20T22:41:14.445Z events#1553 action.allowed pm decision.raise decision 3a6c8fb9
+21
View File
@@ -0,0 +1,21 @@
# Row 66 round 3: rebase only
Sage, 2026-10-20. Round 2 (candidate 0f4a57956, manifest cee63e06fdea) was approved by Filbert (29442) and
Dewey (29443). Its base 5487ed39d is now far behind refactor, and rows 190 and 192 changed four of its paths.
Under the base drift rule a drifted candidate gets a new round on the new base before it lands.
- Candidate: branch `sage/row66-r3`, commit 2369c00e36874e1100b301e0d01cbd105c0c5d4f, two cherry-picks of 8ec03b45a and 0f4a57956 onto c40d846cb.
No conflicts and no hand edits.
- `git patch-id --stable` of the r3 diff (c40d846cb..2369c00e36874e1100b301e0d01cbd105c0c5d4f) equals the r2 diff's (5487ed39d..0f4a57956):
6d085621fd355d43ccaf0efaefe7e74179888ff2.
- Manifest `candidate-manifest-r3.sha256`, sha256 42e3876ca1689719f0e07e66fc9fbd6e4faf3c07678da024accb54c58caef0d1. Same 23 paths as r2. Four hashes differ, and only
because the base moved: `docs/TOOLS.md`, `scripts/stack-copy.sh`, `packages/cli/tests/stack-copy.test.mjs`,
`packages/webui/tests/pinned-copy.test.mjs`. The other 19 are byte-identical to r2.
- Singles on the new base (`singles-r3.log`): every touched test file plus the unslop selftest, all green.
`packages/bus/tests/channel-proof.test.mjs` pins plug-in hashes and names `index.mjs`; it reads only channel
plug-ins, not the two harness files this row changes, and it passes (2/0).
- Still to do: the full gate on my slot after the MVP gates, then the landing sessions on both harnesses that
the row's gate names.
What I ask the reviewers: confirm the rebase. In the four drifted files, check that row 66's hunks sit correctly
next to rows 190 and 192 (`stack-copy.sh` COPY_PATHS in particular). Nothing else changed.
@@ -0,0 +1,23 @@
b99e7f4bf58ac68c8f5fa58eb7b90c0f85b15ca9a80fd093f6aaeafeac931608 adapters/README.md
c32d2a3276db92433d4e3c4f4b45fab048d9a46119d9f69cead6a11cfacfeadb agents/sage/work/queue-66/BUILD.md
ac318150f5e6193adf3750d76b64c643c29f324310c4004b0bdde8caef863b53 docs/TOOLS.md
64a2d00ba57b8673c0905f322711023c10d4dfdc0ccc7e6daa2c440b4d57e34e packages/cli/tests/stack-copy.test.mjs
b027dc74583217b4151173ecbabfcfbfc7b108ac6b7a8e93dd21a2834051714f packages/harness/README.md
bfb2156c609eeaf87c9e209c562576a5ef47535eaeee61d841bb0bd75a76a69d packages/harness/src/runner.mjs
0a27f2c8de7b9191f9c0d4a209331c63ff7630923e669838b41715924b0a64b0 packages/harness/tests/bundle.test.mjs
81d0352f2dcd41eb2e2ef962365d9b97515d7e8d9668a35bb9ce9dce4f1451ea packages/harness/tests/claude-session.test.mjs
b16f33442cefd5b5ae2af85edab33250ee6b31025e437edf0e4eba9fe0afa793 packages/harness/tests/fixtures/fake-adapter.mjs
1e187a2e23a197640d139e6975dfaf83dd7e337c360d128833678dc60f3e2046 packages/harness/tests/pi-session.test.mjs
37ca6ad6467573658c1ac6807b6cd00e3f9da289107fb689190a1872bff9b89c packages/harness/tests/runner.test.mjs
04c24058f37260c9192d5036c8cf28b50faba8e3e3bf57f198d233485d0d57e6 packages/plugins/harness/claude-code/index.mjs
2c1252e8e8db3ba4551bf1e34156ddf4e933cc664fc157105bef9a2705d6ce3b packages/plugins/harness/pi/index.mjs
7e0bea27d4360a39874fa64b3506a85f83888613a285ec0b087d74bf22b76421 packages/plugins/README.md
ec4ec9502ee8e766e3abd04d9f724c0d8ff4ef114394a0f76d5320c2dc715c5c packages/plugins/tests/harness.test.mjs
f6141dac9a8c61573e4198480fed85e093992ee9c19fb55e9bc2752381b7b07a packages/seat/tests/sandbox.test.mjs
09c6cadaf2e27677c95ba5bcb6a359cb6b3ce596ba1db6171b26586d4f9834a8 packages/webui/tests/pinned-copy.test.mjs
e9ff91cd073a1b784f9ea440b18db913ce8107df6246e3b0b0e962eda43f046b scripts/stack-copy.sh
7ee6ed6e6ac55b1348d40a358372e66c86fdebacd90001d447040133e1187882 tools/unslop-hook/claude-hook.js
ce1dfb11d0a8e8efd3d6dae67cc259c2e7443a62c7416bdedd4c71306d8c6df1 tools/unslop-hook/extension.ts
f07d67806f40763b4be87d31c2976448b5cfc34d7328519fae2d8313b1ca3e20 tools/unslop-hook/README.md
dbe3d5c9ae654d2401e3c496a6b03757a438e4a551fd8e460df8c82902bb80a7 tools/unslop-hook/test-unslop-check.js
76be78178a0703b9d20a7df2278ecb13f429dea90cea45180f22302e267ea25e tools/unslop-hook/unslop-check.js
+11
View File
@@ -0,0 +1,11 @@
packages/cli/tests/stack-copy.test.mjs rc=0 ℹ pass 14 ℹ fail 0
packages/harness/tests/bundle.test.mjs rc=0 ℹ pass 22 ℹ fail 0
packages/harness/tests/claude-session.test.mjs rc=0 ℹ pass 10 ℹ fail 0
packages/harness/tests/pi-session.test.mjs rc=0 ℹ pass 10 ℹ fail 0
packages/harness/tests/runner.test.mjs rc=0 ℹ pass 39 ℹ fail 0
packages/plugins/tests/harness.test.mjs rc=0 ℹ pass 9 ℹ fail 0
packages/seat/tests/sandbox.test.mjs rc=0 ℹ pass 10 ℹ fail 0
packages/webui/tests/pinned-copy.test.mjs rc=0 ℹ pass 10 ℹ fail 0
unslop rc=0 28/28 passed
SINGLES_DONE
2026-10-20T23:15:14Z
@@ -196,3 +196,37 @@ a piece. It passed, so the "pm doesn't answer" branch does not apply and
no defect is filed.
Piece 1 is Jason's next real Mosaic Stack request to pm. It is not open yet.
## 2026-10-21T01:14Z dates (sage-1622, Mos mos-1148, Vikunja #442)
Mos accepted the plan with changes C1 and C2. The dates are latest-by markers (Q111).
1. Pieces 1-5 open by 2026-10-23 23:59Z. Mos gives Jason the plain command after tonight's open items with him
(Q13 on PRD 1.8, the 1012 and 1008 boot). I don't ask again.
2. C1: no piece is pinned to a copy. A piece runs on whatever copy is live when Jason sends it. For each piece
this log records the activation id at send. A piece still open across a window gets the new activation id
recorded too.
3. C2: neither the Q175 phase 2 window (10-23) nor the 10-24 re-pin window rolls back or restores while a piece
has history, unless that window has failed. If a restore happens anyway, this log names the pieces to redo.
4. All five closed and reviewed by 2026-10-26 18:00Z. Jason reads the five trails at his gate on 10-26 or
10-27. A pass starts merge plan step 1 under decision 146, and step 4 stays his.
5. Fallback: if fewer than five pieces are open by 10-23, the gate moves to 10-28. No agent opens a piece in
Jason's place.
When Jason sends a piece, I tell Mos its message id.
Correction (2026-10-21T01:06Z): the heading above says 01:14Z. The clock read 2026-10-21T01:05:56Z when I wrote that entry, so 01:06Z is the right time.
## 2026-10-21T01:20Z Jason's pre-S7 checks (mos-1154)
Between 01:11:06Z and 01:13:03Z Jason sent pm five requests with `mosaic talk pm`: ed9e1e14, 51c19692,
94f5d62c, 0645a3b7, f75449c0 (bus seq 1583 to 1591). pm run rc9e993da24cf answered each in 4 to 16 s. They were
two jokes, the sandwich, the purpose of Mosaic Stack and pm's mission. pm made no task and raised no decision
for any of them, and said so each time. Mos's reading, which I take: none is a piece, because a piece has to
become work that gets closed and reviewed. They are recorded as Jason's pre-S7 checks. The five pieces are
still open, latest-by 10-23 23:59Z. Activation at send: copy 4429a104f, as in the 01:06Z entry (C1). Source: Mos
(mos-1154) read the bus. I didn't read the message bodies.
pm also told Jason that its state snapshot lists only the first 50 of 198 open tasks and that it pages the rest
with list_tasks. That is the row 109 (#1603) design: broker.mjs `state` returns `cap: TASK_PAGE` (50) with
`total`, and list_tasks pages up to TASK_PAGE_MAX (200).
@@ -0,0 +1,27 @@
{
"at": "2026-10-20T23:19:35.714Z",
"comments": [
{
"task": 413,
"status": 201,
"id": 411,
"readBack": 200,
"found": true
},
{
"task": 424,
"status": 201,
"id": 412,
"readBack": 200,
"found": true
},
{
"task": 414,
"status": 201,
"id": 413,
"readBack": 200,
"found": true
}
],
"problems": []
}
@@ -0,0 +1,51 @@
// Sage, 2026-10-20: row 192 (Q175 phase 1, #1690) landed. One comment on P19 412, per mos-0882 (every
// landing or decision gets a Vikunja comment). Sage's own key (bot user 13); the sync key reads back. Comments only:
// no task edits, labels, assignees or bucket moves. Checks the task's title before posting. Prints no secret.
// Usage: node comment-412-r192-landed.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON
import { readFileSync, writeFileSync, existsSync } from "node:fs";
const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2);
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
const PROJECT = 19;
const PSIG = "Posted by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13).";
const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const ps = (xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(PSIG)}</p>`;
const POSTS = [
{ id: 412, title: /^Console SSO: zero-trust OIDC verification/, body: [
"Row 192 (Q175 phase 1, Gitea #1690) landed and pushed. Landing commit 4429a104f (Launchpad's launchpad/row192 53bb59462..37582e820, manifest round 2, 18 paths; Webby approved). Patch-id 4a486902614d matches the reviewed diff. Queue revs 2429-2430 (landing note and done) are in 305857c8f, range 32b54ac3b..305857c8f on refactor. #1690 is closed.",
"Sage gate on 088087ac9: package suites 2758 pass 0 fail, scripts/test-*.sh 429 pass 0 fail, mutants 22 of 23 killed (#23, the second origin check, ruled equivalent). The Console now verifies SSO itself under --sso as an OIDC relying party on oauth4webapi 3.8.8, pinned exactly. mosaic-config.mjs load is now async, so in-flight rows that call it rebase and await it (row 190 round 3 is doing this).",
"Not live. The live stack still runs copies/490e2771d3d0. The new dependency needs npm ci in a new stack copy, so the live re-pin is a window with Mos (mos-1099 item 3); Sage sends Mos the plan. Row 200 (phase 2) follows.",
] },
];
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
async function api(method, path, body, auth) {
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
return { status: r.status, json };
}
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
const rec = { at: new Date().toISOString(), comments: [], problems: [] };
const save = () => {
const out = JSON.stringify(rec, null, 1) + "\n";
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
writeFileSync(OUT, out);
};
for (const P of POSTS) {
const t = await api("GET", `/api/v2/tasks/${P.id}`, undefined, sync);
if (t.status !== 200 || t.json?.project_id !== PROJECT || !P.title.test(t.json?.title ?? "")) throw new Error(`task ${P.id}: ${t.status}, title check failed; nothing posted`);
}
writeFileSync(OUT, "", { flag: "wx" });
for (const P of POSTS) {
const c = await api("PUT", `/api/v1/tasks/${P.id}/comments`, { comment: ps(P.body) }, key);
const e = { task: P.id, status: c.status, id: c.json?.id ?? null };
rec.comments.push(e);
if (c.status >= 300 || !Number.isSafeInteger(c.json?.id)) { rec.problems.push(`comment ${P.id}: ${c.status} ${c.json?.message ?? ""}`); save(); continue; }
const back = await api("GET", `/api/v2/tasks/${P.id}/comments?per_page=50`, undefined, sync);
const found = list(back.json).find((x) => x.id === c.json.id);
Object.assign(e, { readBack: back.status, found: Boolean(found) });
if (back.status !== 200 || !found) rec.problems.push(`comment ${P.id} read-back: ${back.status}`);
save();
}
save();
process.stdout.write(JSON.stringify(rec, null, 1) + "\n");
if (rec.problems.length) process.exit(2);
@@ -0,0 +1,59 @@
// Sage, 2026-10-20: catch-up landing and status comments on P19 413 (row 189), 424 (row 210) and 414 (rows 201
// and 204), per mos-0882. Sage's own key (bot user 13); the sync key reads back. Comments only: no task edits.
// Checks each task's title before posting. Prints no secret.
// Usage: node comment-catchup-2026-10-20.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON
import { readFileSync, writeFileSync, existsSync } from "node:fs";
const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2);
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
const PROJECT = 19;
const PSIG = "Posted by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13).";
const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const ps = (xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(PSIG)}</p>`;
const POSTS = [
{ id: 413, title: /^stack-release\.sh activate --no-send/, body: [
"Row 189 landed on refactor as ace27dc5e (Gitea #1687): stack-release.sh activate --no-send drops the Console's --send and keeps the pointer and the unit in step. Dewey approved (comment 29485), and Sage's gate passed.",
"The stack activation log shows the same-copy activate on 490e2771d3d0 after the landing: activation 20261020T192956Z-490e2771d3d0, result ok at 19:30:00Z, with backup 20261020T192956Z-490e2771d3d0. Since then the live Console runs without --send. The re-pin to 4429a104f at 22:02Z (row 192) carried the pointer's flags, no --send and no --sso, as its W3 plan check required.",
] },
{ id: 424, title: /^Credential store: per-agent host access/, body: [
"Status, 2026-10-20 23:20Z. Row 210 (Gizmoduck) builds this task. Its design r3 is approved, and the machine-grant design final r6 f9039682e is approved for code once row 210 lands (mos-1141, mos-1143). Every grant name sits under grant.* behind one guard (mos-1144). No agent creates a grant: the chief requests, Jason approves, and protected code creates it (Q188).",
"Order (mos-1135, mos-1137): row 210 lands only after row 199, the per-agent credential store. Row 199's gate is green on c6868ba41. Megavolt fixed five surviving mutants and is rerunning all 28 now on 7c3cdd940, before the Duckworth review. Rows 190, 199 and 210 go live together in the one 10-24 window with the 3i-3j migration (mos-1145 line 9), backup first, under Q173.",
] },
{ id: 414, title: /^MVP platform: the Given list for Cases 1 and 2/, body: [
"RBAC rows landed (Given list, RBAC enforcement). Row 201, FED R1: access.json loader and validator with covers, afad4993f (Gitea #1699), Gizmoduck, Duckworth approved (comment 29581). Gate: business 173/0, scripts/test-*.sh 422/0, mutants 44 of 44 killed.",
"Row 204, 193a: the access level check widened to viewer, d0bbc5c1c (Gitea #1702), Morgana, Duckworth approved (comment 29595). LEVELS gains collaborator and viewer, covers() takes a required least level, and an unknown grant level covers nothing. Gate: packages and scripts 2789/0, mutants 7 of 7 killed.",
"Neither changes the live stack. Rows 205-208 (193b1 to 193d) follow, and 206 adds a schema step, so it goes live only in a --migrate window.",
] },
];
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
async function api(method, path, body, auth) {
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
return { status: r.status, json };
}
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
const rec = { at: new Date().toISOString(), comments: [], problems: [] };
const save = () => {
const out = JSON.stringify(rec, null, 1) + "\n";
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
writeFileSync(OUT, out);
};
for (const P of POSTS) {
const t = await api("GET", `/api/v2/tasks/${P.id}`, undefined, sync);
if (t.status !== 200 || t.json?.project_id !== PROJECT || !P.title.test(t.json?.title ?? "")) throw new Error(`task ${P.id}: ${t.status}, title check failed; nothing posted`);
}
writeFileSync(OUT, "", { flag: "wx" });
for (const P of POSTS) {
const c = await api("PUT", `/api/v1/tasks/${P.id}/comments`, { comment: ps(P.body) }, key);
const e = { task: P.id, status: c.status, id: c.json?.id ?? null };
rec.comments.push(e);
if (c.status >= 300 || !Number.isSafeInteger(c.json?.id)) { rec.problems.push(`comment ${P.id}: ${c.status} ${c.json?.message ?? ""}`); save(); continue; }
const back = await api("GET", `/api/v2/tasks/${P.id}/comments?per_page=50`, undefined, sync);
const found = list(back.json).find((x) => x.id === c.json.id);
Object.assign(e, { readBack: back.status, found: Boolean(found) });
if (back.status !== 200 || !found) rec.problems.push(`comment ${P.id} read-back: ${back.status}`);
save();
}
save();
process.stdout.write(JSON.stringify(rec, null, 1) + "\n");
if (rec.problems.length) process.exit(2);
@@ -0,0 +1,60 @@
// Sage, 2026-10-21: re-plan comments on P19 405-408 and 411 (due 10-22). Sage's own key (bot user 13); the sync key reads back. Comments only: no task edits.
// Checks each task's title before posting. Prints no secret.
// Usage: node comment-p19-405-411-replan.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON
import { readFileSync, writeFileSync, existsSync } from "node:fs";
const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2);
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
const PROJECT = 19;
const PSIG = "Posted by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13).";
const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const ps = (xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(PSIG)}</p>`;
const POSTS = [
{ id: 405, title: /^stack-release --plan: no test plans a missing store/, body: [
"Re-plan (Sage, 2026-10-21, due 10-22). Tasks 405, 406, 407, 408 and 411 become one queue row, a stack-release follow-up row. All five touch scripts/stack-release.sh, scripts/webui-service.sh or docs/TOOLS.md, and rows 232 (Darkwing) and 238 (Launchpad) are changing stack-release.sh now. So I brief the row after 232 and 238 land, from their landing, which avoids base drift (the row 68 rule). It lands after the 10-24 window, because that window holds the four service scripts at NEW (P5). 411's hold was the Part B window, and Part B is superseded (Q175, mos-0970), so that hold is gone. Each item keeps the test and mutant its task names. I'll name the owner when I brief it, either the seat that frees up first of Darkwing and Launchpad, or me.",
] },
{ id: 406, title: /^stack-release --plan: backup line printed/, body: [
"Re-plan (Sage, 2026-10-21, due 10-22). Tasks 405, 406, 407, 408 and 411 become one queue row, a stack-release follow-up row. All five touch scripts/stack-release.sh, scripts/webui-service.sh or docs/TOOLS.md, and rows 232 (Darkwing) and 238 (Launchpad) are changing stack-release.sh now. So I brief the row after 232 and 238 land, from their landing, which avoids base drift (the row 68 rule). It lands after the 10-24 window, because that window holds the four service scripts at NEW (P5). 411's hold was the Part B window, and Part B is superseded (Q175, mos-0970), so that hold is gone. Each item keeps the test and mutant its task names. I'll name the owner when I brief it, either the seat that frees up first of Darkwing and Launchpad, or me.",
] },
{ id: 407, title: /^stack-release\.sh: the proof_for_target comment/, body: [
"Re-plan (Sage, 2026-10-21, due 10-22). Tasks 405, 406, 407, 408 and 411 become one queue row, a stack-release follow-up row. All five touch scripts/stack-release.sh, scripts/webui-service.sh or docs/TOOLS.md, and rows 232 (Darkwing) and 238 (Launchpad) are changing stack-release.sh now. So I brief the row after 232 and 238 land, from their landing, which avoids base drift (the row 68 rule). It lands after the 10-24 window, because that window holds the four service scripts at NEW (P5). 411's hold was the Part B window, and Part B is superseded (Q175, mos-0970), so that hold is gone. Each item keeps the test and mutant its task names. I'll name the owner when I brief it, either the seat that frees up first of Darkwing and Launchpad, or me.",
] },
{ id: 408, title: /^docs\/TOOLS\.md: the row 178 paragraph/, body: [
"Re-plan (Sage, 2026-10-21, due 10-22). Tasks 405, 406, 407, 408 and 411 become one queue row, a stack-release follow-up row. All five touch scripts/stack-release.sh, scripts/webui-service.sh or docs/TOOLS.md, and rows 232 (Darkwing) and 238 (Launchpad) are changing stack-release.sh now. So I brief the row after 232 and 238 land, from their landing, which avoids base drift (the row 68 rule). It lands after the 10-24 window, because that window holds the four service scripts at NEW (P5). 411's hold was the Part B window, and Part B is superseded (Q175, mos-0970), so that hold is gone. Each item keeps the test and mutant its task names. I'll name the owner when I brief it, either the seat that frees up first of Darkwing and Launchpad, or me.",
] },
{ id: 411, title: /^stack-release\.sh and webui-service\.sh: the proof file repository check/, body: [
"Re-plan (Sage, 2026-10-21, due 10-22). Tasks 405, 406, 407, 408 and 411 become one queue row, a stack-release follow-up row. All five touch scripts/stack-release.sh, scripts/webui-service.sh or docs/TOOLS.md, and rows 232 (Darkwing) and 238 (Launchpad) are changing stack-release.sh now. So I brief the row after 232 and 238 land, from their landing, which avoids base drift (the row 68 rule). It lands after the 10-24 window, because that window holds the four service scripts at NEW (P5). 411's hold was the Part B window, and Part B is superseded (Q175, mos-0970), so that hold is gone. Each item keeps the test and mutant its task names. I'll name the owner when I brief it, either the seat that frees up first of Darkwing and Launchpad, or me.",
] },
];
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
async function api(method, path, body, auth) {
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
return { status: r.status, json };
}
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
const rec = { at: new Date().toISOString(), comments: [], problems: [] };
const save = () => {
const out = JSON.stringify(rec, null, 1) + "\n";
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
writeFileSync(OUT, out);
};
for (const P of POSTS) {
const t = await api("GET", `/api/v2/tasks/${P.id}`, undefined, sync);
if (t.status !== 200 || t.json?.project_id !== PROJECT || !P.title.test(t.json?.title ?? "")) throw new Error(`task ${P.id}: ${t.status}, title check failed; nothing posted`);
}
writeFileSync(OUT, "", { flag: "wx" });
for (const P of POSTS) {
const c = await api("PUT", `/api/v1/tasks/${P.id}/comments`, { comment: ps(P.body) }, key);
const e = { task: P.id, status: c.status, id: c.json?.id ?? null };
rec.comments.push(e);
if (c.status >= 300 || !Number.isSafeInteger(c.json?.id)) { rec.problems.push(`comment ${P.id}: ${c.status} ${c.json?.message ?? ""}`); save(); continue; }
const back = await api("GET", `/api/v2/tasks/${P.id}/comments?per_page=50`, undefined, sync);
const found = list(back.json).find((x) => x.id === c.json.id);
Object.assign(e, { readBack: back.status, found: Boolean(found) });
if (back.status !== 200 || !found) rec.problems.push(`comment ${P.id} read-back: ${back.status}`);
save();
}
save();
process.stdout.write(JSON.stringify(rec, null, 1) + "\n");
if (rec.problems.length) process.exit(2);
@@ -0,0 +1,53 @@
// Sage, 2026-10-21: landing comments on P19 412 (row 200) and 424 (row 199), per mos-0882. Sage's own key (bot user 13); the sync key reads back. Comments only: no task edits.
// Checks each task's title before posting. Prints no secret.
// Usage: node comment-r200-r199-landed.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON
import { readFileSync, writeFileSync, existsSync } from "node:fs";
const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2);
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
const PROJECT = 19;
const PSIG = "Posted by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13).";
const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const ps = (xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(PSIG)}</p>`;
const POSTS = [
{ id: 412, title: /^Console SSO: zero-trust OIDC verification/, body: [
"Row 200 landed on refactor as 9e1085816 (Gitea #1698, closed): stack-release.sh activate --sso, the phase 2 prerequisite. Launchpad built it and Webby approved r3. Sage's run killed 13 of 14 mutants (M12 is the equivalent ruled in round 2). Gate: 15 package suites and 10 scripts/test-*.sh suites, all green, at 00:07:57Z.",
"Follow-up N1 is row 238 (Gitea #1744, Launchpad, Webby). The sso-config hint will carry --instance NAME for a named instance. Nothing is live until a window activates a copy that includes 9e1085816.",
] },
{ id: 424, title: /^Credential store: per-agent host access/, body: [
"Row 199 landed on refactor as cb5fdb064 (Gitea #1697, closed). It adds the per-agent credential store, the agent's own git identity and pushes to a remote. Megavolt built it and Duckworth approved r1 (comment 29779). Gate on 9e1085816 plus the patch: packages 2735/0, scripts 429/0.",
"Row 210 (Gizmoduck) now rebases onto cb5fdb064 on a new branch (mos-1135, mos-1137). Rows 234 and 235 follow it. Rows 190, 199 and 210 go live together in the 10-24 window with the 3i-3j migration, backup first, under Q173 (mos-1145).",
] },
];
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
async function api(method, path, body, auth) {
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
return { status: r.status, json };
}
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
const rec = { at: new Date().toISOString(), comments: [], problems: [] };
const save = () => {
const out = JSON.stringify(rec, null, 1) + "\n";
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
writeFileSync(OUT, out);
};
for (const P of POSTS) {
const t = await api("GET", `/api/v2/tasks/${P.id}`, undefined, sync);
if (t.status !== 200 || t.json?.project_id !== PROJECT || !P.title.test(t.json?.title ?? "")) throw new Error(`task ${P.id}: ${t.status}, title check failed; nothing posted`);
}
writeFileSync(OUT, "", { flag: "wx" });
for (const P of POSTS) {
const c = await api("PUT", `/api/v1/tasks/${P.id}/comments`, { comment: ps(P.body) }, key);
const e = { task: P.id, status: c.status, id: c.json?.id ?? null };
rec.comments.push(e);
if (c.status >= 300 || !Number.isSafeInteger(c.json?.id)) { rec.problems.push(`comment ${P.id}: ${c.status} ${c.json?.message ?? ""}`); save(); continue; }
const back = await api("GET", `/api/v2/tasks/${P.id}/comments?per_page=50`, undefined, sync);
const found = list(back.json).find((x) => x.id === c.json.id);
Object.assign(e, { readBack: back.status, found: Boolean(found) });
if (back.status !== 200 || !found) rec.problems.push(`comment ${P.id} read-back: ${back.status}`);
save();
}
save();
process.stdout.write(JSON.stringify(rec, null, 1) + "\n");
if (rec.problems.length) process.exit(2);
@@ -0,0 +1,66 @@
// Sage, 2026-10-20: W11 of the re-pin window 490e2771d to 4429a104f (STATE.md in commit 99a406ed3) on P19 386, 412
// and 393, and the row 190 landing (00d4cc1f6, #1688) on P19 414, per mos-0882. Sage's own key (bot user 13); the
// sync key reads back. Comments only: no task edits. Checks each task's title before posting. Prints no secret.
// Usage: node comment-w11-r190-2026-10-20.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON
import { readFileSync, writeFileSync, existsSync } from "node:fs";
const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2);
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
const PROJECT = 19;
const PSIG = "Posted by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13).";
const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const ps = (xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(PSIG)}</p>`;
const STATE = "agents/sage/work/window-repin-4429a104f/STATE.md in commit 99a406ed3 (pushed), sha256 a4d0a7b12f431f15";
const POSTS = [
{ id: 386, title: /^Q173 test system: tester writes/, body: [
"Q173 re-pin window, 490e2771d to 4429a104f (row 192), done. STATE report: " + STATE + ".",
"Activation 20261020T220206Z-4429a104f6d6 with --pm, exit 0, every gate check including pm_launched. Store stays on 3i, migration none. Backup ~/.mosaic-dev/backups/stack/20261020T220206Z-4429a104f6d6 (SHA256SUMS b61d6484), rollback set beside it; rollback would go to 490e2771d. Business file unchanged (sha256 1e49bdf0). Console flags unchanged: no --send, no --sso, --tester-port 7333.",
"W0 to W10 passed, nothing rolled back. W10 stopped once on two browser checks; both were checks written before row 192 (the hidden #sign-out link, and a chat check that read only the first project list). Mos passed them (mos-1135) and the corrected checks are in the plan. Matrix line g is 6575 bytes, not 6454, because row 192 grows index.html by 121 bytes; Mos passed it (mos-1137).",
"S1 to S2 store deltas include task_snapshots +7 and task_comment_cursors +5. The writer is the bus's Vikunja poll (packages/tasks/src/adapter.mjs tick, sync.mjs recordTask), source 'poll', not the window. Nobody acted as Jason: no --by jason, no pi, no Save, no mosaic name, bus.tester untouched.",
] },
{ id: 412, title: /^Console SSO: zero-trust OIDC verification/, body: [
"Row 192 (Q175 phase 1) is live in copy 4429a104f since activation 20261020T220206Z (Q173 window with Mos). The sign-in code is in the copy but dormant: the Console runs without --sso, and the proof pair stays unset. The new oauth4webapi 3.8.8 dependency came in through npm ci in the new copy and imports cleanly from it.",
"STATE report: " + STATE + ". The webui journal since activation shows one 'Proxy: proxy proof off' line and no 'Sign-in:' line, as expected without --sso.",
] },
{ id: 393, title: /^dragon-lin: seven legacy user units in failed state/, body: [
"Current failure times read during the Q173 re-pin window W11 (systemctl --user show, CDT with UTC): mosaic-standup-pm.service failed 2026-10-19 19:00:28 CDT (10-20 00:00:28Z), exit 1. mosaic-install-drift.service failed 2026-10-20 00:12:54 CDT (05:12:54Z), exit 9. [email protected] failed 2026-10-20 13:16:12 CDT (18:16:12Z), exit 4.",
"The marker's journal at 18:16:12Z says its tmux send to jarvis failed ('tmux target not found', socket mosaic-fleet), so that page never arrived; the record is in ~/.local/state/mosaic-unit-failures/20261020.jsonl. mosaic-reconcile.service itself last ran OK at 17:16:27 CDT (22:16:27Z), so the marker is stale until reset.",
"All three predate the re-pin at 22:02Z and come from the legacy ~/.mosaic and jarvis-brain setup. The window changed none of them. Disabling or resetting any still needs Jason or ops-01's charter. Details: " + STATE + ", section 'Failed user units'.",
] },
{ id: 414, title: /^MVP platform: the Given list for Cases 1 and 2/, body: [
"Row 190 (several Mosaic Stack instances on one host, MVP T1.1, Gitea #1688) landed and pushed: 00d4cc1f6 on refactor (99a406ed3..00d4cc1f6). Squash of Darkwing's darkwing/row190-r3 176a6bf4b..df65a3d4c, 25 paths, manifest cc0ea9e8, Filbert approved round 3; patch-id matches the branch diff.",
"Sage gate on d0bbc5c1c with the patch: 15 package suites 2664 pass 0 fail, 10 scripts/test-*.sh suites 429 pass 0 fail. Mutants 20 of 20 killed. Not live: the stack runs copy 4429a104f until a re-pin. Row 230 (C2-13a) now carries row 190's @CONFIG_ENV@ and @SESSION_ENV@ tokens (gosalyn-0058 item 4).",
] },
];
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
async function api(method, path, body, auth) {
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
return { status: r.status, json };
}
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
const rec = { at: new Date().toISOString(), comments: [], problems: [] };
const save = () => {
const out = JSON.stringify(rec, null, 1) + "\n";
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
writeFileSync(OUT, out);
};
for (const P of POSTS) {
const t = await api("GET", `/api/v2/tasks/${P.id}`, undefined, sync);
if (t.status !== 200 || t.json?.project_id !== PROJECT || !P.title.test(t.json?.title ?? "")) throw new Error(`task ${P.id}: ${t.status}, title check failed; nothing posted`);
}
writeFileSync(OUT, "", { flag: "wx" });
for (const P of POSTS) {
const c = await api("PUT", `/api/v1/tasks/${P.id}/comments`, { comment: ps(P.body) }, key);
const e = { task: P.id, status: c.status, id: c.json?.id ?? null };
rec.comments.push(e);
if (c.status >= 300 || !Number.isSafeInteger(c.json?.id)) { rec.problems.push(`comment ${P.id}: ${c.status} ${c.json?.message ?? ""}`); save(); continue; }
const back = await api("GET", `/api/v2/tasks/${P.id}/comments?per_page=50`, undefined, sync);
const found = list(back.json).find((x) => x.id === c.json.id);
Object.assign(e, { readBack: back.status, found: Boolean(found) });
if (back.status !== 200 || !found) rec.problems.push(`comment ${P.id} read-back: ${back.status}`);
save();
}
save();
process.stdout.write(JSON.stringify(rec, null, 1) + "\n");
if (rec.problems.length) process.exit(2);
@@ -0,0 +1,66 @@
// Sage, 2026-10-21: files row 237's review follow-ups N1 and N3 (duckworth-0069, #1743 comment 29810) as two P19 tasks.
// Sage's own key (bot user 13); the sync key reads back. No labels, assignees, due dates or bucket moves. Refuses if
// the title exists. Prints no secret.
// Usage: node file-r237-followups.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON
import { readFileSync, writeFileSync, existsSync } from "node:fs";
const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2);
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
const PROJECT = 19;
const SIG = "Created by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13).";
const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const ps = (sig, xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(sig)}</p>`;
const TASKS = [
{
title: "Bus publish: a retry after publish-timeout can push a second fork ref while the first push still runs (row 237 N1)",
description: ps(SIG, [
"Duckworth's row 237 review N1 (#1743 comment 29810, duckworth-0069). Row 237 makes checkPublish refuse publish-busy while an open ticket holds the same business and decision. publishRefused drops the ticket on publish-timeout, but the bus process's push may still be running, so a retry to another branch can push a second ref to the fork.",
"The fix belongs in packages/bus/src/runtime.mjs, in `remotes`: either keep the ticket until the push process exits, or kill the push on timeout and wait for it. A test should start a slow push, time it out, retry to another branch, and expect one ref on the fork. Not in row 237's scope. Megavolt's BUILD.md residual 2 (agents/megavolt/work/queue-237/BUILD.md). Needs a queue row once 237 lands.",
]),
},
{
title: "Bus land: check whether checkLand has the same open-ticket race that row 237 fixed in checkPublish (row 237 N3)",
description: ps(SIG, [
"Row 237 residual 3 (Megavolt's BUILD.md) and Duckworth's N3 (#1743 comment 29810): checkLand has the same ticket shape as checkPublish had before row 237. Two lands on the same decision may both pass the check before either records. Duckworth didn't check it.",
"First step: read checkLand and its ticket code in packages/bus/src/broker.mjs against row 237's fix (broker.mjs:1038-1043). If the race exists, file a queue row with a race test like publish-socket.test.mjs's; if not, record why and close this task.",
]),
},
];
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
async function api(method, path, body, auth) {
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
return { status: r.status, json };
}
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
async function existing() {
const out = [];
for (let page = 1; page < 20; page++) {
const r = await api("GET", `/api/v2/projects/${PROJECT}/tasks?per_page=50&page=${page}`, undefined, sync);
if (r.status !== 200) throw new Error(`list tasks: ${r.status}`);
out.push(...list(r.json));
if (list(r.json).length < 50) return out;
}
throw new Error("more than 19 pages");
}
const rec = { at: new Date().toISOString(), project: PROJECT, created: null, problems: [] };
const save = () => {
const out = JSON.stringify(rec, null, 1) + "\n";
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
writeFileSync(OUT, out);
};
const have = await existing();
for (const T of TASKS) if (have.some((t) => t.title === T.title)) throw new Error("title exists; refusing");
writeFileSync(OUT, "", { flag: "wx" });
rec.created = [];
for (const T of TASKS) {
const r = await api("PUT", `/api/v1/projects/${PROJECT}/tasks`, { title: T.title, description: T.description }, key);
const c = { status: r.status, id: r.json?.id ?? null };
rec.created.push(c);
if (r.status >= 300 || !Number.isSafeInteger(r.json?.id)) { rec.problems.push(`create: ${r.status} ${r.json?.message ?? ""}`); continue; }
const back = await api("GET", `/api/v2/tasks/${r.json.id}`, undefined, sync);
Object.assign(c, { readBack: back.status, done: back.json?.done });
if (back.status !== 200 || back.json?.title !== T.title || back.json?.done) rec.problems.push(`read-back differs for ${r.json.id}`);
}
save();
console.log(JSON.stringify(rec));
@@ -0,0 +1,41 @@
{
"at": "2026-10-21T01:06:25.071Z",
"comments": [
{
"task": 405,
"status": 201,
"id": 416,
"readBack": 200,
"found": true
},
{
"task": 406,
"status": 201,
"id": 417,
"readBack": 200,
"found": true
},
{
"task": 407,
"status": 201,
"id": 418,
"readBack": 200,
"found": true
},
{
"task": 408,
"status": 201,
"id": 419,
"readBack": 200,
"found": true
},
{
"task": 411,
"status": 201,
"id": 420,
"readBack": 200,
"found": true
}
],
"problems": []
}
@@ -0,0 +1,13 @@
{
"at": "2026-10-20T21:35:49.240Z",
"comments": [
{
"task": 412,
"status": 201,
"id": 395,
"readBack": 200,
"found": true
}
],
"problems": []
}
@@ -0,0 +1,20 @@
{
"at": "2026-10-21T00:28:32.038Z",
"comments": [
{
"task": 412,
"status": 201,
"id": 414,
"readBack": 200,
"found": true
},
{
"task": 424,
"status": 201,
"id": 415,
"readBack": 200,
"found": true
}
],
"problems": []
}
@@ -0,0 +1,19 @@
{
"at": "2026-10-21T00:38:47.170Z",
"project": 19,
"created": [
{
"status": 201,
"id": 440,
"readBack": 200,
"done": false
},
{
"status": 201,
"id": 441,
"readBack": 200,
"done": false
}
],
"problems": []
}
@@ -0,0 +1,16 @@
// Sage, 2026-10-21: read-only. Prints id, done, title, the last update and the description as plain text for the
// given P19 tasks, with the sync key. Writes nothing. Prints no secret.
// Usage: node read-tasks.mjs https://tasks.woltje.com SYNC_TOKEN_FILE ID...
import { readFileSync } from "node:fs";
const [ORIGIN, SYNC, ...IDS] = process.argv.slice(2);
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
const sync = readFileSync(SYNC, "utf8").trim();
const text = (h) => (h ?? "").replace(/<\/p>|<br\s*\/?>|<\/li>/g, "\n").replace(/<[^>]+>/g, "").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&").trim();
for (const id of IDS) {
const r = await fetch(`${ORIGIN}/api/v2/tasks/${id}`, { headers: { Authorization: `Bearer ${sync}` }, redirect: "error" });
const j = await r.json().catch(() => null);
if (r.status !== 200 || j?.project_id !== 19) { console.log(`== ${id}: status ${r.status}, not P19`); continue; }
const out = `== ${id} done=${j.done} updated=${j.updated}\n${j.title}\n${text(j.description)}\n`;
if (out.includes(sync)) throw new Error("secret in output");
console.log(out);
}
@@ -0,0 +1,34 @@
{
"at": "2026-10-20T22:51:44.218Z",
"comments": [
{
"task": 386,
"status": 201,
"id": 406,
"readBack": 200,
"found": true
},
{
"task": 412,
"status": 201,
"id": 407,
"readBack": 200,
"found": true
},
{
"task": 393,
"status": 201,
"id": 408,
"readBack": 200,
"found": true
},
{
"task": 414,
"status": 201,
"id": 409,
"readBack": 200,
"found": true
}
],
"problems": []
}
@@ -0,0 +1,90 @@
# 10-24 window: re-pin 4429a104f to the row 234 landing, with the 3i-3j migration (draft 0)
Sage, 2026-10-21T01:05Z, draft 0, for mos-1145 under DECISIONS Q173. This is the skeleton. NEW isn't fixed yet,
so Mos doesn't check this draft. Draft 1 names NEW and goes to Mos for the P0 check. The step layout follows
`repin-4429a104f.md`. Steps that match are named, not copied.
## Scope (mos-1145, mos-1134)
- One window carries the code of rows 190, 199 and 210, plus row 234 (row a) with its schema step 3i to 3j
through `activate --migrate`, agent-run under Q173, backup first.
- Row a lands before the .53 and .54 installs, by 10-25 (mos-1134). If row 234 hasn't landed by 10-24 12:00Z,
I ask Mos whether to run the window without it (no migration) or to move it.
- Jason has no step. Nobody acts as Jason. No pi (P19 383). No Save, no `mosaic name`. Nothing is pruned.
- `--sso` stays off. Row 200's `activate --sso` is in NEW as a tool. Turning it on changes who reaches the Console
from outside, so that's Jason's (Q173 exclusion). It has its own window, the Q175 phase 2 window on 10-23
(mos-1148). That window runs first, so this window's OLD may be the phase 2 copy, and P1 reads it from
`status`.
## Names
- OLD, live: `4429a104f6d65ccf165f6c83b5d1dce3147dfde0`, schema 3i, pointer `pm: true` (record 99a406ed3).
P1 re-reads the activation id and the Console flags from `status`. I don't copy them from the last window.
- NEW: `c771a36f4bd0b6c359282425c668996a90072f93`, the row 234 landing (2026-10-21 06:16Z), unless Mos rules a
later code landing. 194 is the open candidate. 210, 237, 232, 241, 242, 233 and 234 have landed.
- REPO, `P`, `WIN=agents/sage/work/window-repin-<NEW12>/`, `SNAP`: as in `repin-4429a104f.md`, with the
sha checks at P0 and before S1, S2 and any D3.
## What NEW carries over OLD (refactor c771a36f4)
`git log --first-parent 4429a104f..HEAD`, path-limited to packages, scripts, roles, contracts, adapters, skills
and the package files:
| Row | Commit | Live effect to check |
|---|---|---|
| 201 | afad4993f | business: access.json loader. Check: the live business has no access.json, and load stays LOAD_OK (P1). |
| 204 | d0bbc5c1c | business: 193a viewer level. Same check. |
| 190 | 00d4cc1f6 | Several instances per host. Touches `stack-release.sh`, `webui-service.sh`, `bus-service.sh`, `stack-copy.sh`. The default instance must render the same units: P2 diffs the rendered units OLD vs NEW for `mosaic-stack`, and any difference stops the window until it's explained. |
| 200 | 9e1085816 | Tool: `activate --sso`. Off here. `--plan` must show no `--sso` line. |
| 199 | cb5fdb064 | Credential store per agent. Check: bus start with no store entries for the live agents changes nothing, and the chief's and pm's launches still pass. The P3 rehearsal covers start. |
| N3 | 390ea5261 | README only. |
| 230 | c8bc5cf4c | Case 2 scripts, tools only. Not run live. |
| 210 | f5753c0e8 | Host access. The live business has no `hosts`, so nothing is granted. Bus start runs the host-run sweep (M34d). P3 must show it removes nothing. |
| 237 | d7d943199 | Publish busy check (199 N1). |
| 232 | e65a3ab4d | Installer I1: `stack-release.sh --units external` and `stack-copy.sh copy --bundle`, `wrapper --create`. Both are opt-in, and this window uses neither. The default units path must render the same units, so the P2 unit diff covers it with row 190. P0 checks that no window command line carries `--units external`. |
| 241 | 5d4a61776 | Test only (bus land.test.mjs). No live effect. |
| 242 | 5fb736b92 | Bus publish hold: after publish-timeout the bus holds the decision until its push ends, then records a late push. The live business has no publish in flight at W1, and P3 shows none open. Its follow-up fc1cb856b is comments only (git-socket.mjs). |
| 233 | 57f59e0c3 | Installer I3: new tool `scripts/stack-upgrade.sh` and a `stack-release.sh` change. This window doesn't run `stack-upgrade.sh`. The P2 unit diff covers the `stack-release.sh` change with rows 190 and 232. |
| 234 | c771a36f4 | Machine grant row a and the 3i to 3j schema step (new tables `hosts` and `host_grants`). `--plan` must say `migration: 3i -> 3j`. |
| PRD 1.7+1.8 | 0adf8ddfe | `docs/prd/mosaic-stack.md` and `packages/tasks/requirements.json` move to version 1.8 (mos-1154, mos-1156, DECISIONS Q192). The live pm cites 1.6 until this window. Check after: the pm's requirements read answers version 1.8. |
Service script drift OLD to HEAD is 415+/91- over the four scripts, mostly row 190. P5 holds them at NEW.
## Backups and the migration
- W5: the stopped store backup that every activate takes (row 185), with SHA256SUMS, SCHEMA 3i and STAMP,
plus the rollback set. Before `--migrate` writes, I check that SCHEMA reads 3i and the backup SHA256SUMS hash.
- W0b: the business file copy, as in the last window.
- Rollback after a migration isn't the plain rollback. OLD is 3i and the store is 3j, so going back needs
`--restore <W5 activation>`, and that loses the events written after W5. The row 181 restore guard counts
11 tables, and the untimed ones need the D3 snapshot. Row 234 adds `hosts` and `host_grants` to the untimed
list (T46), so at 3j the D3 snapshot covers them too. Draft 1 recounts the list from NEW. Mos decides any restore past W5, as before.
- Grant look-alikes (row 234 A22b, gizmoduck-0107). `3i-3j.sql` adds `events_grant_actor` but doesn't check
rows already in the store, so after the migration only the actor filter in `GRANT_EVIDENCE` (actor_role NULL,
actor_run 'grant') ignores an old session-written row. P3, and again just before `--migrate`: on the
backup copy, count the `action.allowed` events whose body.action or body.operation starts with `grant.`, or
whose body has `hostLineLeft`. Expect 0, since no grant code has run live. Any nonzero count stops the window
until I've read those rows.
- P3 is the rehearsal with `--migrate` on a copy of the live backup. It must show the 3j step and the row
counts before and after.
## Row 42 (S7) interaction
Row 42's pieces may be in flight at window start (sage-1622, mos-1148 C1 and C2). W1 adds: list open pm tasks, and either pm
finishes or parks them before W5, or I record which piece crosses the window. No restore while a piece has
history unless the window fails.
## Open before draft 1
- Q1, ruled by mos-1161: draft 1 doesn't wait for 194. NEW is the latest code landing when I cut draft 1. If
194 has landed with a green full gate (15 packages, ten script suites), NEW is the 194 landing and P3 also runs
the lifecycle verbs once on the backup copy and gets one pm reply under the new roles/chief-of-staff files. A
P3 failure that traces to 194 drops NEW back to c771a36f4 before anything touches live, and the window goes on.
Otherwise NEW is c771a36f4 and 194 rides a later window.
- Q2, settled by mos-1148: phase 2 is its own window on 10-23. Draft 1 takes OLD and the Console flags,
`--sso` included, from `status` after that window.
- Q3: who operates. If Darkwing is free, Darkwing operates and I check. Otherwise I operate as in the last
window.
- The P-step evidence list for rows 199 and 210 at bus start. Both have landed (cb5fdb064, f5753c0e8), so
it's due in draft 1. I take each log line and exit code from a P3 rehearsal run, not from the code.
- Rows 241 and 242 (bus): P3 shows no publish or land decision open before W5.
@@ -0,0 +1,125 @@
# Re-pin 490e2771d to 4429a104f (row 192, Q175 phase 1), with npm ci in the new copy
Sage, 2026-10-20, draft 1, for mos-1099 item 3 under DECISIONS Q173. Mos checks this file at its commit before
P0. Any change to this file or to NEW after the check means Mos checks again (mos-0849/0850).
This is a smaller window than `repin-rows-177-185.md` (draft 2, which Mos checked in mos-0929). Where a step is
the same, this file names that file's step and says what differs. The 7332 matrix, the snapshot, the W9 list and
the rollback compare are reused unchanged.
## Who, and what nobody does
- Sage operates, as the tester. Darkwing holds the gate lane for row 190 round 3 tonight, so I take the
operator seat. Mos verifies each step's output before the next one and runs `rehearse` (P3), which reads the
live config and a live backup (`docs/TOOLS.md` `rehearse`: Jason or Mos runs it).
- Jason has no step. Q173 covers a re-pin of this test system with a backup first and a STATE report after.
Nothing is pruned.
- Nobody acts as Jason: no `--by jason`, no human shell or tmux pane, every bus read and write carries
`--tester`, and no request carries `jason.woltje`. `bus.tester` stays as it is; if it isn't `true`, stop at P1.
- No pi (mos-0865 D2, P19 #383). No Save in the human Console, no `mosaic name`.
- Outside reach doesn't change. The Console keeps its current flags. `--sso` stays off: row 192's sign-in code is
dormant without the flag, and `cli.mjs:57` refuses `--sso` with the preview flags the pointer carries anyway.
The proof pair stays unset. The tunnel still forwards 7332 only (P4 and W10 check it).
## Names
- OLD, live: `490e2771d3d057ab76991e2c7859cd251857e64c`, activation `20261020T192956Z-490e2771d3d0`, pointer
`pm: true`, schema 3i, Console `--port 7332 --public-host mosaic.woltje.com --auth-header X-authentik-username
--allow-user jason.woltje --tester-port 7333` (no `--send`, read from `stack-release.sh status` between 21:36Z and 21:39Z).
- NEW: `4429a104f6d65ccf165f6c83b5d1dce3147dfde0`, row 192's landing, 103 commits past OLD (`git rev-list --count 490e2771d..4429a104f`).
The 106 that `status` showed is OLD to HEAD 305857c8f, so it also counts the three record commits below.
The commits after it (5b8def2f1 plans, 176a6bf4b sessions, 305857c8f queue) are records and don't move NEW. If
another code row lands before W0 (row 190 is the likely one), NEW stays here unless Mos rules otherwise.
- REPO: the canonical checkout. `activate` runs REPO's `webui-service.sh`, so the four service scripts are held
at NEW for the window (P5).
- `P=/mnt/storage/scratch/sage-pin-de352dfaf` (`business-check.mjs`, `console-check.sh`, checked against
`agents/sage/work/window-plan/repin-de352dfaf/SCRIPTS.sha256`).
- `WIN=agents/sage/work/window-repin-4429a104f/`, where every transcript goes.
- `SNAP=$WIN/snapshot.mjs`, a copy of `agents/darkwing/work/window-rows-177-185/snapshot.mjs`, sha256
`639fd28a2674aaa03ce824e3394b4052f21cdef7d6fa49bae61a9927ed341e67` (mos-0899). I check the sha before S1, S2
and any D3.
## What NEW carries over OLD
The path-limited log over `packages`, `scripts`, `roles`, `contracts`, `adapters`, `skills` and the package files:
| Row | Commit | Live effect |
|---|---|---|
| 186 | 269134d8d | Tool only: the activate plan's backup line says what the run will do. |
| 187 | 9a71e4e80 | Tool: activate sets, carries and drops the proof pair. The pointer has no pair, so nothing changes. |
| 188 | 8fc0ee216 | Tool: proof file checks on a carried pair. No pair, no effect. |
| 163 | 8ecc7a239 | Tool: `rehearse`, which P3 uses, and `bus start --no-launch`. CLI `cli.mjs` and `launcher.mjs` gain the `--no-launch` path; the default start is unchanged. |
| 189 | ace27dc5e | Tool: `activate --no-send`. Live already runs without `--send`; the pointer carries that. |
| 192 | 4429a104f | Console `--sso` (off here), `scripts/mosaic-config.mjs` load becomes async, `webui-service.sh` renders `--sso` only when given, and a new dependency, oauth4webapi 3.8.8. `cli.mjs` imports `sso.mjs` at the top, so the Console won't start from a copy without that module. |
No schema step: `SCHEMA_VERSION` (`packages/bus/src/store.mjs:17`) is 3i at OLD and NEW, and no migration was
added. `--plan` must say `migration: none`. No roles, skills, contracts, adapters or plug-in kinds changed. So: no
harness set, no skill carry, no `pm-on`. One `activate --pm`.
## npm ci in the new copy
mos-1099 asks for npm ci in the new copy. `stack-copy.sh copy` already does it: it extracts the archive to a temp
dir, refuses without `package.json` and `package-lock.json` (:55), runs `npm ci --no-audit --no-fund` there and
writes no copy if it fails (:56). W0 checks the result: `node_modules/oauth4webapi/package.json` in the copy reads
3.8.8, and `node --input-type=module -e "await import('oauth4webapi')"` from the copy exits 0. The live copy is
163M; `/home` has 30G free (94% used), enough for one more.
## Backups
| Before | Backup | Taken by |
|---|---|---|
| W5 | the stopped store, `$DATA/backups/stack/<W5 activation id>/` with SHA256SUMS, DATA_ROOT, SCHEMA 3i, STAMP | `stack-release.sh` (row 185: every activate) |
| W5 | the rollback set (units, wrapper, pointer) | `stack-release.sh` |
| W9 | the business file, `cp -p` to `~/.config/mosaic-dev/businesses/mosaic-stack.json.pre-window-<stamp>` with its sha256, as evidence nothing wrote it | Sage, in W0b |
## Before the window (nothing stopped)
| # | Who | Step |
|---|-----|------|
| P0 | Mos | The `$P` sha check, as in the 177-185 plan P0. |
| P1 | Sage | The business file, read only, the same values as the 177-185 plan P1. I report its sha256 now rather than assume the 06:15Z value. `business-check.mjs` for OLD now and for NEW after W0: LOAD_OK both. |
| W0 | Sage | `cd $REPO && scripts/stack-copy.sh copy $NEW`, in a gap between gate-lane runs, since npm ci is heavy. Then the two npm checks above, and `diff <(git -C $REPO show $NEW:package-lock.json) $COPIES/$NEW/package-lock.json` empty. W0 writes only under `copies/` and changes nothing live, so it moves before P2 and P3, which need the copy. |
| P2 | Sage | `console-check.sh $NEW` against a stub Console of NEW on a scratch port, once without and once with `--tester-port` on a second scratch port. CONSOLE_PASS both. |
| P3 | Mos | `scripts/stack-release.sh rehearse $NEW mosaic-stack --backup 20261020T192956Z-490e2771d3d0 --port <scratch>`. It must pass every gate check but the PM's. It stops no unit and writes no pointer. Transcript in `$WIN/p3/`. Correction (mos-1107): `rehearse` starts NEW's Console with only `--port` and `--business`, not the pointer's flags. So Mos also starts NEW's Console by hand with the pointer's flags on two scratch ports and reads healthz and `/api/config` on both, then stops it (`$WIN/p3/flags-check.txt`, `flags-console.log`). |
| P4 | Sage | The tunnel read, as in the 177-185 plan P4. |
| P5 | Sage | `git -C $REPO diff --quiet $NEW HEAD -- scripts/stack-release.sh scripts/webui-service.sh scripts/bus-service.sh scripts/stack-copy.sh` and an empty `git status --porcelain` on the same four. Row 190 round 3 touches `webui-service.sh`; if it lands before W5, P5 fails and the window waits for Mos's ruling. |
| P6 | Sage | The 7332 matrix (a)-(g) on OLD, as in the 177-185 plan, saved as `$WIN/matrix-old.txt`. |
## The window
| # | Who | Step |
|---|-----|------|
| W0b | Sage | The business file copy. |
| W1 | Sage | Idle check (`mosaic agents --business mosaic-stack --tester`, `mosaic launches list`), the start stamp from `date -u`, then S1 `node $SNAP --store "$DATA/bus/bus.sqlite" --held`. |
| W2 | Sage | `stack-release.sh status mosaic-stack`: pointer on OLD, `pm: true`, 3i, last line ok, unit flags match. |
| W3 | Sage | `stack-release.sh activate $NEW mosaic-stack --pm --plan`. Must show from OLD to NEW, `migration: none`, the backup line, and the Console line with OLD's flags exactly: no `--send`, no `--sso`, no proof pair, `--tester-port 7333`. Any other difference stops here. |
| W4 | Sage | The unit reads, as in the 177-185 plan W4. |
| W5 | Sage, on Mos's go | `stack-release.sh activate $NEW mosaic-stack --pm`. Exit 0, `result ok`, every gate check including `pm_launched`. Record the activation id, the backup path and STAMP, and the backup SHA256SUMS sha256. Then `status`. |
| W9 | Sage, after Mos verifies W5 | The 177-185 plan's W9 list unchanged, the chief turn and the one `assistant` thread turn included (mos-0929 Q3), plus two lines: the webui journal since the W5 stamp (with `UTC`) has one `Proxy: proxy proof off` and no `Sign-in:` line, and `ls -l /proc/<Console pid>/cwd` names the NEW copy. |
| W10 | Sage | The browser check on 127.0.0.1:7333 and the matrix again as `$WIN/matrix-new.txt`, identical to `matrix-old.txt`. Then P4 again. Corrections after the window (mos-1135, mos-1137): the matrix line for a file the pin changes isn't "identical". Line (g) serves `index.html`, which row 192 grows by 121 bytes (6470 to 6591), so (g) is 200 non-json at 6575 bytes, not 6454. The two browser checks come from the 177-185 window, before row 192 and before a second thread project. "Only tester forms" now allows `#sign-out` while it is `hidden` and `/api/config` has no `sso: true`. "Chat lists the W9 thread" now reads every `.chat-projects section.panel` list (chat.js:93 renders one per project), not the first. |
| W11 | Sage | STATE report in `$WIN/STATE.md`, posted on P19 386 and P19 412 and on Gitea #1690. |
## Stop conditions and rollback
The same as the 177-185 plan. Two failures in one window: stop and report (mos-0823).
Rollback: `stack-release.sh rollback mosaic-stack --plan`, then the real run. The previous distinct copy in the
log is 490e2771d, both are 3i, so no `--restore` and no data loss. OLD takes the same Console flags. The R1 and
R1post compares are as in the 177-185 plan. A restore past W5 follows that plan's D3 steps, with Mos deciding.
Keep every copy. Prune nothing.
## Questions for the plan check
- Q1: NEW is row 192 alone, not bundled with row 190. Row 190 (several instances) changes `stack-release.sh`
and `webui-service.sh` and deserves its own window. I recommend running this one first.
- Q2: Sage operates instead of Darkwing, who is on the row 190 gate. OK?
- Q3: W0 before P2 and P3. It writes a copy only. OK?
## What Sage ran for this plan
Read only, 2026-10-20 between 21:36Z and 21:39Z (clock read 21:39:50Z at the commit): `stack-release.sh status mosaic-stack` (pointer, last five log
lines, unit flags match, 106 commits of drift), `git rev-list --count`, `SCHEMA_VERSION` at both ends, the
path-limited log, `git diff --stat` on the service scripts, CLI and config loader, `stack-copy.sh:23-56`,
`webui/src/cli.mjs:15-120`, `docs/TOOLS.md` on `rehearse`, `du -sh` of the live copy and `df -h /home`. Nothing
live was changed.
@@ -0,0 +1,3 @@
chief rbb708846b605 claude-code since 2026-10-20T19:29:59.053Z
pm rc36b17ef3788 claude-code since 2026-10-20T20:20:56.278Z
# exit 0
@@ -0,0 +1,256 @@
{
"settings loaded": {
"pass": true
},
"settings": {
"banner": "Tester Console. Changes are recorded as tester, never as the human.",
"h1": "Settings",
"forms": [
"sign-out",
"conv-form",
"routes-form",
"instance-add-form",
"instance-change-form",
"tester-name-form",
"tester-name-form",
"tester-name-form",
"tester-name-form",
"tester-name-form",
"tester-name-form"
],
"convFormHidden": true,
"panels": [
"Routing",
"Role instances",
"Seat names"
],
"instancesTable": true,
"nameForms": 6,
"routesForm": true,
"routesSubmitEnabled": true,
"forbidden": [],
"tokenLimitsPanel": false
},
"tester banner": {
"pass": true,
"detail": "Tester Console. Changes are recorded as tester, never as the human."
},
"instance, routes and name editors present": {
"pass": true
},
"only tester forms (conv-form is index.html:21, global and hidden)": {
"pass": false,
"detail": [
"sign-out",
"conv-form",
"routes-form",
"instance-add-form",
"instance-change-form",
"tester-name-form",
"tester-name-form",
"tester-name-form",
"tester-name-form",
"tester-name-form",
"tester-name-form"
]
},
"no launch settings, assistant, limit reset or send": {
"pass": true,
"detail": []
},
"no Token limits panel (plan W10 item 2, amended)": {
"pass": true
},
"board": {
"busViewHidden": true,
"banner": true,
"headings": [
"Waiting on you –",
"All sessions –"
],
"unavailable": false,
"length": 179,
"forms": [
"sign-out",
"conv-form"
]
},
"board reads": {
"pass": true,
"detail": {
"busViewHidden": true,
"banner": true,
"headings": [
"Waiting on you –",
"All sessions –"
],
"unavailable": false,
"length": 179,
"forms": [
"sign-out",
"conv-form"
]
}
},
"chat": {
"hash": "#/chat",
"banner": true,
"threads": [
{
"href": "#/chat/threads/d0ba7a66-aa3f-4918-9518-94132bce6b28",
"name": "w9",
"instance": "assistant"
}
],
"conversationOpen": false,
"forms": [
"sign-out",
"conv-form",
"chat-project-form"
]
},
"chat lists the W9 thread": {
"pass": false,
"detail": [
{
"href": "#/chat/threads/d0ba7a66-aa3f-4918-9518-94132bce6b28",
"name": "w9",
"instance": "assistant"
}
]
},
"W9 thread page not opened": {
"pass": true,
"detail": "#/chat"
},
"tester banner on chat": {
"pass": true
},
"requests": [
{
"method": "GET",
"url": "/api/config",
"refused": false
},
{
"method": "GET",
"url": "/api/config",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/messages",
"refused": false
},
{
"method": "GET",
"url": "/api/bus-status",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/messages",
"refused": false
},
{
"method": "GET",
"url": "/api/settings",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/usage",
"refused": false
},
{
"method": "GET",
"url": "/api/settings",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/usage",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/names",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/names",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/instances",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/instances",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/routes",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/routes",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/limits",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/limits",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/inbox",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/agents",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/messages",
"refused": false
},
{
"method": "GET",
"url": "/api/chat/projects",
"refused": false
},
{
"method": "GET",
"url": "/api/chat/threads",
"refused": false
},
{
"method": "GET",
"url": "/api/business",
"refused": false
}
],
"no write through the listener": {
"pass": true,
"detail": []
},
"no request to the W9 thread messages or open": {
"pass": true,
"detail": []
}
}
W10 browser: FAIL
# exit 1
@@ -0,0 +1,200 @@
{
"config": {
"sso": null
},
"forms": [
{
"id": "sign-out",
"hidden": true,
"action": "/auth/logout",
"method": "post"
},
{
"id": "conv-form",
"hidden": true,
"action": null,
"method": null
},
{
"id": "routes-form",
"hidden": false,
"action": null,
"method": null
},
{
"id": "instance-add-form",
"hidden": false,
"action": null,
"method": null
},
{
"id": "instance-change-form",
"hidden": false,
"action": null,
"method": null
},
{
"id": "tester-name-form",
"hidden": false,
"action": null,
"method": null
},
{
"id": "tester-name-form",
"hidden": false,
"action": null,
"method": null
},
{
"id": "tester-name-form",
"hidden": false,
"action": null,
"method": null
},
{
"id": "tester-name-form",
"hidden": false,
"action": null,
"method": null
},
{
"id": "tester-name-form",
"hidden": false,
"action": null,
"method": null
},
{
"id": "tester-name-form",
"hidden": false,
"action": null,
"method": null
}
],
"chat": {
"hash": "#/chat",
"open": false,
"projects": [
{
"project": "window-20261020-0801",
"threads": [
"#/chat/threads/d0ba7a66-aa3f-4918-9518-94132bce6b28"
]
},
{
"project": "window-20261020-2205",
"threads": [
"#/chat/threads/446201de-e8b5-4df3-9356-d0d5422cc899"
]
}
]
},
"requests": [
{
"method": "GET",
"url": "/api/config",
"refused": false
},
{
"method": "GET",
"url": "/api/config",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/messages",
"refused": false
},
{
"method": "GET",
"url": "/api/bus-status",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/messages",
"refused": false
},
{
"method": "GET",
"url": "/api/settings",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/usage",
"refused": false
},
{
"method": "GET",
"url": "/api/settings",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/usage",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/names",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/names",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/instances",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/instances",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/routes",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/routes",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/limits",
"refused": false
},
{
"method": "GET",
"url": "/api/bus/limits",
"refused": false
},
{
"method": "GET",
"url": "/api/config",
"refused": false
},
{
"method": "GET",
"url": "/api/chat/projects",
"refused": false
},
{
"method": "GET",
"url": "/api/chat/threads",
"refused": false
},
{
"method": "GET",
"url": "/api/business",
"refused": false
}
]
}
sign-out hidden true, sso null; W9 thread listed true; thread page open false; writes 0
# exit 0
@@ -0,0 +1,8 @@
a 200 ok=true
b 401 error="sign-in required"
c 401 error="sign-in required"
d 403 error="proxy Host refused"
e 403 error="tester listener required"
f 403 error="proxy header refused"
g 200 non-json 6575 bytes
# exit 0
@@ -0,0 +1,7 @@
# P4 2026-10-20T22:31:29Z
DropInPaths=
ExecStart={ path=/home/jwoltje/.local/bin/chisel ; argv[]=/home/jwoltje/.local/bin/chisel client --fingerprint ce+ISFvMAhuoCRDuc7riIf+hYz87TLHk/C9C/H8QsUg= --keepalive 25s --max-retry-interval 10s http://10.1.1.227:8080 R:0.0.0.0:7332:127.0.0.1:7332 ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }
# exit 0
# R: specs (1): R:0.0.0.0:7332:127.0.0.1:7332
P4 PASS: one R: spec R:0.0.0.0:7332:127.0.0.1:7332, DropInPaths empty
# exit 0
@@ -0,0 +1,6 @@
ActiveState=active
DropInPaths=
MainPID=2950923
ExecStart={ path=/home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0/scripts/mosaic ; argv[]=/home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0/scripts/mosaic bus start mosaic-stack --pm ; ignore_errors=no ; start_time=[Tue 2026-10-20 17:02:08 CDT] ; stop_time=[n/a] ; pid=2950923 ; code=(null) ; status=0/0 }
WorkingDirectory=/home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
# exit 0
@@ -0,0 +1,12 @@
● jarvis-curation-gate.service loaded failed failed Jarvis curation-gate liveness check (wake the agent if staged pulls sit uncurated)
● jarvis-retro-check.service loaded failed failed Jarvis weekly retrospective ARTIFACT check (did Sunday's tick produce a retro?)
● jarvis-retro.service loaded failed failed Jarvis weekly retrospective tick
● mosaic-install-drift.service loaded failed failed Assert the installed Mosaic binaries still match the committed source (drift, not failure)
● mosaic-standup-pm.service loaded failed failed Coordinator standup trigger (evening)
● [email protected] loaded failed failed Escalate failed Mosaic unit jarvis-curation-gate.service to the local agent seat
● [email protected] loaded failed failed Escalate failed Mosaic unit jarvis-retro-check.service to the local agent seat
● [email protected] loaded failed failed Escalate failed Mosaic unit jarvis-retro.service to the local agent seat
● [email protected] loaded failed failed Escalate failed Mosaic unit mosaic-install-drift.service to the local agent seat
● [email protected] loaded failed failed Escalate failed Mosaic unit mosaic-reconcile.service to the local agent seat
● setspark-tmp-guard.service loaded failed failed SetSpark: /tmp and scratch guard
# exit 0
@@ -0,0 +1,5 @@
ActiveState=active
SubState=running
ActiveEnterTimestamp=Tue 2026-10-13 08:40:19 CDT
MainPID=1317
# exit 0
@@ -0,0 +1,6 @@
ActiveState=active
DropInPaths=
MainPID=2950925
ExecStart={ path=/usr/bin/node ; argv[]=/usr/bin/node /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0/packages/webui/src/cli.mjs serve --checkout /mnt/storage/src/mosaic-stack --port 7332 --public-host mosaic.woltje.com --auth-header X-authentik-username --allow-user jason.woltje --tester-port 7333 ; ignore_errors=no ; start_time=[Tue 2026-10-20 17:02:08 CDT] ; stop_time=[n/a] ; pid=2950925 ; code=(null) ; status=0/0 }
WorkingDirectory=/home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
# exit 0
@@ -0,0 +1,2 @@
chief r097436c91816 claude-code since 2026-10-20T22:02:08.951Z
# exit 0
@@ -0,0 +1,7 @@
PASS markdown-preview.js is served and equals the copy's
PASS /api/config on loopback: send=false edit=false
PASS project.files through the Console: head=ae0b17c9586e51da7bc1b7e548c6acf913a55665 files=2 spec=true error=none
PASS project.file spec/north-star.md through the Console: commit=ae0b17c9586e51da7bc1b7e548c6acf913a55665 length=99 error=none
PASS loopback save refused: HTTP 403 error=read-only sent=false
CONSOLE_PASS
# exit 0
@@ -0,0 +1,19 @@
[
{
"business": "mosaic-stack",
"instance": "chief",
"run": "r097436c91816",
"launchedBy": "jason",
"harness": "claude-code",
"model": "claude-opus-5-5",
"provider": "anthropic",
"modelKey": "opus",
"pid": 2951160,
"startTime": "63491912",
"runnerPid": 2951161,
"runnerStartTime": "63491912",
"runDir": "/home/jwoltje/.mosaic-dev/launches/mosaic-stack/r097436c91816",
"startedAt": "2026-10-20T22:02:08.941Z",
"live": true
}
]
@@ -0,0 +1,2 @@
r097436c91816 mosaic-stack/chief claude-code claude-opus-5-5 by jason since 2026-10-20T22:02:08.941Z running
# exit 0
@@ -0,0 +1,2 @@
[]
# exit 0
@@ -0,0 +1,2 @@
created project window-20261020-2205 id 77e7b45f-8c34-49a3-b2fa-9cf565177c30
# exit 0
@@ -0,0 +1 @@
1792533965
@@ -0,0 +1,21 @@
pointer: /home/jwoltje/.mosaic-dev/state/stack-active.json
{"copy":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","business":"mosaic-stack","pm":true,"claude":"/home/jwoltje/.local/bin/claude","console":["--port","7332","--public-host","mosaic.woltje.com","--auth-header","X-authentik-username","--allow-user","jason.woltje","--tester-port","7333"],"schema":"3i","activation":"20261020T220206Z-4429a104f6d6","at":"2026-10-20T22:02:09Z"}
log (last five lines):
{"at":"2026-10-20T07:58:02Z","op":"activate","from":"89af38a83afa22a8a4f0d368a0a4030ad15a0efa","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":"20261020T075800Z-490e2771d3d0","activation":"20261020T075800Z-490e2771d3d0","result":"ok"}
{"at":"2026-10-20T19:29:56Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":null,"activation":"20261020T192956Z-490e2771d3d0","result":"started"}
{"at":"2026-10-20T19:30:00Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":"20261020T192956Z-490e2771d3d0","activation":"20261020T192956Z-490e2771d3d0","result":"ok"}
{"at":"2026-10-20T22:02:06Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","backup":null,"activation":"20261020T220206Z-4429a104f6d6","result":"started"}
{"at":"2026-10-20T22:02:09Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","backup":"20261020T220206Z-4429a104f6d6","activation":"20261020T220206Z-4429a104f6d6","result":"ok"}
check:
bus: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
Console: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
CLI: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
result: pass
bus status:
host: mosaic-stack, pid 2950923, running, since 2026-10-20T22:02:08.133Z, notifier off
socket: present
writer.lock: pid 2950981
boot: organization file loaded at 2026-10-20T22:02:08.125Z
unit flags: match the pointer
drift: 6 commit(s) on refactor since the pointer's copy
# exit 0
@@ -0,0 +1,4 @@
sent c5b43f04 to chief
chief (RESULT, re c5b43f04) c3e236d7:
Got the re-pin of 4429a104f6d65ccf165f6c83b5d1dce3147dfde0 from the tester; I took no action because a tester message authorizes nothing, and I launched and changed nothing.
# exit 0
@@ -0,0 +1,4 @@
sent b4b66ced to window-20261020-2205/w9
assistant (RESULT, re b4b66ced) cfa7ea99:
Assistant (run r497f1c817d4d) got the [mos test] message and is replying in one line as asked, without treating it as Jason's instruction or acting on it.
# exit 0
@@ -0,0 +1,32 @@
[
{
"seq": 570,
"id": "b4b66ced-d088-4b9c-a944-306f164028f9",
"at": "2026-10-20T22:05:58.283Z",
"business": "mosaic-stack",
"from_role": "tester",
"from_run": "tester-cli",
"to_role": "assistant",
"class": "REQUEST",
"in_reply_to": null,
"decision": null,
"corrects": null,
"body": "[mos test] reply with one line, then stop",
"to_name": null
},
{
"seq": 571,
"id": "cfa7ea99-7ccb-4225-80a6-9a903540e337",
"at": "2026-10-20T22:06:04.247Z",
"business": "mosaic-stack",
"from_role": "assistant",
"from_run": "r497f1c817d4d",
"to_role": "tester",
"class": "RESULT",
"in_reply_to": "b4b66ced-d088-4b9c-a944-306f164028f9",
"decision": null,
"corrects": null,
"body": "Assistant (run r497f1c817d4d) got the [mos test] message and is replying in one line as asked, without treating it as Jason's instruction or acting on it.",
"to_name": null
}
]
@@ -0,0 +1,2 @@
created window-20261020-2205/w9 id 446201de-e8b5-4df3-9356-d0d5422cc899 instance assistant
# exit 0
@@ -0,0 +1,3 @@
window-20261020-0801/w9 assistant d0ba7a66
window-20261020-2205/w9 assistant 446201de
# exit 0
@@ -0,0 +1 @@
446201de-e8b5-4df3-9356-d0d5422cc899
@@ -0,0 +1,165 @@
{
"total": {
"input": 62,
"output": 9623,
"cacheWrite": 186650,
"cacheRead": 404132,
"unknownTurns": 1
},
"byRun": [
{
"run": "r8fba7e598156",
"input": 0,
"output": 0,
"cacheWrite": 0,
"cacheRead": 0,
"unknownTurns": 1
},
{
"run": "r4a96e2142e4d",
"input": 2,
"output": 52,
"cacheWrite": 11750,
"cacheRead": 0,
"unknownTurns": 0
},
{
"run": "rc78c9700c8e9",
"input": 14,
"output": 1519,
"cacheWrite": 18187,
"cacheRead": 89559,
"unknownTurns": 0
},
{
"run": "r293f33bcbc63",
"input": 2,
"output": 44,
"cacheWrite": 18595,
"cacheRead": 0,
"unknownTurns": 0
},
{
"run": "r34520a581318",
"input": 6,
"output": 323,
"cacheWrite": 26349,
"cacheRead": 14307,
"unknownTurns": 0
},
{
"run": "r7e99838f9f93",
"input": 2,
"output": 44,
"cacheWrite": 9858,
"cacheRead": 0,
"unknownTurns": 0
},
{
"run": "rc964611b2092",
"input": 4,
"output": 120,
"cacheWrite": 26138,
"cacheRead": 23870,
"unknownTurns": 0
},
{
"run": "rbb708846b605",
"input": 12,
"output": 2089,
"cacheWrite": 17285,
"cacheRead": 73823,
"unknownTurns": 0
},
{
"run": "rc36b17ef3788",
"input": 16,
"output": 5221,
"cacheWrite": 36717,
"cacheRead": 202573,
"unknownTurns": 0
},
{
"run": "r097436c91816",
"input": 2,
"output": 131,
"cacheWrite": 11915,
"cacheRead": 0,
"unknownTurns": 0
},
{
"run": "r497f1c817d4d",
"input": 2,
"output": 80,
"cacheWrite": 9856,
"cacheRead": 0,
"unknownTurns": 0
}
],
"byInstance": [
{
"instance": "chief",
"input": 36,
"output": 4114,
"cacheWrite": 85486,
"cacheRead": 177689,
"unknownTurns": 1
},
{
"instance": "coder",
"input": 2,
"output": 44,
"cacheWrite": 18595,
"cacheRead": 0,
"unknownTurns": 0
},
{
"instance": "assistant",
"input": 4,
"output": 124,
"cacheWrite": 19714,
"cacheRead": 0,
"unknownTurns": 0
},
{
"instance": "pm",
"input": 20,
"output": 5341,
"cacheWrite": 62855,
"cacheRead": 226443,
"unknownTurns": 0
}
],
"byProvider": [
{
"provider": "anthropic",
"input": 62,
"output": 9623,
"cacheWrite": 186650,
"cacheRead": 404132,
"unknownTurns": 1
}
],
"byModel": [
{
"provider": "anthropic",
"model": "claude-opus-5-5",
"input": 62,
"output": 9623,
"cacheWrite": 186650,
"cacheRead": 404132,
"unknownTurns": 1
}
],
"byAccount": [
{
"account": null,
"input": 62,
"output": 9623,
"cacheWrite": 186650,
"cacheRead": 404132,
"unknownTurns": 1
}
]
}
# exit 0
@@ -0,0 +1,2 @@
r097436c91816 mosaic-stack/chief claude-code claude-opus-5-5 by jason since 2026-10-20T22:02:08.941Z running
# exit 0
@@ -0,0 +1,19 @@
[
{
"business": "mosaic-stack",
"instance": "chief",
"run": "r097436c91816",
"launchedBy": "jason",
"harness": "claude-code",
"model": "claude-opus-5-5",
"provider": "anthropic",
"modelKey": "opus",
"pid": 2951160,
"startTime": "63491912",
"runnerPid": 2951161,
"runnerStartTime": "63491912",
"runDir": "/home/jwoltje/.mosaic-dev/launches/mosaic-stack/r097436c91816",
"startedAt": "2026-10-20T22:02:08.941Z",
"live": true
}
]
@@ -0,0 +1,153 @@
{
"schema": "3i",
"tables": {
"decision_events": {
"rows": 21,
"maxSeq": 21,
"sha256": "28dd95056663dd5eb7442a3d815b73a4a3cd311a0e854fd82dc54f7dc3e6ef26"
},
"decisions": {
"rows": 21,
"maxSeq": 21,
"sha256": "fef1058660b0d128ef21089dda5e0fba7f71763d8325148fe521de0d3f57e4ad"
},
"deliveries": {
"rows": 1726,
"maxSeq": 1726,
"sha256": "8667571f4a05ce3621b04cf2dc5eb0311bac3d3aff1858584c78445c5c4c2fb4"
},
"events": {
"rows": 1525,
"maxSeq": 1525,
"sha256": "e51827e31f2fa5a9a50993e5a10f37f1dee383b7d673f793006bd2ba1e9a5573"
},
"instance_names": {
"rows": 0,
"maxSeq": null,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"message_threads": {
"rows": 2,
"maxSeq": null
},
"messages": {
"rows": 567,
"maxSeq": 567
},
"meta": {
"rows": 3,
"maxSeq": null,
"sha256": "89fec56e7293d19e0b2f6cd8c0ef745ebf99e4115abeaea7fbdbc98a261332d9"
},
"projects": {
"rows": 1,
"maxSeq": 1,
"sha256": "a3945f7462895e0e302029c542d384a6d33e559da900e6461d78bbcd7cc28b16"
},
"role_claims": {
"rows": 98,
"maxSeq": 98,
"sha256": "ac6694fd0b365469f7486ae1f31486cd52b31be7bc0399938b7361f22454362e"
},
"task_comment_cursors": {
"rows": 215,
"maxSeq": 215,
"sha256": "7f80df8efb90b502a9530a7ed5068d59be59b2eb318aa4e64ce6bf481762a5e9"
},
"task_snapshots": {
"rows": 401,
"maxSeq": 401,
"sha256": "cced8c7f90ecaa60eee3010d987013d8c0301563cb888860fd91b8c5a02f0a00"
},
"thread_claims": {
"rows": 2,
"maxSeq": 2,
"sha256": "6046c7088779aa2f6e9b3fd2c7520404b1678a4fcae71c2b9157a008bba22a5f"
},
"thread_opens": {
"rows": 0,
"maxSeq": null,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"threads": {
"rows": 1,
"maxSeq": 1,
"sha256": "3043721efd386f23dfdaad4acf41e0d553ff6182f0068c2133a615141bcf772a"
},
"usage_records": {
"rows": 30,
"maxSeq": 30,
"sha256": "7ecbc0606273043b017d778bb23880377bfe367f788d234289b7c132caa588f2",
"byRun": [
{
"business": "mosaic-stack",
"run": "r8fba7e598156",
"input": "0",
"output": "0",
"unknownTurns": 1
},
{
"business": "mosaic-stack",
"run": "r4a96e2142e4d",
"input": "2",
"output": "52",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "rc78c9700c8e9",
"input": "14",
"output": "1519",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "r293f33bcbc63",
"input": "2",
"output": "44",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "r34520a581318",
"input": "6",
"output": "323",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "r7e99838f9f93",
"input": "2",
"output": "44",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "rc964611b2092",
"input": "4",
"output": "120",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "rbb708846b605",
"input": "12",
"output": "2089",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "rc36b17ef3788",
"input": "16",
"output": "5221",
"unknownTurns": 0
}
]
}
},
"untimed": [
"message_threads",
"meta",
"task_comment_cursors"
]
}
@@ -0,0 +1,167 @@
{
"schema": "3i",
"tables": {
"decision_events": {
"rows": 21,
"maxSeq": 21,
"sha256": "28dd95056663dd5eb7442a3d815b73a4a3cd311a0e854fd82dc54f7dc3e6ef26"
},
"decisions": {
"rows": 21,
"maxSeq": 21,
"sha256": "fef1058660b0d128ef21089dda5e0fba7f71763d8325148fe521de0d3f57e4ad"
},
"deliveries": {
"rows": 1740,
"maxSeq": 1740,
"sha256": "554ce5b377ffacc72bfde11c99c920cd5d30337c8bfa217275d0073b587e2169"
},
"events": {
"rows": 1543,
"maxSeq": 1543,
"sha256": "0151673193d0286ce8f3b234d7d21fa2b0f43f2ceec55644cccaa5cbd77603dd"
},
"instance_names": {
"rows": 0,
"maxSeq": null,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"message_threads": {
"rows": 4,
"maxSeq": null
},
"messages": {
"rows": 571,
"maxSeq": 571
},
"meta": {
"rows": 3,
"maxSeq": null,
"sha256": "89fec56e7293d19e0b2f6cd8c0ef745ebf99e4115abeaea7fbdbc98a261332d9"
},
"projects": {
"rows": 2,
"maxSeq": 2,
"sha256": "3435f574ab94bc2fcc65fb8605251f30d4e9e63baf38e6fdf3c39c126531aa63"
},
"role_claims": {
"rows": 101,
"maxSeq": 101,
"sha256": "fb47114defbb8a57dd148a06fbaf61b30f791a81f14b1510b8c32690506b4308"
},
"task_comment_cursors": {
"rows": 220,
"maxSeq": 220,
"sha256": "df03c84094999299499de01107ddd08636df6156f5c9ccd2ff6e8c43e459f7e4"
},
"task_snapshots": {
"rows": 408,
"maxSeq": 408,
"sha256": "e2243a89388afba07e8d096b4961deeb2d17c656cdcace54b46eac7001393814"
},
"thread_claims": {
"rows": 4,
"maxSeq": 4,
"sha256": "088b8236570d8afe03364b85803fdc5034cce2cef6ed7b16f8b99bf6366524ad"
},
"thread_opens": {
"rows": 0,
"maxSeq": null,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"threads": {
"rows": 2,
"maxSeq": 2,
"sha256": "819fd24c64e279d0debad5ab5dbbe533d8e6f26de58277c69510b9cbbaadbc3a"
},
"usage_records": {
"rows": 34,
"maxSeq": 34,
"sha256": "de614f3d62c9b3819757c5659bee40085cb86cc873b7bfe381b13e254ddb7cad",
"byRun": [
{
"business": "mosaic-stack",
"run": "r8fba7e598156",
"input": "0",
"output": "0",
"unknownTurns": 1
},
{
"business": "mosaic-stack",
"run": "r4a96e2142e4d",
"input": "2",
"output": "52",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "rc78c9700c8e9",
"input": "14",
"output": "1519",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "r293f33bcbc63",
"input": "2",
"output": "44",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "r34520a581318",
"input": "6",
"output": "323",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "r7e99838f9f93",
"input": "2",
"output": "44",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "rc964611b2092",
"input": "4",
"output": "120",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "rbb708846b605",
"input": "12",
"output": "2089",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "rc36b17ef3788",
"input": "16",
"output": "5221",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "r097436c91816",
"input": "2",
"output": "131",
"unknownTurns": 0
},
{
"business": "mosaic-stack",
"run": "r497f1c817d4d",
"input": "2",
"output": "80",
"unknownTurns": 0
}
]
}
},
"untimed": [
"message_threads",
"meta",
"task_comment_cursors"
]
}
@@ -0,0 +1,193 @@
# Re-pin window 490e2771d to 4429a104f (row 192, Q175 phase 1), STATE report (W11)
Operator and tester: Sage. Plan: `agents/sage/work/window-plan/repin-4429a104f.md` at f41729178, sha256
e1272d1a391e00fb1291d47da9973c0ba342d65fcd42f583ea007e883c1f32b9 (checked before W0b, W9). The plan file in
this commit carries three corrections made after the window. They are listed at the end, so its sha256 differs
now. Tool blob 3b068b1546d4 (`scripts/stack-release.sh`). OLD 490e2771d3d057ab76991e2c7859cd251857e64c, NEW
4429a104f6d65ccf165f6c83b5d1dce3147dfde0. Every stamp below comes from `date -u` or from the transcript `W.txt`
in this directory. Mos's gos: mos-1113 (W5), mos-1114 (W9 to W11), mos-1128 (W9b accepted), mos-1135 (W10
browser part), mos-1137 (line g, post W11).
## Outcome
NEW is live for mosaic-stack with its PM, and the store stays on 3i. Row 192's sign-in code is in the copy but
dormant, because the Console runs without `--sso`. W0 to W10 passed, and nothing was rolled back. W10 stopped
once on two failed browser checks. I diagnosed both read-only as checks written before row 192, not faults in
NEW, and Mos passed them (mos-1135). Matrix line (g) isn't byte-identical, for a known cause, and Mos passed it
(mos-1137). Nobody acted as Jason. There was no `--by jason`, no pi, no Save and no `mosaic name`, and
`bus.tester` was not touched.
## Stamps and activation
| Step | Start (UTC) | Result |
| --- | --- | --- |
| P1 values, business-check OLD | file written 21:43:13Z | values as planned, LOAD_OK (`p1.txt`) |
| W0 copy NEW with npm ci, business-check NEW | 21:45:57Z | copied, lockfile diff empty, LOAD_OK (`w0.txt`) |
| P2 console-check on a stub of NEW | 21:46:09Z | CONSOLE_PASS without and with `--tester-port` (`p2.txt`) |
| P3 rehearsal (Mos) | 21:52:31Z to 21:52:34Z | REHEARSAL_PASS, rc 0 (`p3/`) |
| P3 flags check (Mos) | 21:53:52Z to 21:53:56Z | NEW Console with the pointer flags on 7341/7342: healthz 200 both, then gone, 0 listeners |
| P4, P5, P6 | 22:00:17Z | P4 PASS, P5 PASS (HEAD 4385b9ec1), `matrix-old.txt` |
| W0b business file copy | 22:00:49Z | sha256 1e49bdf0 |
| W1 idle check, S1 | 22:00:49.758Z | chief idle; S1 a7649760 |
| W2 status | 22:00:49.819Z | pointer OLD, pm true, 3i, unit flags match |
| W3 plan | 22:00:50.068Z | exit 0 (`W3-plan.txt` 45de68d4) |
| W4 unit read | 22:00:50.238Z | OLD units, no drop-ins, bus `--pm`, no quote or backslash |
| W5 activate (mos-1113) | 22:02:06.776Z, end 22:02:09.556Z | exit 0, gate passed, activate ok |
| W5 status | 22:02:13.885Z | pointer NEW, pm true, 3i, unit flags match |
| W9 checks and turns (mos-1114) | 22:05:51Z to 22:06:06Z | pass |
| W9b run end and S2 | 22:21:05.171Z, S2 22:21:07Z | ended by the launcher (thread-idle); S2 1a444f5a |
| W10 browser (stop), diagnosis | 22:29:14Z, 22:30:02Z | 10 of 12, then both failures traced to the checks |
| W10 matrix, P4 (mos-1135) | 22:31:29Z | a-f identical, g +121 bytes; P4 PASS |
| W11 reads | 22:44:33Z to 22:44:34Z | units, tunnel, business file, failed units |
Activation id 20261020T220206Z-4429a104f6d6. Pointer `at` 2026-10-20T22:02:09Z.
## Backups and hashes
- Store backup: `~/.mosaic-dev/backups/stack/20261020T220206Z-4429a104f6d6`, SHA256SUMS sha256
b61d64843c9e2a153842606353450838da9cb778c8f3b9c27cc9366dd72ec0c9.
- Rollback set: `~/.mosaic-dev/state/stack-rollback/20261020T220206Z-4429a104f6d6`. Rollback goes to 490e2771d.
- Business file: 1e49bdf05e62607972958f5b7298cfd600c7825812370e26d7c2a842f2af9b93 at P1, at W0b and again at
W11. The W0b copy is `~/.config/mosaic-dev/businesses/mosaic-stack.json.pre-window-20261020T220049Z` with the
same hash. Nothing in the window wrote it. `backups.txt` holds the W0b and W5 lines.
## Pointer before and after
Before (W2): copy 490e2771d3d0, pm true, schema 3i, activation 20261020T192956Z-490e2771d3d0. Console flags
`--port 7332 --public-host mosaic.woltje.com --auth-header X-authentik-username --allow-user jason.woltje
--tester-port 7333`.
After (W5, W9 and W11): copy 4429a104f6d6, pm true, schema 3i, activation 20261020T220206Z-4429a104f6d6. The
Console flags are the same, with no `--send`, no `--sso` and no proof pair. Bus pid 2950923 and Console pid
2950925, both since 22:02:08Z. Status shows drift of 6 commits on refactor since the pointer's copy (records).
## Unit ExecStart lines (no Environment values)
W11, after. Both units active, DropInPaths empty, WorkingDirectory the NEW copy:
```
bus: ~/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0/scripts/mosaic bus start mosaic-stack --pm (MainPID 2950923)
webui: /usr/bin/node ~/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0/packages/webui/src/cli.mjs serve --checkout /mnt/storage/src/mosaic-stack --port 7332 --public-host mosaic.woltje.com --auth-header X-authentik-username --allow-user jason.woltje --tester-port 7333 (MainPID 2950925)
```
W4 read the same two lines on OLD's copy (W.txt 329-344). The bus Environment has 2 assignments with no quote or
backslash. The values are not recorded. The Console's cwd is the NEW copy (W9). The webui journal since W5 has one
"Proxy: proxy proof off" line and no "Sign-in:" line.
Tunnel: `mosaic-preview-tunnel.service` active, running, MainPID 1317, since 2026-10-13 08:40:19 CDT. The window
didn't touch it. P4 before and after shows one R: spec, `R:0.0.0.0:7332:127.0.0.1:7332`.
## S1, S2 and the store
S1 (`S1.json`, sha256 a7649760c3439705c8b5f3af26d0c21aea1081c1eb295fd02aef3a642064194b) is W1's held read at
22:00:49Z. S2 (`agents/sage/work/window-repin-4429a104f/S2.json`, sha256
1a444f5ac0049bc78e397b6928e1008cfe8cbc30989bbf2042b4ed27f56a4173) is the held read after the assistant run
ended, at 22:21:07Z. Both are 3i. SNAP (`snapshot.mjs`, 639fd28a) was checked before each.
| Table | S1 rows | S2 rows | S2 vs S1 |
| --- | --- | --- | --- |
| decisions, decision_events | 21, 21 | 21, 21 | same sha256 |
| meta, instance_names, thread_opens | 3, 0, 0 | 3, 0, 0 | same sha256 |
| deliveries | 1726 | 1740 | +14 |
| events | 1525 | 1543 | +18 |
| role_claims | 98 | 101 | +3 |
| messages | 567 | 571 | +4 (counts only) |
| message_threads | 2 | 4 | +2 (counts only) |
| usage_records | 30 | 34 | +4 |
| projects, threads, thread_claims | 1, 1, 2 | 2, 2, 4 | +1, +1, +2 |
| task_snapshots | 401 | 408 | +7 |
| task_comment_cursors | 215 | 220 | +5 |
For every grown table with a seq, maxSeq grew by exactly the row delta.
The task_snapshots and task_comment_cursors rows (mos-1128 item 2). The writer is the bus's own Vikunja poll, not
an agent. `packages/bus/src/process.mjs:104` loads the tasks adapter. Its timer runs `tick` every `pollSeconds`
(`packages/tasks/src/adapter.mjs:192-197`), and `sync.mjs:169` calls `broker.recordTask` with no cap
(`packages/bus/src/broker.mjs:1123`). That writes `source 'poll'` with role and run null (`broker.mjs:1159-1174`)
and the comment cursors (`:1131-1138`). The adapter logs only on an error (`adapter.mjs:115`), so the bus journal
has no line for a good tick. That's why there's no sync line between 22:05Z and 22:22Z. The rows, read
read-only after the window:
- task_snapshots 402-404 at 22:05:08Z: P19 436, 435 and 434, all updated 22:04:55-56Z. 405-406 at 22:12:38Z: 438
and 437. 407 at 22:17:08Z: 438 again. 408 at 22:19:08Z: 431. All have `source poll`, `via cursor`, role null
and run null. These are tracker edits made by seats during the window, which the poll picked up.
- task_comment_cursors 216-220: tasks 424, 432, 438, 431 and 436, last comments 397 to 401. These are the
comments seats posted on P19 in the same minutes (398 is Mos's comment on #432).
Rows after S2 (task_snapshots 409 at 22:33Z, cursors 221-223) are the same poll and are outside the window.
## W9 output
- Status, console-check (CONSOLE_PASS), the journal counts, the Console cwd and one running chief from this
start (r097436c91816) all passed.
- `talk chief --tester` (c5b43f04): the chief replied that a tester message authorizes nothing and it took no
action.
- Project `window-20261020-2205`, thread 446201de-e8b5-4df3-9356-d0d5422cc899. `talk --thread` (b4b66ced) got one
assistant reply from run r497f1c817d4d. `thread read` shows the tester REQUEST and the assistant RESULT.
- `threads`, `names` (empty) and `usage` read fine. The human-thread probe was skipped: S1's one thread is the
177-185 window's tester thread d0ba7a66, so no human thread exists.
- W9b: the launcher logged launch at 22:06:00.766Z, thread-idle at 22:21:05.149Z and end at 22:21:05.171Z
(reason stopped, exit 0). Nobody stopped it by hand.
## W10
The browser check (`w10-browser.mjs`, dda57ccf) ran against 127.0.0.1:7333 with fetch and XHR limited to GET and
HEAD. It made no writes and sent no W9 thread request. 10 of 12 checks passed. The two that failed:
1. "Only tester forms": `#sign-out` was in the list. It is row 192's form at `packages/webui/src/public/index.html:12`,
`hidden`. `app.js:479` shows it only when `/api/config` has `sso: true`. The live config has no `sso`
key, and the form stayed hidden.
2. "Chat lists the W9 thread": the check read only the first `ul.chat-thread-list`. `chat.js:93` renders one list
per project, and W9 made the second project. The diagnosis (`w10-diag.mjs`, 42d0844b) read every panel and
found 446201de listed.
Both checks come from the 177-185 window (Darkwing's `window-rows-177-185/w10-browser.mjs`), written before row
192 and before a second thread project existed. Corrected, they now read:
- Only tester forms, allowing `#sign-out` when it is `hidden` and `/api/config` has no `sso: true`.
- The W9 thread is in some `.chat-projects section.panel` thread list, not only the first.
Both corrections are in the plan's W10 line, for the next window that copies this one.
The matrix (`matrix-new.txt` against `matrix-old.txt`). Lines a to f are byte-identical. Line g is 200 non-json
both times, but 6454 bytes on OLD and 6575 on NEW. `index.html` is 6470 bytes at 490e2771d and 6591 at 4429a104f,
+121, and the only hunk is the line 12 form. The served page grew by the same 121. The plan's W10 line now says a
line for a file the pin changes isn't "identical", with this case as its example. P4 again at 22:31:29Z: PASS.
## Failed user units (mos-1128 item 3)
All three predate the re-pin at 22:02Z and come from the legacy `~/.mosaic` and jarvis-brain setup, not the
stack. P19 #393, "dragon-lin: seven legacy user units in failed state", covers all three (filed by Mos on
10-20 at about 01:42Z). It has no comments yet. The current failure times are later than the ones #393 lists,
so I added a comment with them. Times are from `systemctl --user show` (CDT) with UTC alongside.
| Unit | Runs | Failed | #393 |
| --- | --- | --- | --- |
| mosaic-standup-pm.service | `~/.mosaic/tools/coordinator/standup_trigger.py pm` | 2026-10-19 19:00:28 CDT (10-20 00:00:28Z), exit 1 | listed |
| mosaic-install-drift.service | `~/src/jarvis-brain/scripts/alerting/mosaic-install-drift` | 2026-10-20 00:12:54 CDT (05:12:54Z), exit 9 | listed, with an older time |
| mosaic-unit-failed@mosaic-reconcile.service | `~/.local/bin/mosaic-unit-failed mosaic-reconcile.service` | 2026-10-20 13:16:12 CDT (18:16:12Z), exit 4 | listed, with an older time |
The marker's journal at 18:16:12Z says the escalation's tmux send to jarvis failed ("tmux target not found",
socket mosaic-fleet), so the page wasn't delivered. The record is on disk in
`~/.local/state/mosaic-unit-failures/20261020.jsonl`. `mosaic-reconcile.service` failed at that time (exit 7)
but last ran OK at 17:16:27 CDT (22:16:27Z). The marker stays failed until something resets it. The journal no
longer has lines for the other two units. The window changed none of these units, and touching them is
outside it (`~/.mosaic` is off limits, and #393 needs Jason or ops-01's charter to disable any). W11's list also
shows `setspark-tmp-guard.service` failed. It belongs to another project, and I've left it.
## Corrections to the plan, made after the window
- NEW is 103 commits past OLD (`git rev-list --count 490e2771d..4429a104f`), not 106. The 106 in `status` was OLD
to HEAD 305857c8f, so it also counted the three record commits.
- P3: `rehearse` starts NEW's Console with only `--port` and `--business`, not the pointer's flags. Mos's
separate flags check covers that (`p3/flags-check.txt`, `p3/flags-console.log`). The line was wrong as
written (mos-1107).
- W10: the matrix line g size change and the two corrected browser checks, as above.
## Files
`W.txt` (transcript), `S1.json`, `S2.json`, `matrix-old.txt`, `matrix-new.txt`, `backups.txt`, `W3-plan.txt`,
`W5.txt`, `W5-status.txt`, `w0.txt`, `p1.txt`, `p2.txt`, `p3/` (Mos's rehearsal and flags check), `p456.sh`,
`w9.sh`, `w9b.sh`, `w9.out`, `w9b.out`, `w10-browser.mjs`, `w10-diag.mjs` with their outputs (the three
screenshots stay on disk in this directory, uncommitted, since the push check refuses binaries),
and the `.w9*`, `.w10*` and `.w11*` reads. The store snapshot tool is `snapshot.mjs` (639fd28a).
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,116 @@
plan: activate for mosaic-stack
from: 490e2771d3d057ab76991e2c7859cd251857e64c
to: 4429a104f6d65ccf165f6c83b5d1dce3147dfde0
store schema: 3i (read from the store the running bus holds)
target schema: 3i
migration: none; the store is at the target's version
backup: the run backs up the stopped store to /home/jwoltje/.mosaic-dev/backups/stack/<activation>/ before any migration
commits: the run adds 103 (newest first, at most 200):
4429a104f webui: Q175 phase 1, the Console verifies SSO itself under --sso, OIDC RP with oauth4webapi 3.8.8 (row 192, #1690)
32b54ac3b plans: Case 2 row briefs from Gosalyn's 51006710a (webby-0034 N1/N2 and webby-0035: row A README :711 one line per variable, C2-13 owns README :713 and says the precheck prints only variable names)
6025ddd9c plans: Case 2 row briefs from Gosalyn's a2d94b2c2 (Webby r9 notes, webby-0032: row A README line on claude-session.env and the dist/providers/data/zai.json path, C2-13 XDG_DATA_HOME precheck and last-assignment-wins key file parse)
ecbfd6deb plans: Case 2 row briefs from Gosalyn's 29e85196b (design r9 for mos-1090: row A out of scope points at design 10 step 2d, C2-13 precheck checks the hub key file by name and mode, claude marker check only for claude-code harness, EnvironmentFile= fixed in case2-unit.sh)
0498c0613 agents: Gosalyn seat dir, README from gosalyn/case2-design, so Case 2 rows can name the seat as owner (#416)
49d1f3a23 plans: Case 2 slice row briefs plus rows A and B, from Gosalyn's f325657c7 on gosalyn/case2-design (#416, design r8, C2-13 copy id and profile asks for row 195, C2-20 count-only note, row A ZAI_API_KEY per mos-1086/mos-1089, row B #428 after 10-27)
628e2ca5c sessions: Filbert row 190 manifest round 1 CHANGES (base drift on row 189)
088087ac9 queue: revs 2248-2361+. Sage: rows 210 (#424 host access, Gizmoduck) and 211 (#426 force-push guard, Rocko) added and briefed; row 195 re-briefed to the Case 1 install on .53; review requests for 200 r1/r2 and 202; plus earlier Sage revs. Other seats' revs: Launchpad 2254, 2283-2284, 2286, 2311-2313, 2328-2329, 2344, 2347-2348 (rows 192/200); Honker 2255; Morgana 2257, 2320, 2326-2327 (row 204); Gander 2262; Herb 2265; Dewey 2268; Bushroot 2273; Megavolt 2276; Webby 2303, 2316, 2339, 2355 (row 192/200 verdicts); Gizmoduck 2304, 2318-2319 and the row 210 claim (rows 201/210); Quackerjack 2317, 2341-2342 (row 202); Duckworth 2323, 2335, 2353 (rows 201/204/202 verdicts); Darkwing 2354, 2357 (row 190 in-review)
a0fbefd48 plans: pre-push force-push hook and failing busy check brief (Vikunja #426, mos-1078, mos-1083; owner Rocko, reviewer Dewey)
f24f2cd40 plans: row 195 brief becomes the Case 1 install on 10.1.1.53 (Q181 hosts, Q182/Q183 recorded acceptance, Q185 existing account, chained on rows 190 and C2-13; ops-01 root steps under #423 step 1; sage-1439)
9ba31b5b3 agents: Honker's PRD 1.7 draft r2 on refactor for the prd.approve raise (11cb0423b, sha256 e896746df595d1b5155130ca163fbf63b0dc38a87a8dd154af77f1a6d1328647, Duckworth approved duckworth-0039, Vikunja #420)
9db578ff5 plans: agent host access with sudo through the credential store brief (#1707, Vikunja #424, Q183/Q184, chained on row 199)
8eb9ddd12 plans: FED R8 brief to r4.11 (b93f098f0, recorded acceptance, Duckworth N1/N2, #416 date), R5 context source (sage-1419), 193a makes covers level required (duckworth-0033 N1)
ae3c36f24 agents: Gizmoduck seat dir and roster row (FED R1 owner, #414)
a2af7ab49 plans: FED R1, R5, R2 and R8 briefs (#414, r4.9 a1daa3c07) and row 193a-d build briefs (#1691); Quackerjack seat dir and roster row
aa1bd209e gate-lane: row 189 gate timing table for row 197 (Herb)
ace27dc5e row 189 (#1687): stack-release.sh activate --no-send drops the Console's --send (Sage; Dewey approve 29485; Sage gate passed)
a4a42dbac plans: stack-release --sso brief (#1698, Launchpad, reviewer Webby, row 192 F3; chained on 192)
a31f1a0f8 plans: row 198 brief records Mos's call, in-process board reads behind the Console's auth (mos-1019)
992650232 plans: agent credentials and git identity brief (MVP T1 item 6, #1697, Megavolt, reviewer Duckworth); Megavolt seat dir
a7661ada0 plans: row 195 acceptance script takes Gizmoduck's gate item list; B8 issuer and TLS front end (mos-1011)
f871cb309 plans: retire the control board writer on 7331 brief (#1696, Bushroot, reviewer Webby, mos-1008 item 2); Bushroot seat dir
0bbd23c61 plans: row 195 brief adds the identity provider check and flags any new model account for Jason (mos-1008)
0217b71c8 plans: row 195 brief rewritten as the hub on its own host, host left to Jason (#1693, mos-0998, mos-1005)
c6f528a32 plans: second gate lane brief (#1695, Herb, reviewer Webby, mos-0977 item 6); Herb seat dir; gate template and busy check copies for the row
96f243faf plans: human boundary row brief (P19 333, #1694, Gander, reviewer Duckworth, mos-0989); Gander seat dir; seat READMEs name the credentials row, not row 195 (now 190b)
c86269935 sessions: Filbert row 190 design review r3, approved
48ebee0ff plans: row 190 brief scopes isolation to addressing on one uid, points to row 190b (Filbert N7, mos-0986)
66bdd84b8 sessions: Filbert row 190 design review r2, changes requested
6966164f0 plans: row 190b brief, Melanie's hub under its own Linux user (#1693, Darkwing, reviewer Duckworth), mos-0986
53bb59462 sage: common start for MVP T1 builder seats (mos-0974, mos-0977, mos-0981)
5e01c3683 agents: seat dirs and roster rows for Launchpad, Morgana, Honker (Sage's T1 builders) and Webby, Duckworth (Mos's reviewers); row 193 brief takes Mos's B1 starting rule (mos-0985)
9f56d2098 plans: rows 192 and 194 review by Webby, not Filbert or Dewey (mos-0981)
0599b119f plans: MVP T1 briefs, SSO phase 1 (#1690, Launchpad), human users (#1691, Morgana), agent lifecycle (#1692, Honker), mos-0974/0977
b416d2ede sage: Q175 SSO design, Mos C1 condition (mos-0977): straggler check before Jason step 4 in the phase 2 window
5fc73a729 revs 2237-2247: row 189 added, briefed, started, in-review round 1 (Dewey, comment 29457); rows 190 (Darkwing) and 191 (Rocko) added and briefed for MVP T1 (mos-0974); darkwing-190-start (rev 2245, Darkwing's) and rocko-191-start (rev 2244, made for Rocko)
99772421a sage: row 189 candidate manifest for Dewey's review (d6116771f)
da230ca48 plans: MVP T1 briefs, several instances per host (#1688, Darkwing) and the kill switch (#1689, Rocko), mos-0974
4f2d1be68 sessions: Filbert Q175 SSO design D1/D2 ack
d55d86bb0 sage: Q175 SSO design r3 fix, Filbert r3 D1 (loader checks slot shape only) and D2 (slot from config dir, scratch Console dataRoot and unreachable issuer)
c56ccf916 sessions: Filbert Q175 SSO design review r3, approve with D1
817e8f51b sage: Q175 SSO design r3 for #412, Filbert r2 C1 (shared loader, config.json inside the phase 2 window, rollback order, test 25) and C2 (401 signed-out page, test 24), notes and Q-E
5d019d876 sessions: Filbert Q175 SSO design review r2, changes
59475fc5a sage: row 163 landing record, P19 comments 372/373 on 337/412, and P19 #413 filed for the --no-send row
1a268249e sage: Q175 SSO design r2 for #412, Filbert r1 B1-B6, rulings Q-B/Q-C/Q-D, config in config.json (mos-0973)
def2e7932 plans: brief for stack-release activate --no-send (Q175 interim, mos-0972/0973, #1687, P19 413)
c30b36fff sessions: Filbert Q175 SSO design review r1, changes
9ae8c73ac queue: revs 2235-2236: row 163 Sage landing note (8ecc7a239, patch-id b8d5f8a37c24, gate passed on eaa0f2666) and done
8ecc7a239 row 163 (#1660): stack-release.sh rehearse, the D2 rehearsal on a scratch root from a backup, and bus start --no-launch (Sage; Filbert approve 29447; Sage gate passed)
7f57419b3 sage: Q175 SSO design for #412, OIDC relying party on oauth4webapi 3.8.8, for Filbert review (mos-0970, mos-0971)
4ca8d6e94 sage: row 42 run log, S7 reachability pass (51cebff7 answered by cff1e9ad after a chief launch, mos-0969)
22e5e825b sage: row 42 run log, plain mosaic on a pinned host supersedes the 10-12 full-path line (mos-0968)
e6dea74b2 sessions: Sage row 163 r2 approval intake; P19 337 comment 368 script and record
cd2b377bb queue: rev 2234: row 163 r2 approve from Filbert (op filbert-163-r2, comment 29447, candidate c6f4f4c12596)
ceccb0ef2 sessions: Filbert row 163 r2 review, approve
10ab8ea62 sessions: Sage row 163 r2 in-review; P19 337 comment 367 script and record
c539995cf queue: revs 2231-2233: row 163 r2 in-review from Sage (ops sage-163-r2-start, sage-163-inreview-2, .outcome; request comment 29446, reviewer Filbert, manifest c6f4f4c12596)
92cc6011c queue: rev 2230: row 163 r1 changes from Filbert (op filbert-163-r1, comment 29445, candidate 430212299beb)
7af166574 sage: row 163 r2 packet (candidate 2c090eefd on code 2a5f3a3a0, base 4f02486d5: diff, manifest c6f4f4c12596, gate passed, mutants K34-K41 killed)
2244e8baf sessions: Filbert row 163 r1 review, changes
bde0fdec9 sessions: Sage row 163 r1 in-review; P19 337 comment 366 script and record
def677bc7 queue: revs 2228-2229: row 163 r1 in-review from Sage (ops sage-163-inreview-1, .outcome; request comment 29444, reviewer Filbert, manifest 430212299beb)
51f67f1f8 sage: row 163 packet (candidate 4be3f6aec on 580500699: diff, manifest 430212299beb, gate passed, mutants K1-K33 killed after four survivors got tests)
5b298bd00 sessions: Dewey row 66 r1 (changes) and r2 (approve) reviews
565bc32c2 queue: revs 2223-2227: row 66 r2 round from Sage (2223-2225, ops sage-r66-r2-start, sage-r66-inreview-2); r2 verdicts from Filbert (2226, op filbert-66-r2, approve) and Dewey (2227, op dewey-66-r2, approve)
5140aa9e8 sessions: Filbert row 66 r2 review, approve
973cb5a87 queue: revs 2220-2222: row 163 Sage start (2220); row 66 r1 verdicts from Filbert (2221, op filbert-66-r1) and Dewey (2222, op dewey-66-r1)
b82ec9353 sage: row 66 r2 packet (candidate 0f4a57956: diff, manifest cee63e06fdea, gate, mutants K1-K4 killed, singles rerun)
3560c450a sessions: Filbert row 66 r1 review, changes
4f02486d5 sage: row 66 r1 packet (candidate 8ec03b45a: diff, manifest e68c6bce5ec4, gate r2), row 42 run log (trigger (a), mos-0960), SESSIONS line
263ea8a9a queue: revs 2215-2219: row 66 Sage start (2215), in-review round 1 on candidate manifest e68c6bce5ec4 (8ec03b45a) and request comment 29437 (2216-2217); row 163 brief re-pinned to b1500d96ad27 (6020bb43a) and briefed (2218-2219)
6020bb43a plans: D2 brief owner is sage (row 163, #1660), and D2 lands after the Part B window (mos-0958, mos-0959)
5487ed39d sage: row 188 landing record: P19 411 (N1) and comments 361-363 script and record, 409/410 description restore after a partial done update, SESSIONS line
31e328958 queue: revs 2209-2214: row 188 Darkwing start, in-review and request 29432 (2209-2211); Filbert approve 29433 (2212); Sage landing note (8fc0ee216, patch-id c9a56b167c07, stack-release.sh blob 2586a2a37cd2, gate passed) and done (2213-2214)
8fc0ee216 row 188 (#1686, P19 409, 410): stack-release T54, the proof file checks on a carried pair and a proof file inside the repository (Darkwing; Filbert approve 29433; Sage gate passed)
23a609c5c sessions: Filbert row 188 r1 review, approve
b9c1c9fe2 sage: Part B draft 2 A2 passed (mos-0958), row 188 launched, Vikunja comments 358-360 script and record, SESSIONS line
525990ecb queue: revs 2207-2208: row 188 add and briefed (Sage), #1686, P19 409/410, gates Part B (mos-0956)
148a9503d sage: Part B window file draft 2 for mos-0957 (F1: B6 and B-R1 name the stop's endLaunch rows, PB5 counts per step; F2: PB6, PB7 and B-R1 matrices run (a)-(h)); Q2 answered, Q1 to Jason's list
240f21650 brief: stack-release tests for a carried proof pair and a proof file inside the repository (P19 409, 410, #1686, mos-0956)
742e98976 sage: Part B window file, the read window's end stamp from the clock (10:29Z, not 10:40Z)
288ab1b5a sage: Part B window file for Mos's A2 check (live-copy B3 and B-R1, Q1 on J2 order), SESSIONS lines for Darkwing (row 187, 10:25:22Z) and Sage
a843ae284 sage: row 187 landing, P19 407-410 filed and P19 385 comment 357, #1685 closed (comment 29425), SESSIONS line
652d6f3a8 queue: revs 2201-2206: row 187 Darkwing start, in-review and request comment 29423 (2201-2203); Filbert approve comment 29424 (2204); landing note (9a71e4e80, patch-id 6b57c47bc661, gate passed) and done (Sage 2205-2206)
9a71e4e80 row 187 (#1685): activate sets, carries and drops the Console's proof pair
d17328a55 sessions: Filbert row 187 r1 review, approve
f81ebfbae sage: row 186 follow-ups filed as P19 405 and 406, P19 404 closed (mos-0955)
9760d4967 sage: row 186 landing, P19 404 comment 354 and SESSIONS line, with Darkwing's row 186 SESSIONS line (09:13:21Z)
16a936c79 queue: revs 2198-2200: row 186 Filbert approve comment 29419 (2198); landing note (269134d8d, patch-id c97b9f6e25cd, gate passed) and done (Sage 2199-2200)
269134d8d row 186 (#1684): the activate plan's backup line says what the run will do
3c5da3a91 sessions: Filbert row 186 r1 review, approve
b367f5730 sage: rows 186-187 Vikunja comments (P19 404 351, P19 385 352-353) and SESSIONS line (mos-0954)
5a768d82c queue: row 187 added and briefed (Part B A0, #1685), with Darkwing's row 186 start (rev 2193)
6de3c86be plans: brief for row 187, activate carries the row 178 proof pair (mos-0928 Part B A0, #1685)
1d7fd75e2 queue: row 186 added and briefed (P19 404, #1684)
f5c72a5c3 plans: brief for row 186, the activate plan's backup line (P19 404, #1684)
b724b7b0b sessions: Sage Part A window line, with Darkwing's 06:53:53Z and 08:28:01Z lines
e24a13ad8 sage: Vikunja scripts and read-back records 2026-10-13 to 10-20 (decision 110, mos-0882)
21aa1cab0 darkwing: re-pin window rows 177-185 packet, STATE.md (sha256 b586f11c9b6f1329), W.txt (edd4622c5c45ef3f), S1/S2/S2b/B snapshots, matrices, W10 browser check and P3 rehearsals (#1683; P19 386)
9ddd8a92c plans: repin-rows-177-185 draft 4, R1 snapshot after the plain rollback (mos-0943)
e7637b99c plans: repin-rows-177-185 draft 3, R1post expects one tester action.refused per refused read (mos-0935, mos-0942)
2ea05700b plans: mos-0928 plan draft 2, NEW is the row 185 landing 490e2771d
010666074 queue: revs 2185-2190: row 185 start and in-review on bf87dbba with request comment 29403 (Darkwing 2185-2187); Filbert approve comment 29404 (2188); landing note (490e2771d, patch-id 774cd23186d5, gate passed) and done (Sage 2189-2190)
bus: scripts/bus-service.sh install --copy 4429a104f6d65ccf165f6c83b5d1dce3147dfde0 --copies /home/jwoltje/.local/share/mosaic-stack/copies --pm --claude /home/jwoltje/.local/bin/claude
proof: off
send: off
Console: scripts/webui-service.sh install --copy 4429a104f6d65ccf165f6c83b5d1dce3147dfde0 --copies /home/jwoltje/.local/share/mosaic-stack/copies --port 7332 --public-host mosaic.woltje.com --auth-header X-authentik-username --allow-user jason.woltje --tester-port 7333
CLI: scripts/stack-copy.sh wrapper 4429a104f6d65ccf165f6c83b5d1dce3147dfde0 --copies /home/jwoltje/.local/share/mosaic-stack/copies --bin /home/jwoltje/.npm-global/bin
@@ -0,0 +1,21 @@
pointer: /home/jwoltje/.mosaic-dev/state/stack-active.json
{"copy":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","business":"mosaic-stack","pm":true,"claude":"/home/jwoltje/.local/bin/claude","console":["--port","7332","--public-host","mosaic.woltje.com","--auth-header","X-authentik-username","--allow-user","jason.woltje","--tester-port","7333"],"schema":"3i","activation":"20261020T220206Z-4429a104f6d6","at":"2026-10-20T22:02:09Z"}
log (last five lines):
{"at":"2026-10-20T07:58:02Z","op":"activate","from":"89af38a83afa22a8a4f0d368a0a4030ad15a0efa","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":"20261020T075800Z-490e2771d3d0","activation":"20261020T075800Z-490e2771d3d0","result":"ok"}
{"at":"2026-10-20T19:29:56Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":null,"activation":"20261020T192956Z-490e2771d3d0","result":"started"}
{"at":"2026-10-20T19:30:00Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":"20261020T192956Z-490e2771d3d0","activation":"20261020T192956Z-490e2771d3d0","result":"ok"}
{"at":"2026-10-20T22:02:06Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","backup":null,"activation":"20261020T220206Z-4429a104f6d6","result":"started"}
{"at":"2026-10-20T22:02:09Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","backup":"20261020T220206Z-4429a104f6d6","activation":"20261020T220206Z-4429a104f6d6","result":"ok"}
check:
bus: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
Console: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
CLI: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
result: pass
bus status:
host: mosaic-stack, pid 2950923, running, since 2026-10-20T22:02:08.133Z, notifier off
socket: present
writer.lock: pid 2950981
boot: organization file loaded at 2026-10-20T22:02:08.125Z
unit flags: match the pointer
drift: 6 commit(s) on refactor since the pointer's copy
# exit 0
@@ -0,0 +1,8 @@
stack-release: captured the rollback set: /home/jwoltje/.mosaic-dev/state/stack-rollback/20261020T220206Z-4429a104f6d6
stack-release: stopped both units
stack-release: stopped store schema: 3i
stack-release: backup: /home/jwoltje/.mosaic-dev/backups/stack/20261020T220206Z-4429a104f6d6
stack-release: installed 4429a104f6d65ccf165f6c83b5d1dce3147dfde0
stack-release: gate passed on 4429a104f6d65ccf165f6c83b5d1dce3147dfde0
stack-release: activate ok: 4429a104f6d65ccf165f6c83b5d1dce3147dfde0 is live for mosaic-stack
# exit 0
@@ -0,0 +1,2 @@
W0b 2026-10-20T22:00:49Z business file copy /home/jwoltje/.config/mosaic-dev/businesses/mosaic-stack.json.pre-window-20261020T220049Z sha256 1e49bdf05e62607972958f5b7298cfd600c7825812370e26d7c2a842f2af9b93 (source /home/jwoltje/.config/mosaic-dev/businesses/mosaic-stack.json sha256 1e49bdf05e62607972958f5b7298cfd600c7825812370e26d7c2a842f2af9b93)
W5 2026-10-20T22:02:13Z activation 20261020T220206Z-4429a104f6d6 backup /home/jwoltje/.mosaic-dev/backups/stack/20261020T220206Z-4429a104f6d6 SHA256SUMS sha256 b61d64843c9e2a153842606353450838da9cb778c8f3b9c27cc9366dd72ec0c9
@@ -0,0 +1,7 @@
a 200 ok=true
b 401 error="sign-in required"
c 401 error="sign-in required"
d 403 error="proxy Host refused"
e 403 error="tester listener required"
f 403 error="proxy header refused"
g 200 non-json 6575 bytes
@@ -0,0 +1,8 @@
# P6 2026-10-20T22:00:17Z
a 200 ok=true
b 401 error="sign-in required"
c 401 error="sign-in required"
d 403 error="proxy Host refused"
e 403 error="tester listener required"
f 403 error="proxy header refused"
g 200 non-json 6454 bytes
@@ -0,0 +1,4 @@
copy 490e2771d: roles 6, projects 1, arbiters 2
HOLDS chief instance.name yes
HOLDS pm instance.name no
LOAD_OK
@@ -0,0 +1,19 @@
{
"bus.tester": true,
"plugins": {
"usage-source": [
"transcript"
],
"channel": [
"cli",
"webui"
],
"harness": [
"claude-code"
]
},
"launch.by": "chief",
"launch.instances has assistant": true,
"roles with pi harness": [],
"sha256": "1e49bdf05e62607972958f5b7298cfd600c7825812370e26d7c2a842f2af9b93"
}
@@ -0,0 +1,35 @@
start 2026-10-20T21:46:09Z
business-check.mjs: OK
console-check.sh: OK
1ba3c7efbc84a1d7eb8ee0068d32b7917f4e75cdc4066703944cea4c20db5b93 /mnt/storage/scratch/sage-pin-de352dfaf/fixture/console-stub.mjs
a4500094e7c7a6dc21797eaf2d696f9814f272ece945c2735b79e4593f9c9503 /mnt/storage/scratch/sage-p2-4429/console-stub-tester.mjs
== run 1: pinned stub, no tester listener
stub pid 2495939 port 39561
PASS markdown-preview.js is served and equals the copy's
PASS /api/config on loopback: send=false edit=false
PASS project.files through the Console: head=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa files=2 spec=true error=none
PASS project.file spec/north-star.md through the Console: commit=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa length=4 error=none
PASS loopback save refused: HTTP 403 error=read-only sent=false
CONSOLE_PASS
PORT 39561
CALLS []
run 1 console-check rc=0
== run 2: main listener plus a tester listener on a second port
stub pid 2496085 port 35213 tester port 35945
PASS markdown-preview.js is served and equals the copy's
PASS /api/config on loopback: send=false edit=false
PASS project.files through the Console: head=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa files=2 spec=true error=none
PASS project.file spec/north-star.md through the Console: commit=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa length=4 error=none
PASS loopback save refused: HTTP 403 error=read-only sent=false
CONSOLE_PASS
PASS main refuses x-mosaic-tester: HTTP 403 "tester listener required"
PASS tester /api/config: HTTP 200 "tester":true
PASS tester /api/config send and edit off: HTTP 200 "send":false,"edit":false
PASS tester refuses the main auth header: HTTP 403 "proxy header refused"
PASS tester refuses project-file save: HTTP 403
PORT 35213
TPORT 35945
CALLS []
TESTER_PASS
run 2 console-check rc=0 tester rc=0
end 2026-10-20T21:46:10Z
@@ -0,0 +1 @@
2026-10-20T21:52:34Z
@@ -0,0 +1,10 @@
flags-check start 2026-10-20T21:53:52Z
pid 2729699 alive: yes
127.0.0.1:7341
127.0.0.1:7342
main /healthz: 200
tester /healthz: 200
main /api/config: {"board":"http://127.0.0.1:7331","send":false,"edit":false}
pid 2729699 after TERM: gone
listeners after: 0
flags-check end 2026-10-20T21:53:56Z
@@ -0,0 +1,7 @@
Mosaic Console: http://127.0.0.1:7341/
Board: http://127.0.0.1:7331
Bus: mosaic-stack
Preview: https://mosaic.woltje.com/ through the proxy, signed-in user from x-authentik-username, read-only
Proxy: proxy proof off
Tester Console: http://127.0.0.1:7342/ (binds the tester)
Ctrl-C stops this server.
@@ -0,0 +1 @@
rc=0
@@ -0,0 +1,20 @@
PASS copy: 4429a104f6d65ccf165f6c83b5d1dce3147dfde0, schema 3i
PASS port: 127.0.0.1:7341 is free
PASS backup: /home/jwoltje/.mosaic-dev/backups/stack/20261020T192956Z-490e2771d3d0 matches its SHA256SUMS
PASS scratch-root: /home/jwoltje/.mosaic-dev/rehearsals/20261020T215231Z-4429a104f6d6/data from the backup, config and business file under /home/jwoltje/.mosaic-dev/rehearsals/20261020T215231Z-4429a104f6d6/config, business.diff beside them
PASS data-root-live: /home/jwoltje/.mosaic-dev/rehearsals/20261020T215231Z-4429a104f6d6/data is not the live data root
PASS sessions-live: no running session named in the data root
PASS data-root-scratch
PASS notify-off: binding null, so no Discord bridge
PASS tracker-configured: bootConfig sets no trackers
PASS credential-ref-live: 9 reference(s), each a placeholder
PASS migration: not needed: the backup is already 3i
PASS gate: host-up, bus-status, console-up, healthz and copy-match on 4429a104f6d65ccf165f6c83b5d1dce3147dfde0
PASS no-session: no sessions.json and no launches under /home/jwoltje/.mosaic-dev/rehearsals/20261020T215231Z-4429a104f6d6/data
PASS console-stopped: pid 2675437
PASS host-stopped: pid 2675436
PASS host-released-store
PASS backup-unchanged
PASS live-config-unchanged
REHEARSAL_PASS
rehearsal directory: /home/jwoltje/.mosaic-dev/rehearsals/20261020T215231Z-4429a104f6d6
@@ -0,0 +1 @@
2026-10-20T21:52:31Z
@@ -0,0 +1,7 @@
# P4 2026-10-20T22:00:17Z
DropInPaths=
ExecStart={ path=/home/jwoltje/.local/bin/chisel ; argv[]=/home/jwoltje/.local/bin/chisel client --fingerprint ce+ISFvMAhuoCRDuc7riIf+hYz87TLHk/C9C/H8QsUg= --keepalive 25s --max-retry-interval 10s http://10.1.1.227:8080 R:0.0.0.0:7332:127.0.0.1:7332 ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }
# exit 0
# R: specs (1): R:0.0.0.0:7332:127.0.0.1:7332
P4 PASS: one R: spec R:0.0.0.0:7332:127.0.0.1:7332, DropInPaths empty
rc 0
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# P4-P6 of agents/sage/work/window-plan/repin-4429a104f.md (f41729178), adapted from Darkwing's window-rows-177-185/p456.sh, read only.
# P4 the tunnel unit, P5 the four REPO scripts against NEW, P6 the 7332 matrix on OLD (matrix-old.txt).
# usage: p456.sh p4|p5|p6
set -uo pipefail
REPO=/mnt/storage/src/mosaic-stack
WIN=$REPO/agents/sage/work/window-repin-4429a104f
NEW=4429a104f6d65ccf165f6c83b5d1dce3147dfde0
FILES=(scripts/stack-release.sh scripts/webui-service.sh scripts/bus-service.sh scripts/stack-copy.sh)
# The plan's <32 letters>: a fixed dummy, not a secret. matrix-new.txt uses the same value.
PROOF=abcdefghijklmnopqrstuvwxyzabcdef
p4() {
echo "# P4 $(date -u +%FT%TZ)"
local out; out="$(systemctl --user show mosaic-preview-tunnel.service -p ExecStart -p DropInPaths)"; local rc=$?
printf '%s\n' "$out"; echo "# exit $rc"
local specs; specs="$(grep -oE 'R:[^ ;]+' <<<"$out")"
local n; n="$(grep -c . <<<"$specs")"
echo "# R: specs ($n): $(tr '\n' ' ' <<<"$specs")"
local drop; drop="$(sed -n 's/^DropInPaths=//p' <<<"$out")"
if [[ $rc -eq 0 && $n -eq 1 && "$specs" == "R:0.0.0.0:7332:127.0.0.1:7332" && -z "$drop" ]]; then
echo "P4 PASS: one R: spec R:0.0.0.0:7332:127.0.0.1:7332, DropInPaths empty"
else
echo "P4 FAIL"; return 3
fi
}
p5() {
echo "# P5 $(date -u +%FT%TZ); HEAD $(git -C $REPO rev-parse HEAD)"
git -C $REPO diff --quiet $NEW HEAD -- "${FILES[@]}"; local d=$?
echo "# git diff --quiet $NEW HEAD -- ${FILES[*]}: exit $d"
local st; st="$(git -C $REPO status --porcelain -- "${FILES[@]}")"; local s=$?
echo "# git status --porcelain (exit $s): [${st}]"
for f in "${FILES[@]}"; do echo "# $f HEAD blob $(git -C $REPO rev-parse HEAD:$f) NEW blob $(git -C $REPO rev-parse $NEW:$f)"; done
if [[ $d -eq 0 && $s -eq 0 && -z "$st" ]]; then echo "P5 PASS"; else echo "P5 FAIL"; return 3; fi
}
# one matrix line: label, then curl arguments; prints "<label> <code> <error or ok field>"
req() {
local label="$1"; shift
local body; body="$(mktemp -p "${TMPDIR:-/tmp}")"
local code; code="$(curl -s -m 10 -o "$body" -w '%{http_code}' "$@")"
local field; field="$(node -e '
let s = require("fs").readFileSync(process.argv[1], "utf8"), j;
try { j = JSON.parse(s); } catch { console.log("non-json " + s.length + " bytes"); process.exit(0); }
console.log(j.error !== undefined ? "error=" + JSON.stringify(j.error) : "ok=" + JSON.stringify(j.ok));
' "$body")"
rm -f "$body"
echo "$label $code $field"
}
p6() {
req a http://127.0.0.1:7332/healthz
req b -H 'Host: mosaic.woltje.com' http://127.0.0.1:7332/
req c -H 'Host: mosaic.woltje.com' -H "X-Mosaic-Proof: $PROOF" http://127.0.0.1:7332/
req d -H 'X-authentik-username: tester-probe' http://127.0.0.1:7332/
req e -H 'x-mosaic-tester: 1' http://127.0.0.1:7332/
req f -H 'X-authentik-username: tester-probe' http://127.0.0.1:7333/
req g -H "X-Mosaic-Proof: $PROOF" http://127.0.0.1:7333/
}
case "${1:-}" in
p4) p4 ;;
p5) p5 ;;
p6) p6 ;;
*) echo "usage: p456.sh p4|p5|p6" >&2; exit 2 ;;
esac
@@ -0,0 +1,9 @@
# P5 2026-10-20T22:00:17Z; HEAD 4385b9ec183a2da4136ccd639d2a6b041eb9c68f
# git diff --quiet 4429a104f6d65ccf165f6c83b5d1dce3147dfde0 HEAD -- scripts/stack-release.sh scripts/webui-service.sh scripts/bus-service.sh scripts/stack-copy.sh: exit 0
# git status --porcelain (exit 0): []
# scripts/stack-release.sh HEAD blob 3b068b1546d4020cffde5d73929d9130afe91911 NEW blob 3b068b1546d4020cffde5d73929d9130afe91911
# scripts/webui-service.sh HEAD blob 0a32547c54ac233e75a21f4817ae230855dbc088 NEW blob 0a32547c54ac233e75a21f4817ae230855dbc088
# scripts/bus-service.sh HEAD blob f71250204dfeceb51beeea5941ebbf3a33ce4b92 NEW blob f71250204dfeceb51beeea5941ebbf3a33ce4b92
# scripts/stack-copy.sh HEAD blob 24bbb02a6aa8d7857da7be3b6815105a9fef629f NEW blob 24bbb02a6aa8d7857da7be3b6815105a9fef629f
P5 PASS
rc 0
@@ -0,0 +1,235 @@
// Merged window B+C, S1, S2 and D3 (repin-merged-bc.md "S1 and S2"): a read-only snapshot of every
// table in a bus store, so the 3h to 3i migration and a restore can be checked without the bus.
// OLD's broker doesn't take --tester on usage, names or threads.
//
// usage: node snapshot.mjs --store /abs/bus.sqlite (--held | --stopped) [--after STAMP] [--backup /abs/bus.sqlite]
// --held the running bus holds the store: a plain readOnly open, which adds no file to a WAL
// store that already has -wal and -shm (as stack-release.sh does for a held store).
// Refuses store-not-held when either is absent
// --stopped the bus is stopped: refuses store-unclean when -wal or -shm is present, else opens
// with immutable=1 and readOnly, which creates no file and leaves the store's mtime
// and sha256 alone
// --after also counts each timed table's rows with `at` after STAMP (mos-0838 D3, before a
// --restore). STAMP is a backup's STAMP file, in now_ms form (stack-release.sh:43,
// 2026-10-20T00:11:14.123Z). The count is a text compare, as the tool's own heldcount
// does, so every `at` in every timed table must have that form: a table with any
// other value refuses at-format and names the table (sage-1125)
// --backup also compares every table with a stack-release backup's bus.sqlite, which opens as
// --stopped opens a store: immutable=1 and readOnly, refusing backup-unclean when -wal
// or -shm is present, so nothing writes into the backup set
//
// It prints one JSON object: the schema version, then for every table in sqlite_master (type
// table, less sqlite_*) the row count, the max seq and a sha256 of the rows (in seq order, else in
// column order). `untimed` names the tables with no `at` column. For usage_records it adds per-run
// input and output sums, counted as `mosaic usage` counts them (usage.mjs turnRecords). messages
// and message_threads hold bodies and names, so they print counts only, no sha256 (sage-1125). It
// prints counts, hashes and table and meta key names only: no message body, no instance, project
// or thread name, no credential and no file content. It reads no config: the store path is always
// given.
//
// With --backup, each table gets `vsBackup` and `stopTables` names every table other than events
// whose rows differ from the backup's (sage-1123): a row with `at` after STAMP, a row past the
// backup's max seq, a changed row at or below it, a changed untimed table, or a table only one
// side has. meta counts only when a key other than schema_version and schema_digest changed (the
// 3h to 3i migration sets those two). Events are left to the tool's own --events N.
import { DatabaseSync } from 'node:sqlite';
import { createHash } from 'node:crypto';
import { existsSync, lstatSync } from 'node:fs';
import { isAbsolute } from 'node:path';
import { pathToFileURL } from 'node:url';
// usage_records is hashed on these columns in this order, never SELECT * (mos-0888 F1), as
// 3h-3i.sql:9 copies it. Every other table is hashed on its own columns in name order, so a
// rebuild that reorders columns keeps the hash. usage_records with any other column set refuses.
const USAGE_COLUMNS = ['seq', 'at', 'business', 'run', 'instance', 'provider', 'model', 'account', 'turn', 'source',
'phase', 'status', 'code', 'cursor', 'input', 'output', 'cache_write', 'cache_read'];
const COUNTS_ONLY = new Set(['messages', 'message_threads']);
const SCHEMA_KEYS = ['schema_digest', 'schema_version'];
const STAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
function refuse(code, detail) {
process.stderr.write(`snapshot: ${code}: ${detail}\n`);
process.exit(1);
}
function args(argv) {
const o = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--store' || a === '--after' || a === '--backup') {
if (i + 1 >= argv.length) refuse('usage', `${a} needs a value`);
o[a.slice(2)] = argv[++i];
} else if (a === '--held' || a === '--stopped') {
if (o.mode) refuse('usage', 'give one of --held and --stopped');
o.mode = a.slice(2);
} else refuse('usage', `unknown argument ${a}`);
}
if (!o.store || !o.mode) refuse('usage', 'node snapshot.mjs --store /abs/bus.sqlite (--held | --stopped) [--after STAMP] [--backup /abs/bus.sqlite]');
if (!isAbsolute(o.store)) refuse('usage', '--store must be an absolute path');
if (o.backup !== undefined && !isAbsolute(o.backup)) refuse('usage', '--backup must be an absolute path');
if (o.after !== undefined && !(STAMP.test(o.after) && Number.isFinite(Date.parse(o.after))))
refuse('usage', '--after must be a STAMP like 2026-10-20T00:11:14.123Z');
return o;
}
function file(path, code) {
if (!existsSync(path) || !lstatSync(path).isFile()) refuse(code, `${path} is not a file`);
}
function immutable(path, code) {
for (const f of [path + '-wal', path + '-shm'])
if (existsSync(f)) refuse(code, `${f} is present; an immutable read would skip the WAL`);
return new DatabaseSync(pathToFileURL(path).href + '?immutable=1', { readOnly: true });
}
function open({ store, mode }) {
file(store, 'store-missing');
if (mode === 'stopped') return immutable(store, 'store-unclean');
// A plain readOnly open of a WAL store with no -wal and -shm creates them, so a store that
// nothing holds refuses here instead of gaining two files.
for (const f of [store + '-wal', store + '-shm'])
if (!existsSync(f)) refuse('store-not-held', `${f} is absent; use --stopped for a store the bus doesn't hold`);
return new DatabaseSync(store, { readOnly: true });
}
const q = (name) => `"${name.replaceAll('"', '""')}"`;
const value = (v) => (typeof v === 'bigint' ? String(v) : v instanceof Uint8Array ? { hex: Buffer.from(v).toString('hex') } : v);
// The same records `mosaic usage` counts: per (run, turn, source) the latest row, an end row
// winning; a turn is known only when that row is an end row with status known.
function byRun(rows) {
const turns = new Map();
for (const row of rows) {
const key = JSON.stringify([row.business, row.run, row.turn, row.source]);
if (row.phase === 'end' || !turns.has(key)) turns.set(key, row);
}
const runs = new Map();
for (const row of turns.values()) {
const key = JSON.stringify([row.business, row.run]);
if (!runs.has(key)) runs.set(key, { business: row.business, run: row.run, input: 0n, output: 0n, unknown: new Set() });
const r = runs.get(key);
if (row.phase === 'end' && row.status === 'known') {
r.input += BigInt(row.input);
r.output += BigInt(row.output);
} else r.unknown.add(row.turn);
}
return [...runs.values()].map(({ unknown, input, output, ...r }) => ({
...r, input: String(input), output: String(output), unknownTurns: unknown.size,
}));
}
// meta is compared by key: the names of the keys whose value differs, never the values.
function metaKeys(db) {
return new Map(db.prepare('SELECT key, value FROM meta').all().map((r) => [String(r.key), String(r.value)]));
}
// One table: rows, max seq and sha256; rows after `after`; with `priorMax` (the backup's max
// seq) a second hash of the rows at or below it and the count of rows above it. The hashes stay
// inside: a COUNTS_ONLY table's `t` gets none.
function read(db, table, { after, priorMax } = {}) {
const cols = db.prepare('SELECT name FROM pragma_table_info(?)').all(table).map((c) => c.name);
if (!cols.length) refuse('table-missing', table);
let hashed = [...cols].sort();
if (table === 'usage_records') {
if (JSON.stringify(hashed) !== JSON.stringify([...USAGE_COLUMNS].sort()))
refuse('columns-changed', `usage_records has columns ${hashed.join(',')}`);
hashed = USAGE_COLUMNS;
}
const seq = cols.includes('seq');
const at = cols.includes('at');
const stmt = db.prepare(`SELECT ${hashed.map(q).join(',')} FROM ${q(table)} ORDER BY ${seq ? 'seq' : hashed.map(q).join(',')}`);
stmt.setReadBigInts(true);
const full = createHash('sha256');
const prior = createHash('sha256');
const limit = priorMax === undefined ? undefined : BigInt(priorMax ?? 0);
let rows = 0;
let rowsAfter = 0;
let badAt = 0;
let newRows = 0;
const kept = [];
for (const row of stmt.iterate()) {
rows++;
const line = JSON.stringify(hashed.map((c) => value(row[c]))) + '\n';
full.update(line);
if (seq && limit !== undefined) {
if (row.seq <= limit) prior.update(line);
else newRows++;
}
if (after !== undefined && at) {
if (typeof row.at !== 'string' || !STAMP.test(row.at)) badAt++;
else if (row.at > after) rowsAfter++;
}
if (table === 'usage_records') kept.push(row);
}
if (badAt) refuse('at-format', `${table} has ${badAt} row(s) whose at is not in the STAMP form, so a text compare with --after would miscount`);
const sha256 = full.digest('hex');
const t = { rows, maxSeq: seq ? (db.prepare(`SELECT max(seq) AS m FROM ${q(table)}`).get().m ?? null) : null };
if (!COUNTS_ONLY.has(table)) t.sha256 = sha256;
if (after !== undefined && at) t.rowsAfter = rowsAfter;
if (table === 'usage_records') t.byRun = byRun(kept);
return { t, sha256, cols: hashed, seq, at, newRows, priorSha: seq && limit !== undefined ? prior.digest('hex') : null };
}
// Tables only: no index, view or trigger, and none of SQLite's own sqlite_* tables.
const tablesOf = (db) => db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' ORDER BY name")
.all().map((r) => r.name);
const o = args(process.argv.slice(2));
if (o.backup !== undefined) file(o.backup, 'backup-missing');
const db = open(o);
const bdb = o.backup === undefined ? null : immutable(o.backup, 'backup-unclean');
const out = {};
try {
db.exec('BEGIN');
out.schema = db.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value ?? null;
const names = tablesOf(db);
const base = new Map();
if (bdb) {
out.backupSchema = bdb.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value ?? null;
for (const name of tablesOf(bdb)) base.set(name, read(bdb, name));
}
out.tables = {};
out.untimed = [];
const stop = [];
for (const name of names) {
const b = base.get(name);
const r = read(db, name, { after: o.after, priorMax: b ? b.t.maxSeq : undefined });
if (!r.at) out.untimed.push(name);
if (bdb) {
const v = { backupRows: b ? b.t.rows : null };
let changed;
if (!b) changed = true;
else if (name === 'meta') {
const now = metaKeys(db);
const then = metaKeys(bdb);
v.keysChanged = [...new Set([...now.keys(), ...then.keys()])].filter((k) => now.get(k) !== then.get(k)).sort();
changed = v.keysChanged.some((k) => !SCHEMA_KEYS.includes(k));
} else if (r.seq) {
v.backupMaxSeq = b.t.maxSeq;
v.newRows = r.newRows;
v.priorSame = JSON.stringify(r.cols) === JSON.stringify(b.cols) && r.priorSha === b.sha256;
changed = r.newRows > 0 || !v.priorSame;
} else {
v.same = JSON.stringify(r.cols) === JSON.stringify(b.cols) && r.sha256 === b.sha256;
changed = !v.same;
}
if ((r.t.rowsAfter ?? 0) > 0) changed = true;
r.t.vsBackup = v;
if (changed && name !== 'events') stop.push(name);
}
out.tables[name] = r.t;
}
if (bdb) {
out.onlyInBackup = [...base.keys()].filter((n) => !names.includes(n));
out.stopTables = [...stop, ...out.onlyInBackup].sort();
}
if (o.after !== undefined) {
out.after = o.after;
out.eventsAfter = out.tables.events?.rowsAfter ?? null;
}
} finally {
try { db.exec('ROLLBACK'); } catch {}
db.close();
bdb?.close();
}
process.stdout.write(JSON.stringify(out, null, 2) + '\n');
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Re-pin 4429a104f window step wrapper (copied from Darkwing's window-rows-177-185/w.sh): w.sh FILE TITLE CMD... runs CMD in REPO under a clean env (no ANTHROPIC_*, CLAUDE_*,
# MOSAIC_* or pi variables), appends the title, command, output, exit code and date -u stamps to window/FILE,
# prints the output, and exits with CMD's exit code.
set -uo pipefail
W=/mnt/storage/src/mosaic-stack/agents/sage/work/window-repin-4429a104f
f="$W/$1"; title="$2"; shift 2
E=(env -i HOME="$HOME" PATH="$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin" LANG=C.UTF-8
XDG_RUNTIME_DIR="$XDG_RUNTIME_DIR" DBUS_SESSION_BUS_ADDRESS="${DBUS_SESSION_BUS_ADDRESS:-unix:path=$XDG_RUNTIME_DIR/bus}")
t0=$(date -u +%FT%T.%3NZ)
{ echo; echo "## $title"; echo "# start $t0"; echo "# command: (cd \$REPO && <clean env> $*)"; } >> "$f"
out=$(cd /mnt/storage/src/mosaic-stack && "${E[@]}" "$@" 2>&1); rc=$?
printf '%s\n' "$out" >> "$f"
echo "# exit $rc, end $(date -u +%FT%T.%3NZ)" >> "$f"
printf '%s\n# exit %s\n' "$out" "$rc"
exit $rc
@@ -0,0 +1,9 @@
npm warn deprecated [email protected]: Use your platform's native DOMException instead
added 128 packages in 2s
npm warn install-scripts 3 packages had install scripts blocked because they are not covered by allowScripts:
npm warn install-scripts @google/[email protected] (preinstall: echo 'preinstall: no-op')
npm warn install-scripts [email protected] (postinstall: node install.js)
npm warn install-scripts [email protected] (postinstall: node scripts/postinstall)
npm warn install-scripts
npm warn install-scripts Run `npm install-scripts ls` to review, or `npm install-scripts approve <pkg>` to allow.

Some files were not shown because too many files have changed in this diff Show More