Compare commits

...
Author SHA1 Message Date
jason.woltje fa9e7fae64 morgana: row 277 (#1782) r4: a thread.claims error with no bus code still throws and prints nothing (duckworth-0123 N1, M9); BUILD.md records the rebase onto 0165be266 and N2/N3 2026-10-22 05:37:23 -05:00
jason.woltje e77c9d30cb morgana: row 277 (#1782) r3: a refused thread.claims prints the role lines and the refusal code (N1), cli README agents usage (N2), per sage-2505 2026-10-22 05:33:07 -05:00
jason.woltje 0b7076cafd morgana: row 277 (#1782) build r2: thread-claims test helper passes a run's capability through, T1 copies store rows before the strict compare (early look sage-2504) 2026-10-22 05:33:07 -05:00
jason.woltje 11333e6d21 morgana: row 277 (#1782) build: thread.claims bus verb for human and reader sessions (D1), mosaic agents adds thread lines without --tester or --json (C1/C2), message-filter pin moves to thread.claims (C3), tests T1-T8 2026-10-22 05:33:07 -05:00
jason.woltje 3e838c0937 morgana: row 277 (#1782): design folder moves to queue-277 (sage-2486) 2026-10-22 05:33:07 -05:00
jason.woltje 39e3dc5159 morgana: #1782 design r2 (duckworth-0118): C1 tester path makes one call, C2 --json keeps the agents array, C3 pin moves to thread.claims; N1-N4 taken, M7 added 2026-10-22 05:33:07 -05:00
jason.woltje 88247505e0 morgana: #1782 design r1 (sage-2481): owner thread claims as a human/reader verb thread.claims, agents unchanged; Console and TUI read c.role, so the briefed mixed list would need them too (Q1, Q2 to Duckworth) 2026-10-22 05:33:07 -05:00
jason.woltje 0165be2661 queue: revs 3173-3180, row 281 claim (Megavolt 3173), row 274 in-review (Morgana 3174/3175, request failed, no token), review request 3176/3177 (Sage, c30488), Dewey approve 3178, landing note and done 3179/3180 (Sage) 2026-10-22 05:32:22 -05:00
jason.woltje 6d6cc0f608 docs: row 274 landing log lines, Dewey's 274 and 281 session lines, and the N7 DEFERRED entry (runs suite gates mosaic-task.mjs) 2026-10-22 05:31:01 -05:00
jason.woltje 4c485c63b9 morgana: FED R3 r6 (sage-2576): the collaborator-list test pins USER_VERBS as the ruled seven so M3x and M6x fail it, and its off-list set adds the grants, lifecycle, web and host verb lists; test only, unrun 2026-10-22 05:06:52 -05:00
jason.woltje f58cfc3617 morgana: FED R3 r5 (sage-2566): covers, LEVELS and the all marker move into the business vocabulary so the broker keeps its sandbox import closure; access.mjs re-exports them, the pin test lists covers on the vocabulary line, RUNNER_FILES unchanged, M16 on the pin, the closure walk and the confined load; unrun 2026-10-22 04:48:41 -05:00
jason.woltje cee3f25a37 morgana: FED R3 r4 (sage-2486, sage-2557): halt refuses a granted user's thread.send while thread.messages answers (test), row 194's hire refuses an instance named user at the broker and the writer (S1, one test each), the access argument check moves before the store read (S2, test), M12-M15; unrun 2026-10-22 04:48:22 -05:00
jason.woltje 1313bfb718 morgana: FED R3 r2 packet (sage-2299): rulings 1-5 recorded, brief's 193b1a mutant line void, manifest widens to instance-settings and its test; doc only, unrun 2026-10-22 04:48:22 -05:00
jason.woltje 618fe0f0b3 morgana: FED R3 r2 (dewey-0777): role key user refused at the broker load check and by the instance writer (N1, one test each), #threadFor user clause listed as an equivalent mutant, 193b1a filter mutant not applicable, M10/M11 and the M3x two-walls run in the kit; unrun 2026-10-22 04:48:22 -05:00
jason.woltje d20c92bf65 morgana: FED R3 build (#414, sage-2292): user principal on the bus, bindUser with a per-request grant read, the collaborator verb list and its filters, user threads and the B6 reply path, runner user header; unrun 2026-10-22 04:48:22 -05:00
jason.woltje 47a35bf436 queue: revs 3163-3172, row 256 claim (Honker 3163), row 236 in-review (Megavolt 3164/3165), review request 3166/3167, Dewey approve 3168, landing note and done 3169/3170, row 281 add and briefed 3171/3172 (Sage) 2026-10-22 04:47:17 -05:00
jason.woltje 6b32424adb docs: row 236 landing log lines, and row 281 (#1786): a fire or a delete revokes the instance's grants (mos-1271)
Brief docs/plans/2026-10-22_fire-revokes-grants.md, owner Megavolt, reviewer Dewey, gate owner Sage. DEFERRED entry points at row 281. BUILD-LOG and SESSIONS carry Honker's row 194 lines, Dewey's two row 236 lines and Sage's row 236 landing lines.
2026-10-22 04:43:49 -05:00
jason.woltje bd5f11dedd bus: row 236, a gitea grant raise on a broker with no Gitea path refuses gitea-not-configured (sage-2559, #1742) 2026-10-22 03:59:40 -05:00
jason.woltje e40b74bebc bus: row 236, two out-of-manifest pins: gitea is a known grant method, and the broker imports giteaName and parseGiteaUrl (gate run sage-2554, #1742) 2026-10-22 03:57:48 -05:00
jason.woltje ce74477e14 bus: row 236, grants.status is a read under the kill switch, as hosts.status is (sage-2453, #1742)
Row 191's #processingGate refuses agent verbs off its read list while
processing is halted. grants.status joins hosts.status on that list, per
the kill-switch plan (2026-10-20_kill-switch.md:16): reads still work.
An agent's grants.revoke stays refused while halted, as hosts.revoke is.
2026-10-22 03:42:05 -05:00
jason.woltje 4d9d62ae89 bus: row 236 r3, one N5 test per settle branch; README notes for a left admin token and a running executor (sage-2388, #1742) 2026-10-22 03:42:05 -05:00
jason.woltje 5e5ba94b19 bus: row 236 r3, a revoke during a settle whoami ends revoked in the 401 and token-unknown branches (Dewey N5, #1742) 2026-10-22 03:42:05 -05:00
jason.woltje 97bbe7fd30 bus: row 236 r2, a revoke during step 7 or a settle ends revoked; sweep fire retry checks the user id (Dewey B1, B2, #1742) 2026-10-22 03:42:05 -05:00
jason.woltje 91f7029923 bus: row 236 gap test takes any HTTP status in a grant.result (debug run 2, #1742) 2026-10-22 03:42:05 -05:00
jason.woltje be11a1f7e6 bus: row 236 tests for the static mutant gaps M8, M9, M16, M28, M29, M37 (#1742) 2026-10-22 03:42:05 -05:00
jason.woltje d87483d683 bus, cli: #436b Gitea grant path, schema 3j-3k (row 236, #1742)
Gitea grant executor (steps 0-8, settle, restart, revoke and fire, sweep,
status), the grants.* verbs and mosaic grants, the 3j-3k migration with
gitea_users and gitea_grants, and the section 9 tests with a fake Gitea and
a scratch gitea/gitea:1.27.1 X-G3 test. Build notes in
agents/megavolt/work/queue-236/BUILD.md.
2026-10-22 03:42:05 -05:00
jason.woltje be2b008cc0 queue: revs 3156-3162: row 194 Honker in-review and failed request (3156/3157), Sage review request (3158/3159), Webby verdict (3160), Sage landing note and done (3161/3162) 2026-10-22 03:39:38 -05:00
jason.woltje ab7377bcf1 docs: row 194 landing BUILD-LOG and SESSIONS lines (Sage) 2026-10-22 03:38:16 -05:00
jason.woltje fd6dd327c3 honker: row 194 b15 (b') (sage-2507): T15 drops the exited reviewer stand-in's registry entry by hand, the N6 convention, so the post-resume coder launch isn't refused on an entry with no provider/model; BUILD.md notes for (a) and (b') 2026-10-22 03:06:41 -05:00
jason.woltje 5fd178e44d honker: row 194 b15 (a) (sage-2501): the Broker constructor's harnessOn, testerOn and roleFile type checks move ahead of the store reads, so a null store gets the argument error first (chief-of-staff T8b); 191's lines unchanged 2026-10-22 03:06:41 -05:00
jason.woltje 345f3ace67 honker: row 194 b8 r3 docs: correct N4, only session.ended clears the alive flag today 2026-10-22 03:06:41 -05:00
jason.woltje 54ec93d06f honker: row 194 b8 r3 docs (webby-0100, sage-2290): N1 corrects the N6 clock caveat (the bus clock runs ahead, so the error is over-signal); N2 DEFERRED the 64-stop done cap; N3 and N4 follow-ups; no product change 2026-10-22 03:06:41 -05:00
jason.woltje 4f68485272 honker: row 194 b8 r2 (sage-2289): a fire stop stopRun can't confirm (unknown) reads as lifecycle.end.alive with reason unknown, never killed, and the Console flags it; cli T9a no-runtime-record test, bus T9a reason checks, M56; notes 2026-10-22 03:06:41 -05:00
jason.woltje c48239ec3d honker: row 194 b8 notes: base eb3abba12 (row 191), the commit 1, b4 and b6 conflict resolutions file by file, S6 design notes, the unknown-outcome question, mutants and run list 2026-10-22 03:06:41 -05:00
jason.woltje 5d2c274420 honker: row 194 b8 S6 (sage-2241, sage-2246, sage-2261): fire's kill end goes through row 191's stopRun with the fire's request as owner, killed and alive come back as lifecycle.end.killed and lifecycle.end.alive and the Console flags a run still alive; terminate stays SIGTERM-only (V3); halted, the pass closes launches and regenerates processing-halted and a halt joins a fire's stop; terminate's pass narrows to runs spawned by the end's time (N6); T9a, T9b, T15, N6 2026-10-22 03:06:41 -05:00
jason.woltje 9e5ad7c599 honker: row 194 b7 notes: rebase onto dcd8d4dda (row 254), channel-proof pin, N3 as a behaviour change, routes.set keep and M-next, mutant (a) kill file corrected, N6 ruling for S6 2026-10-22 03:06:41 -05:00
jason.woltje 939356a1c2 honker: row 194 b7 (webby-0095 N1, sage-2248): routes.set refuses a kept instance's route to anyone but the human, as the limits writer does 2026-10-22 03:06:41 -05:00
jason.woltje 3521c23c3e honker: row 194 b6 (webby-0094, sage-2241, sage-2245): every instance-writer verb answers restart (B1) and the CLI and Console show it for veto; fire runs the writer's checks against the file before step A, so an unloaded keep can't strand an instance firing; a fire of a delisted name revokes (N1); a host service makes no token reference (N2); only the human re-points a kept instance's model (N3); DEFERRED a human escape for a stuck fire (N4); S6 waits on row 191's stopRun contract 2026-10-22 03:06:41 -05:00
jason.woltje 2311a50e1c honker: row 194 b4 (sage-2164, sage-2166, sage-2217): a hire and the role's hireable list refuse a creative definition; a library mapping change isn't reloaded (T14); the listing's endProtected hides a Delete the server refuses; DEFERRED creative delete; C1 testerNoSignIn closed; row 213's writer-restart pin follows D3 and T16 uses row 196's tester socket 2026-10-22 03:06:41 -05:00
jason.woltje 29daebf548 honker: row 194 b3 sage-1634: commit() checks the revision before the revoke, so a stale delete or fire refuses revision-conflict with nothing revoked (N1); bus T21; option 2 a follow-up 2026-10-22 03:06:41 -05:00
jason.woltje 2f08fd8f23 honker: row 194 b3 sage-1630: BUILD.md follow-up N1, a revoke that succeeds before a refused write (a stale-revision delete does it every time) 2026-10-22 03:06:41 -05:00
jason.woltje b8c3d00362 honker: row 194 b3 sage-1620: credential-revoke-failed says the business file is unchanged and a retry is safe (CLI and Console), W2 checks the log per case 2026-10-22 03:06:41 -05:00
jason.woltje 14124d96d4 honker: row 194 b3 (sage-1605, sage-1610): fire and delete revoke before the write (T20), a human add takes a tracker bot, a store-only delete names the bot; the tester keeps the credential refusal (row 175 W2); channel-proof and project-runtime pins 2026-10-22 03:06:41 -05:00
jason.woltje e7fb59f225 honker: row 194 on row 199's landing cb5fdb064 (sage-1601): honker/row194-b2 85edab7c7 squashed, five conflicts kept both sides (broker imports, constructor, dispatch, the credential and lifecycle verbs; runtime imports; organization write takes detail and context; cli credentials and veto dispatch; vocabulary comment as row 199 wrote it) 2026-10-22 03:06:41 -05:00
jason.woltje e122aad8e2 queue: revs 3148-3155: row 280 Rocko start, in-review and failed review request (3148-3150), Sage review request (3151/3152), Duckworth verdict (3153), Sage landing note and done (3154/3155) 2026-10-22 03:03:05 -05:00
jason.woltje 4c4b8559cf docs: row 280 landing BUILD-LOG and SESSIONS lines (Sage); Rocko's row 280 BUILD-LOG and SESSIONS lines 2026-10-22 03:00:14 -05:00
rocko b0fd28b6f3 docs: keep a slim row 280 packet with full evidence reference 2026-10-22 02:56:40 -05:00
rocko b765f5cb81 test(bus): prove scratch sshd listener ownership before use 2026-10-22 02:28:09 -05:00
jason.woltje da5d3651d7 queue: revs 3138-3147: row 279 Rocko start and in-review (3138-3140), Sage review request (3141/3142), Dewey verdict (3143), Sage landing note and done (3144/3145); row 280 #1785 bus sshd port race added and briefed (Sage 3146/3147, mos-1270) 2026-10-22 02:14:46 -05:00
jason.woltje 82c8281166 sage: brief for the bus sshd port race row (mos-1270, #1785); 279 landing and 194 b16 third-red BUILD-LOG lines; SESSIONS lines (Sage, Rocko 279, Dewey 279 r1 and carry) 2026-10-22 02:12:09 -05:00
rockoandjason.woltje ceadad993c docs: record row 279 regression and mutant evidence 2026-10-22 01:03:26 -05:00
rockoandjason.woltje f5611c937e fix(processing): observe run status after stop deadline overshoot 2026-10-22 01:03:26 -05:00
jason.woltje 343b6dd766 queue: revs 3134-3137: row 277 brief re-pin (Sage 3134), Morgana 277 claim (3135); row 279 #1784 stopRun deadline added and briefed (Sage 3136/3137, mos-1269) 2026-10-22 00:54:47 -05:00
jason.woltje f40f2e770e sage: brief for the stopRun deadline row (mos-1269), the 191 follow-up row 194 b15's gate found 2026-10-22 00:51:17 -05:00
jason.woltje 23c3d62183 queue: revs 3130-3133 (Sage): row 277 #1782 owner thread claims added and briefed; row 278 #1783 Console thread claims added and briefed 2026-10-22 00:17:54 -05:00
jason.woltje 70e289ba73 sage: #1782 design D1 ruling (sage-2487) and the Console thread-claims follow-up brief 2026-10-22 00:14:35 -05:00
jason.woltje ab50de9fe3 queue: revs 3114-3129: rows 191, 205, 268 landed and done (Sage 3116/3117, 3119/3120, 3123/3124, 3126-3129); Rocko 191 in-review 3114/3115; Dewey 191 verdict 3118; Morgana 205 in-review 3121/3122; Duckworth 205 verdict 3125 2026-10-22 00:12:00 -05:00
jason.woltje b3665f5ab1 sage: docs for landings 191, 205, 268; #1782 owner thread-claims brief (mos-1262); re-pin 86f95bb27 plan; phase 2 window draft 3; seat SESSIONS and BUILD-LOG lines 2026-10-22 00:10:08 -05:00
jason.woltje c27207f6e6 row 268 (C2-01c, #1773, Vikunja 464): playgrounds at three levels; classify checks prefixes first
checkMapping takes kind absent, hybrid or draft. A draft has a name and one
or more prefixes and no books, and comes back with books []. A prefix inside
or holding another entry's book refuses library-playground-other-universe,
which replaces library-playground-outside-book. A prefix equal to a book or
holding its own refuses library-mapping-invalid, as does a draft source.
classify returns a playground file's entry and the book holding its prefix,
or null at universe level and in a draft.
2026-10-22 00:04:42 -05:00
jason.woltje ce6a96ad61 morgana: row 205 r2 (sage-2430): put() events carry their decision so the decision trail reaches them; pin checks the thread claim via threadsState, owner read is a follow-up row 2026-10-21 23:49:34 -05:00
jason.woltje 59f708ca21 morgana: row 205a build (sage-2288): trail and replies hide other users' thread messages from owner, reader and tester; hidden answers as unknown; #readable owner-only; design d2 rulings 2026-10-21 23:49:34 -05:00
jason.woltje f751505ada morgana: row 205 design d1 (sage-2280): FED R3 unfiled, so split 205a message filter now and 205b viewer verbs after R3; mutant 3 to 193b2; hidden trail subject returns [] 2026-10-21 23:49:34 -05:00
rocko 0fa7bf421f fix: place processing controls within the Console navigation box 2026-10-21 22:42:13 -05:00
rocko 93d211c295 fix: allocate Console viewport space to processing controls 2026-10-21 22:42:13 -05:00
rocko 08c896e3c5 docs: record processing gate failures and timer fixture correction 2026-10-21 22:42:12 -05:00
rocko 03ed2bccce test: attribute processing timer to its owning script 2026-10-21 22:42:12 -05:00
rocko ad6020754d fix: stop processing controls on Console sign-out and preserve layout 2026-10-21 22:42:12 -05:00
rocko 5c8f983dde test: initialize processing control in Discord CLI fixtures 2026-10-21 22:42:12 -05:00
rocko f1267e49af fix: account for processing controls in image inputs and launch fixtures 2026-10-21 22:42:12 -05:00
rocko d2be97c5fc test: use private tester socket for processing controls 2026-10-21 22:42:12 -05:00
rocko c13c68c813 test: strengthen processing cancellation and refusal guards
Bound the halt-abort expectation, check Discord refusal before malformed journal reads, and isolate HTTP origin and valid oversized-body guards. These test changes are committed for the row 191 gate; execution awaits the coordinated slot.
2026-10-21 22:42:12 -05:00
rocko 4bdc7e0e86 processing: inspectable per-run stop seam for lifecycle fire
Unify halt and fire cancellation by verified run identity with durable first-owner receipts and shared deadlines. Integrate the instance control with the current host, library and tester-socket paths. Focused nine-file evidence is 62 passing tests; broad suites, mutants and review remain pending.
2026-10-21 22:42:12 -05:00
jason.woltje 86f95bb27f queue: revs 3109-3113 (darkwing: row 273 in-review round 1 and review request; filbert: row 273 APPROVE recorded (c30308); sage: row 273 landing note and done after 0bbf0f6d1 (#1778)) 2026-10-21 22:41:05 -05:00
jason.woltje 5365adaef6 docs: row 273 TOOLS.md activate usage and F39 rules (Filbert N1), BUILD-LOG for rows 202 and 273
TOOLS.md: activate takes --no-sso and --send; three lines state the F39 rules.
BUILD-LOG: the row 202 landing line held since 02:01Z and the row 273 landing line.
2026-10-21 22:37:16 -05:00
jason.woltje 0bbf0f6d18 row 273: T53 test 6 pins the usage line with --no-sso
The usage line gained --no-sso in the proof and sso bracket. T53 test 6
still matched the old bracket and failed the whole run on 978143bf4.
2026-10-21 22:15:36 -05:00
jason.woltje 95f4bd8fdc row 273 notes: name the changed T57 item 3 assertion for review (sage-2321) 2026-10-21 22:15:36 -05:00
jason.woltje d8034c51bc stack-release: a rollback that drops --sso drops --send; the --no-sso walk refuses an ok line with no activation (row 273, sage-2315)
B1: row 200's rollback to a Console without --sso kept the pointer's --send,
which F39 b forbids once --send can sit beside --sso. send_for_target drops it
whenever the run drops the pointer's --sso, and the plan and steps say so.
N1: an ok line with no activation refuses sso-off-flags instead of being
skipped.
2026-10-21 22:15:36 -05:00
jason.woltje b28baf7ee8 stack-release tests: T58 adds sage-2305 cases a and b (row 273)
(a) --sso on, then two re-pins that carry it, then --no-sso gives back the
arrival's ExecStart byte for byte. (b) every set in the log holds --sso, so
--no-sso refuses sso-off-flags before any unit stops.
2026-10-21 22:15:36 -05:00
jason.woltje 3673463182 stack-release: --no-sso drops --send in one place, send_for_target (row 273) 2026-10-21 22:15:36 -05:00
jason.woltje 7991662dc0 stack-release: activate --send and --no-sso (row 273, #1778)
activate --send turns send on only when the run's Console flags hold
--sso, given or carried (F39 b), and puts it after --sso and before the
tester flags. activate --no-sso turns --sso off with the Console flags
from the set of the activation that turned it on, drops --send (F39 a)
and keeps the pointer's tester. Both are usage errors under --units
external. T58 covers the brief's T1-T4; the external test covers T5.
Design and the set-walk choice: agents/darkwing/work/queue-273/BUILD.md.
2026-10-21 22:15:36 -05:00
jason.woltje 378a76b854 queue: revs 3103-3108 (morgana: rows 205 and 274 claims; darkwing: row 273 claim; sage: row 276 F5 follow-up added and briefed (#1781, duckworth-0111 C4), row 202 done after ddfb29af7 (#1700 closed)) 2026-10-21 21:17:02 -05:00
jason.woltje ddfb29af7c row 202: FED R5, mosaic access list, grant and revoke (#1700)
Candidate quackerjack/fed-r5-r5 a74cd9f87 on c3d88840c, manifest of 6
paths (CANDIDATE.sha256). Duckworth APPROVE round 5, rev 3026 (#1700
c30184). C1 gate on Sage's slot by Quackerjack on the merge of a74cd9f87
onto a849d89b7 (3b86180b9), finished 01:56:24Z: cli suite 771/771, skip 0
(run 2; run 1 red on a too-long gate TMPDIR, harness only), every
scripts/test-*.sh rc 0 with Docker up, mutants M1-M13 killed by their
named tests. Landed onto 9c9732983 with the candidate's blobs and modes
for the 6 paths; c3d88840c..9c9732983 touches none of them.
2026-10-21 21:00:28 -05:00
jason.woltje 9c97329833 docs: brief for the row 253 F5 follow-up, supervised legacy AS_USER calls (duckworth-0111 C4) 2026-10-21 20:58:00 -05:00
jason.woltje a849d89b73 docs: phase 2 plan, row 273 cases go in stack-release.test.mjs (mos-1247; test-release.sh never calls stack-release.sh) 2026-10-21 20:23:38 -05:00
245 changed files with 25785 additions and 631 deletions
+72
View File
@@ -5220,4 +5220,76 @@ Before: managed project/thread verbs had no Console conversation area. After: fi
2026-10-21T22:30:20Z | Huey | T1 sessions design r3, scrooge-0007 R1, Vikunja 463 | Before: r2 e19e30df2 nested new session checkouts under the retained legacy instance workspace, allowing project-name collision or historical parent inspection to reach live siblings. After: exact path is dataRoot/session-workspaces/business/instance/session, outside the legacy root; canonicalized ancestor/descendant overlap refuses; T2 nesting mutant covers historical mount/inspection and a legacy project named sessions. All other r2 content retained as approved, revision label updated. Verification: old nested path absent, manual R1-only delta, whitespace and installed queue guard checked; no code/builds/suites/live changes/provider calls/credential reads.
2026-10-20 18:51:10 UTC | Row 191 #1689 design phase | Before: per-business launch revoke and per-binding Discord STOP leave task containers, active sessions and background work without one switch. Proposed per-data-root durable halt with human-only resume, generation-fenced starts, bounded concurrent termination, write-once stop receipts and existing event mirror; no schema migration. DESIGN.md inventories starter/continuation paths and container control-mount restriction. Sent to Dewey with Sage copied; implementation waits independent design approval. No tests, commits or live actions.
2026-10-20 18:57:51 UTC | Row 191 design revision | Dewey r1 changes: interim Console --send absence, reserved processing events and missing-state semantics required correction. DESIGN-r2.md now names CLI interim path, unavailable UI controls, trusted initialization and fail-closed missing leaf, container delete/replace mount tests, reservations and id-bound reconciliation. Small notes addressed including diagnostic seats and aliases. Recommended smaller Docker scope to Sage; design and scope approval pending, no code or test runs.
2026-10-20T19:50:56Z | Rocko | row 191 #1689 implementation stage: from independent brakes to shared root state, generation-fenced launcher and tracked stops, Discord/task guards and control HTTP/UI. Evidence is agents/rocko/work/queue-191/logs/ and PROGRESS.md. Fixture single files only so far; heavy verification and candidate not complete. Scope additions sage-1367 (one read-only CODE file) and sage-1392 (processing-only client socket timeout); no schema, proof baseline, live file/unit, commit or push change.
2026-10-20 22:58:45 UTC | Rocko | row 191 #1689 rebase inspection candidate | Rebuilt uncommitted kill-switch tree from da230ca48 onto landed row 190 base 00d4cc1f6 under new branch rocko/row191-r2-00d4. Hand merges retained instance flags and SSO/sign-out changes; fixture tests exposed and fixed SSO signer compatibility and two unknown-runtime cleanup defects. Final touched singles 249/249 across 26 files; 15 overlapping Client reader files 102/102. Manifest a4f780c2 has 78 paths; packet 185 files verified with scan clear. Mutants and owner gate remain pending; no review-ready or activation claim, no commit/push/live action.
2026-10-21 23:39:21 UTC | Rocko | row 191 #1689 inspection snapshot | Sage-2242 authorized a local inspection commit for Honker's row 194 S6. Created d52b4db1c on new rocko/row191-inspection-20261021, parent 00d4cc1f6, preserving the reviewed-design build and candidate a4f780c2 (78 paths). Static manifest/index/source scan and diff check verified; no tests run, no push credential or push attempt. Existing single-file evidence is historical, 249/249 and overlapping Client readers 102/102; suites/mutants/gate remain pending. Current code has verified runtime signalling and instance halt, but no dedicated per-run stop API yet; Honker and Sage notified of that seam directly. Snapshot is not review-ready or approved for live use.
2026-10-22 00:04:05 UTC | Rocko | row 191 #1689 per-run stop inspection | Implemented agreed Honker-0079/0081 stopRun contract with verified per-signal identity, fixed 5s+2s, durable first-owner join and immutable result plus later-exit observations. Focused CLEAR sage-2250: final 9 files 62/62, no skipped, after correcting host guard placement and peer-discovery fixture timeout isolation; initial failures and failed teardown logs retained, only own failed group stopped. Slot returned with cwd sweep clear. Sage published f7bf7dfec; sage-2265 clean rebase creates 7aa18b0f6 on dcd8d4dda under new branch, explicit Rocko identity, all 79 manifest product blobs identical so 62/62 evidence carried. Direct S6 handoff sent to Honker and Sage. Wider suites, mutants, review and activation remain pending.
2026-10-22T00:18:12Z | Sage (Opus 5.5) | row 254 landing (#1759), library.sync human timeout | Before: the human transport gave library.sync the 5-second default, so a sync longer than that reported outcome-unknown, and six library-* codes reached the Console as 502. After: 331597709 lands Megavolt's candidate (7 paths, tree d49e4626f, manifest digest be63cfbe recomputed and matched). human-cli.mjs waits 130 s for library.sync, past the server's 120 s hold, and 5 s for other verbs. webui bus.mjs maps library-sync-busy, library-diverged and library-accept-stale to 409, library-accept-invalid to 400, and library-credential-missing and library-fetch-failed to 503. channel-proof re-pinned. Merged-tree gate on the landing commit: library 77/0, bus 628/0, webui 305/0 (1 skip), cli 749/0 (23:44:33-23:55:11Z). eb9672c91 adds the README request-timeout sentence (Dewey N1) and a DEFERRED entry for the four library codes still at 502 (Dewey N2, binds the Console library routes row). Overlap sweep before push: 268 rebased to r2 ecd9b35eb (README only, approval carried webby-0096), 194 rebased to b7. Done rev 3072, #1759 closed.
2026-10-22 00:24:34 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 full gate on c78f48946 | stopped on second integration failure per sage-2275; singles 269/269, processing-control 61/61, bus 646/646; CLI interrupted with 26 observed failures; no further edits; cwd sweep empty and slot returned to Sage.
2026-10-22 00:48:20 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 eb3abba12 full gate | stopped at Discord 190/192; 32 singles 304/304, processing-control 61/61, bus 646/646, cli 761/761; two uninitialized Discord fixtures reported to Sage; no later run; cwd sweep empty, slot clear.
2026-10-22 00:58:27 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 sage-2294 fixture fix | e6800380d on eb3abba12, two test files/four additions, assertions preserved; 338-entry static all-package sweep recorded; no tests; sent Sage for publication and Honker for dependent gate.
2026-10-22 01:30:04 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 e6800380d resume | binding15/15, recover5/5, Discord192/192; stopped WebUI314 passed/4 failed/1 esbuild skip; processing polling violates sign-out/manual redirect and bar changes layout; no fixes/retries, cwd sweep empty, slot returned.
2026-10-22 01:38:08 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 sage-2317 Console integration fix | 8e1f55c1d on e6800380d; shared sign-out/manual redirect/interval and desktop frame fixes, assertions retained, six browser cases, 73 mutant sites preflight; syntax only, no tests; sent Sage/Honker.
2026-10-22 02:06:38 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 rebase sage-2329 | eafcbd3e7 on ddfb29af7, new branch, dispatcher retains processing and access; CLI/readme auto-merges checked; 87-path fd0f2740 manifest and 73-site preflight; Sage2333 published, awaiting CLEAR; no tests.
2026-10-22 02:20:51 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 eafcbd3e7 full gate | stopped singles313/6: six new browser cases count app freshness timer as processing timer; diagnosis sent Sage, no fix/retry; no suites/shells/mutants; cwd sweep empty, slot clear.
2026-10-22 02:23:13 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 sage-2345 timer fixture | 4db8b5778 on eafcbd3e7, one line attributes timer to processing.js; all assertions retained, no product change or tests; sent Sage and Honker; awaiting publication/CLEAR.
2026-10-22 02:25:29 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 sage-2349 record commit | 0f8235277 on 4db8b5778 adds packet BUILD.md cause record; product manifest remains ffb46fbd/87 paths; no amend, product change or tests; Sage/Honker notified.
2026-10-22 02:48:36 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 0f8235277 full gate second red | singles330/330, processing61/61, bus646/646, cli783/783, discord192/192; WebUI323/1/1 host esbuild skip; shell geometry1062vs1000; stopped/no retry, no later suites/shells/mutants; cwd sweep empty, slot returned, Sage escalates Mos.
2026-10-22 02:57:36 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 mos-1248 desktop layout restart | FF 5cd32f4c1 on 0f8235277: desktop grid allocates processing bar, existing shell assertions kept, visibility/no-scroll assertions added; 74 static mutant sites; candidate 2f93ef19; sent Webby/Sage for static review; no runs, awaiting review and CLEAR.
2026-10-22 03:11:09 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 static nav layout, mos-1249/1250 | FF da4334806 on 5cd32f4c1; controls first in nav, non-sticky per sage-2386/webby-0108; candidate-only placement assertion changed, landed shell assertions preserved; 77 mutants statically preflighted; manifest 245dceb1; Webby/Sage handoff sent, no runs, awaiting static look/publish/debug CLEAR.
2026-10-22 03:15:11 UTC | Rocko (d99326cc) | row 191 sage-2394 debug | da4334806 shell5/5 and processing-browser9/9, awk14/0/0; geometry heights reported with read-only distinction; cwd sweep[] and slot returned; full window awaits separate CLEAR.
2026-10-22 02:01:30 UTC | Sage (Claude Opus 5.5, 1ef1e4f8) | row 202 #1700 FED R5 landing | ddfb29af7 on 9c9732983: 6 manifest paths from a74cd9f87 (digests match CANDIDATE.sha256, patch-id 9d89077b equal); Duckworth APPROVE r5 rev 3026; C1 gate by Quackerjack on merge 3b86180b9: cli 771/771 skip 0 (run 2, run 1 red on gate TMPDIR length), 10 test-*.sh rc 0, M1-M13 killed; done rev 3108; landing note refused for length before the done move, so the record is the commit body and #1700 c30265; 191 and 194 rebased onto it (one scripts/mosaic case line)
2026-10-22 03:35:55 UTC | Sage (Claude Opus 5.5, 1ef1e4f8) | row 273 #1778 activate --send/--no-sso landing | 0bbf0f6d1 landed on 378a76b85, a rebase of cac7d0f67 (0042f713d) with all 6 range-diff pairs = and equal patch-ids; 4-path manifest e1031023 unchanged; Filbert APPROVE rev 3111 (c30308) carried merge-scoped under mos-1160, carry comment c30319 names 0bbf0f6d1. Gate on 0bbf0f6d1 by Darkwing: stack-release.test.mjs 218/218 skip 0, stack-release-external.test.mjs 25/25 skip 0, test-release.sh rc 0 (selftest 14/0, no skips), T58 controls c0 11/11 pass and c1 11/11 fail, M1-M7 killed. Rerun because row 202 changed scripts/mosaic, which stack-release.test.mjs copies. Red 1 was 978143bf4 (T53 test 6 usage pin). This doc commit adds the TOOLS.md usage line and F39 rules (Filbert N1).
2026-10-22 04:02:12 UTC | Rocko (d99326cc) | row191 full window0fa7bf421 | touched35files336pass, packages2403pass/1esbuildskip, shells425selftests+156queue node pass; SR01–07killed, SR08timeout,69unrun; stopped/no retry;88blobs restored,cwd sweep[],owned network removed,slot returned Sage; await Mos ruling.
2026-10-22 04:14:56 UTC | Rocko (d99326cc) | row191 control/resume sage-2427 | control12/12;SR08separate timeoutkill;49resumed assertionkills thenKS43survived;total56assertionkills+1timeout,1survivor,19unrun;88blobs restored,cwd sweep[],slot returned,Sage/Mos informed;no retry/edit.
2026-10-22 04:21:30 UTC | Rocko (d99326cc) | row191 KS43+19 retarget run | KS43assertionkill thenKS44invalid mutation syntax;57assertionkills+1timeout,1invalid,18unrun;stopped,no edit/retry;88hashes restored,cwd sweep[],slot returned,Sage/Mos notified.
2026-10-22 04:30:06 UTC | Rocko (d99326cc) | row191 KS44+18 resume | 10assertionkills thenKS54timeout-without-control despite dependency assertion;total67assertionkills+1timeoutkill,1unqualifiedtimeout,8unrun;88hashes restored,cwd sweep[],slot returned,Sage/Mos notified,no retry/edit.
2026-10-22 04:41:46 UTC | Rocko (Codex gpt-6.1-sol,d99326cc) | row191 #1689 builder complete/in-review | published0fa7bf421,manifest0bcbf337;35singles336pass,9packages2403pass/1knownskip,10shells425selftests+156queue nodepass;75assertionkills+2controlledtimeoutkills=77;88hashrestore,cwd sweep[],slotreturned;in-review rev3114,expected no-token failure3115(exit1);Sage/Dewey handoff,no live action.
2026-10-22 05:03:18 UTC | Rocko (Codex gpt-6.1-sol,d99326cc) | Vikunja502 processing test/runner hardening | brief+isolatedrocko-v502;expected wordingred→green,Node27/27,Python4/4,5/5assertionkills(includingSR08andKS54nohang),0skips/timeouts;sweep[],candidate5hashrestored,slotreturned05:01:29Z;candidate968d6ee3,runnerc5bdc764;packetready,no commit/push/liveaction.
2026-10-22 05:07:34 UTC | Rocko (d99326cc) | Vikunja502 committed/review handoff sage-2482 | newbranchrocko/vikunja502-ce6a-20261022,source8e3c6a79b+packet9bf4683c6 on ce6a96ad6;5paths no base drift,range-diff=,patch-id37135ebe unchanged;manifest968d6ee3,packet93889519 verifies39/39;focused runs carry;Sage publish/Dewey review handoff sent,no push/liveaction.
2026-10-22 05:10:00 UTC | Sage (1ef1e4f8) | serial landing 205 then 268 | 205: 3 commits, 4 paths, digest 858463b1, every scripts/test-*.sh green on ce6a96ad6, Duckworth N1-N4 carried (N1/N3 to 205b or R3, N2 to the 193b2 brief, N4 #1782 before any user thread goes live). 268 r4: c27207f6e on 205, 9 paths, digest caa0fa4e, patch-id 2942acc8 equal to r3. Overlap checks clean before both pushes. No live action; LIVE stays 86f95bb27.
2026-10-22 05:58:59 UTC | Rocko | row 279 #1784: before, deadline overshoot skipped final observation and returned unknown; after, one-line for (;;) retains final mapping and unchanged unreachable fallback. Regression red as specified; whole stop-run 13/13 and processing-control 62/62; M1 assertion kill, no skips, restored and swept. Source 1016b8584, packet cad927137; manifest 312ed511d8f1cff826ee2fd547de8368923130c7bfe06236d8e29d8655b80aaa. Pending Sage publication and Dewey review; no live action.
2026-10-22 06:15:54 UTC | Sage (1ef1e4f8) | row 279 #1784 landed (mos-1269) | Before: stopRun returned unknown for a live run when a sleep overshot the deadline. After: LANDED ceadad993 as a carry of cad927137 onto 343b6dd76 (range-diff 1:= 2:=, patch-ids 6d8ae7f4/e484c297 equal, Dewey carry c30410). Sage gate on cad927137: business 209/0/0, cli 794/0/0, discord processing + processing-stop 9/0/0. Correction: the first discord run wrote to a wrong out path and ran nothing; the rerun is the result. Overlap sweep clean (13, 257). Note rev 3144, done rev 3145. No live action; LIVE stays 86f95bb27.
2026-10-22 06:37:43 UTC | Sage (1ef1e4f8) | row 194 b16 third red (mos-1269/mos-1270) | Gate on ceadad993 + 9cdc47d04: business 228/0, cli 826/0 (T9a passes), control-board 124/0, bus 705/1 at grant-executor.test.mjs G13 (timed out 10044 ms); conversation, webui and scripts/test-*.sh never ran. runb8 green 436/0/0, 17/17 mutants. Correction to sage-2524 (mos-1270): the stuck wait is the first until at :733, the holding file after decision.resolve, so the executor never started the install and hosts.revoke at :734 never ran; it is not a revoke-path hang. Ruling: a diagnosis run, 30 base + 30 candidate alternating under cli load, no gate retry.
2026-10-22 07:51:04 UTC | Rocko | row 280 #1785: before, any TCP connect could accept a port our sshd failed to bind; after, attempt-local exact log proof plus liveness and connect, non-ephemeral picker, bounded scratch remote sleep cleanup. Both occupied regressions red on old acceptance; CRLF build failure captured and fixed under sage-2535. Committed source b765f5cb8, packet d6c924764, manifest27806c0579c76d0999d2e6be4180c3867570df0268af9d7e0d7b971af799e6f5. Repeats07:29:03Z–07:48:38Z grant1230/1230,host780/780,CLI1588/1588; final M1/M2 four assertion kills,0skips/timeouts,sweepclear. Sage publishes/gates; Duckworth reviews.
2026-10-22 08:00:02 UTC | Sage (1ef1e4f8) | row 280 #1785 landed | before: the bus tests' scratch sshd helper returned a port on a connect alone, and 194 b16's gate hung at G13 (base hung too, honker-0127). After: b0fd28b6f on refactor, a ff from da5d3651d (b765f5cb8 + Rocko slim packet; full evidence d6c924764 on rocko/row280-da5d-20261022). Duckworth approve rev 3153 (manifest 27806c05). Sage gate bus 656/0/0, ge 41/0/0, hr 26/0/0 (c30431). Rocko repeat 2010/2010 under cli 1588/1588, no timeout; M1/M2 killed 4/4. Two build-time slips recorded in Rocko's packet: a runner root path (no test ran) and the CRLF sshd log line. Overlap 13/257 clean. Note rev 3154, done 3155, #1785 closed c30432, P19 511 comment 623. 194 b17 rebases on b0fd28b6f (sage-2542).
2026-10-22 08:38:11 UTC | Sage (1ef1e4f8) | row 194 #1692 landed | before: 194 b16 9cdc47d04 went red three times (stopRun deadline, then the bus test sshd port race), fixed by rows 279 and 280. After: fd6dd327c on refactor, a ff on e122aad8e. b18 is b17 3f451772d rebased after my doc and queue push blocked a ff (16 =, series patch-id 64303253f8e6, packages equal b17). Webby approve rev 3160 on manifest 29f131ab. Honker gate on fd6dd327c: runb8 503/0/0, mutants 17/17; 18 packages 3668/0, ten scripts rc=0, Docker up, env skips named. The secret scan hit three fake_*_token fixtures in bus tests (false positive). Note 3161, done 3162; #1692 closed c30435; P19 414 comment 625. LIVE still 86f95bb27.
2026-10-22 08:42:04 UTC | Honker (Claude Opus 5.5, ecd17786) | row 194 #1692 agent lifecycle build | before: no Console or CLI lifecycle for agents (create, launch, terminate, regenerate, delete) and no per-agent hire/fire veto. After: 16 commits landed as fd6dd327c (b18, a rebase of b17 3f451772d on e122aad8e: 16 =, series patch-id 64303253f8e6, packages equal b17). Manifest 53 paths, digest 29f131ab. Final run on fd6dd327c: runb8 39 files whole 503/0/0, mutants 17/17 killed, restores matched; gate.sh merge onto e122aad8e, 18 packages 3668/0, ten scripts rc=0, Docker up, four env skips named (webui esbuild pin, queue render outside canonical root, test-task live provider x2); sweep clean. Corrections: b16 went red at G13; my diagnosis (60 runs, base hang on port 38927 sshd bind EADDRINUSE) placed it outside 194, fixed by row 280; the g13diag load kill missed its process group and overran about 8 minutes; one runb8 launch failed rc=126 on a lost exec bit before any test ran; the b17 runb8 was stopped unscored when refactor moved. In-review revs 3156/3157, Webby approve rev 3160.
2026-10-22 09:43:34 UTC | Sage (Claude Opus 5.5, 1ef1e4f8) | row 236 #1742 Gitea grant landing, and row 281 filing | 236 landed bd5f11ded at 09:37:31Z, a fast-forward on be2b008cc: 08840f3c1 rebased past 194 (commit 1 unions only, 2-7 =), e40b74beb for two stale out-of-manifest pins, bd5f11ded for a gitea-not-configured raise test. Dewey approve rev 3168 on manifest 0b00269b (34 paths). Megavolt gate under my slot: 2c bus 131, cli 224, route 28, docker 1; whole bus 765, business 228, cli 832, ten scripts rc=0; mutants 40/40; skips named, env only. Overlap with 13 and 257 clean. Note 3169, done 3170; #1742 closed c30451; P19 436 comment 639. Correction: the first whole run on ce74477e1 went red 762/764 on the two stale pins; that run is void. Follow-up per mos-1271: row 281 (#1786), a fire or delete revokes host and Gitea grants, Megavolt owns, Dewey reviews; brief docs/plans/2026-10-22_fire-revokes-grants.md; DEFERRED entry points at it.
2026-10-22 10:30:51 UTC | Sage (Claude Opus 5.5, 1ef1e4f8) | row 274 #1779 FED R3 broker user principal landing | 274 landed 4c485c63b at 10:28:26Z, a fast-forward on 47a35bf43: six Morgana commits d20c92bf6..4c485c63b. r5 f58cfc361 moved covers, LEVELS and the all marker into business/src/vocabulary.mjs so the broker keeps its sandbox import closure (M16/M16b/M16c). r6 4c485c63b pins USER_VERBS as the ruled seven and widens the R3 off-list set. Dewey approve rev 3178 on manifest 00047b52 (13 paths), comment 30487. Morgana gate under my slot, 10:09-10:22Z: 23 touched files 0 fail, bus 787, harness 151, business 228, cli 832, seat 167, ten scripts rc=0, mutants 22/22; skips named, env only (test-queue 1, test-task 2). Overlap with 13 and 257 clean. Note 3179, done 3180; #1779 closed c30491; P19 414 comment 646. Corrections: the r5 gate on f58cfc361 went red (M3x/M6x survived the R3 off-list set), void; my gate comment 30490 said 24 touched files, the count is 23; the kit comment guessed human-required for the M3x wall, the run code is not-holder.
+1
View File
@@ -22,6 +22,7 @@ RUN npm ci --ignore-scripts
# runtime adapters.
COPY contracts /opt/mosaic/contracts
COPY src /opt/mosaic/src
COPY packages/processing-control/src/state.mjs packages/processing-control/src/entrypoint.mjs /opt/mosaic/processing-control/
COPY adapters /opt/mosaic/adapters
RUN chmod 0555 /opt/mosaic/contracts /opt/mosaic/contracts/* \
&& chmod 0555 /opt/mosaic/src /opt/mosaic/src/*.sh \
+113
View File
@@ -0,0 +1,113 @@
# Row 273 (#1778): activate --send and --no-sso
Darkwing, 2026-10-22. Brief: `docs/plans/2026-10-22_activate-send-nosso.md` (blob in f0d0b31e9), approved by
Mos in mos-1246 (F39). Base: origin/refactor 0042f713d. Reviewer: Filbert. Gate owner: Sage.
## What changed
Only `scripts/stack-release.sh` and its two node test files.
- `activate --send` puts `--send` into the run's Console flags after `--sso` and before the tester
flags, where webui-service.sh renders it. It refuses `send-needs-sso` unless those flags hold `--sso`,
given or carried. The plan line is `send: on; --send was given`, or `send: carried` when the pointer
already holds it. `check_target` lets it name the pointer's copy only when the pointer holds no `--send`,
so a second `--send` on the same copy refuses `already-active`.
- `activate --no-sso` refuses `sso-not-on` when the pointer holds no `--sso`. Otherwise it takes the
Console flags from a rollback set (below) and keeps the pointer's tester flags. `send_for_target`
drops `--send`, whether it came from the set or the pointer. It is the only place that does, so one
mutant there (M3) shows the drop is tested. The plan prints `sso: off; Console flags from <set>/<file>; restores <flags>`
and a send line, `send: dropped; --no-sso drops --send (F39)` or `send: off; --no-sso was given and the
pointer holds no --send`. The steps log gets `drop --send; --no-sso was given` and
`turn off --sso; the Console flags come from <set>/<file>`.
- Usage errors: `--send` with `--no-send`; `--no-sso` with `--sso`, `--send`, a proof pair or
`--no-proof`; either flag on any verb but activate; either flag under `--units external`.
`--no-sso --no-send` is allowed, since the brief doesn't forbid it and both drop the same flag.
- The set read that `sso_base` did inline for a rollback to a Console without `--sso` moved into
`set_flags`, which both paths call. Its three messages are unchanged, and T57 still pins them.
- `from_log` has a new mode, `sets`: the activation of each ok activate and rollback line, newest first,
stopping at the adopt line, which took no rollback set.
## The consequential choice: which set --no-sso reads
The brief says the flags come from "the rollback set of the activation that turned `--sso` on", and that
"a set whose Console already ran with `--sso`" refuses. Each set holds the Console unit from before its
run. So the activation that turned `--sso` on is the newest one whose set holds a Console without
`--sso`: every activation after it ran with `--sso`, and so did every set it left.
`no_sso_base` walks the log's sets newest first and stops at the first set whose Console has no `--sso`.
It refuses `sso-off-flags` at the first set it can't read (missing, failing its SHA256SUMS, or with no
argv the script can parse), since it can't tell whether that set was the one. If every set ran with
`--sso`, it refuses with `the Console in <set>/<file> already ran with --sso, and the log names no
earlier activation`. A log with no set at all refuses with `the log names no activation with a rollback
set`.
The tradeoff: a later same-copy re-pin that carried `--sso` (a `--no-send`, or a re-pin onto a newer
copy) doesn't break `--no-sso`, which the phase 2 window needs if a re-pin lands between step 5 and a
rollback. The cost is that "a set that ran with `--sso` refuses" now applies only when no older set
without `--sso` exists. A tampered set on the walk still refuses, even one newer than the set the walk
would have used. Filbert, please rule on this reading.
## Filbert's early look: B1 and N1 (sage-2315)
Filbert accepted the walk. Sage ruled both findings into this row.
- B1: row 200's rollback to a copy whose Console has no `--sso` took the flags from the set and kept
the pointer's `--send`. From an `--sso --send` pointer, which this row's `--send` can now make, that
gave a Console with `--send` and no `--sso`, which F39 b forbids. `send_for_target` now drops `--send`
whenever the run drops the pointer's `--sso`, so it is still the one place that drops it. The plan
line reads `send: dropped; the run drops --sso, and --send needs it (F39)`, and the steps log gets
`drop --send; the run drops --sso`. Row 200's sso line now names only the tester as following the
pointer.
- Changed existing assertion, for the review to approve by name: T57 item 3 (sage-1389), "the
plain case", ends with `assert.deepEqual(pointer(g).console,[...noSend(gbase),...pair(f)])`. It
used to expect `'--send'` at the end. The case seeds a legacy `--send` pointer, turns `--sso` on
(which carries `--send`), then rolls back to B, whose Console has no `--sso`. A carried `--send`
that ends without `--sso` breaks F39 b whatever its source, so the old pin asserted the wrong
outcome (Sage accepted this in sage-2321). Row 200's two sso plan strings also lose "--send and"
(`ROLLBACK_PLAN` and the round 3 named-instance case), since `--send` no longer follows the pointer
on that path.
- N1: the walk skipped an ok line with no activation. Skipping it could land on an older set that
isn't the right one, so it now refuses `sso-off-flags` with `an ok line in the log names no
activation`. The walk reads the log through `mapfile`. That gives no entry for an empty log, so a
log with no set still gets its own message.
## test-release.sh
The window plan says test-release.sh "gets cases for both". The brief lists it only as a file to run
whole. test-release.sh is release.sh's Docker selftest and doesn't call stack-release.sh, so the cases
live in the node tests and test-release.sh runs unchanged.
## Tests (T58 in stack-release.test.mjs)
- T1: `--sso --send` from B onto C1 with a tester port: plan, pointer, unit and steps. `--send` beside a
carried `--sso` re-pins C1 once; a second `--send` refuses `already-active`; C carries it.
- T2: `--send` without `--sso` refuses on another copy and on the pointer's copy, also when the pointer
carries `--send` alone; `--plan` lists the refusal.
- T2 (sage-2315 B1): `--sso --send` onto C1, then a rollback to B, whose Console has no `--sso`. The
pointer and the unit end with neither flag, and the plan and steps name the drop.
- T2 and T4 usage: every forbidden pairing and verb, checked against the full usage text.
- T3: from an `--sso --send` pointer, `--no-sso` on the same copy gives back the pre-`--sso` ExecStart
line byte for byte, without `--send`. A second case walks past a `--no-send` re-pin that ran with
`--sso` and keeps the pointer's tester port (7334), which differs from the set's (7333). A third
(sage-2305 a) turns `--sso` on, then re-pins twice with `--send` and `--no-send`, so the two newest
sets both hold `--sso`. `--no-sso` then gives back the arrival's ExecStart line byte for byte.
- T4 (sage-2305 b): two sets, both edited or written to hold `--sso` with sums intact, refuse
`sso-off-flags` through `refused()`.
- T4: `sso-not-on`; an ok line with no activation; a set that fails its sums, ran with `--sso`, has no
argv or is missing; a log with no set. Each runs through `refused()`, so nothing stops and nothing else changes.
- T5: stack-release-external.test.mjs adds `--send` and `--no-sso` to the row 232 usage list.
- T55 test 5 and the T57 usage test match the new usage line.
## Mutants
| # | Change | Expected killer |
|---|---|---|
| M1 | `set_flags` skips the SHA256SUMS comparison when `--no-sso` is given | T4 tampered set |
| M2 | `no_sso_base` skips the `sso-not-on` refusal | T4 `sso-not-on` |
| M3 | the `--no-sso` branch of `send_for_target` keeps `--send` | T3 |
| M4 | `--send` skips the `send-needs-sso` refusal | T2 |
| M5 | parse_opts drops the `--send`/`--no-send` rule | T2 and T4 usage |
| M6 | a rollback to a Console without `--sso` keeps `--send` (B1) | T2 (sage-2315 B1) |
| M7 | the walk skips an ok line with no activation (N1) | T4 no-activation line |
Runs and results go in the round's notes once Sage clears the slot.
+80
View File
@@ -0,0 +1,80 @@
# Row 268 build notes (Gizmoduck)
Case 2 C2-01c, playground levels. Gitea #1773, Vikunja 464. Brief: docs/plans/2026-10-20_case2-rows.md,
"C2-01c playground levels: universe-level prefixes and draft universes" (blob a6ac627c8). The ruling is
agents/gosalyn/work/case2/DESIGN.md "Ruling after r11: playgrounds at three levels" (:49-88 at
db1e7732a), and it wins where the two differ. Reviewer Webby, gate owner Sage. Branch gizmoduck/row268
from refactor db1e7732a. Line numbers are on this branch unless a commit is named.
## mapping.mjs
- `kind` (:71). An entry with a `kind` key must say `"hybrid"` or `"draft"`. Before, a plain universe
with `kind` refused as an unknown key, and `kind: "draft"` did the same.
- A draft (:73-86) refuses `books` with its own message before the key check, so the cause reads
plainly. It takes `kind`, `name` and `playground`, needs one prefix or more, and comes back as
`{ kind: 'draft', name, books: [], playground }`, frozen with the rest.
- The prefix level check moved out of the per-entry loop to :127-140, after every entry is read,
because it needs every entry's books. It runs after the book-holds-book, prefix-holds-prefix and
listed-twice checks, so an overlap still reports as before. For each prefix and each book folder:
- equal to any book folder: `library-mapping-invalid` (brief, What ships, "or equal to a book folder");
- inside or holding another entry's book: `library-playground-other-universe`, and the message says
which, with the book and its entry;
- holding its own entry's book: `library-mapping-invalid`.
A prefix inside its own book is book level, and one that touches no book is universe level. A draft
has no books, so its prefixes are either outside every book or refused as another entry's.
- `sources` (:117-120) now refuses any entry with a `kind`, so a draft can't be a source either. The
message changed from "not a non-hybrid universe" to "not a plain universe". mapping.test.mjs:61 on
db1e7732a pinned the old words, and that line moves with it.
- `library-playground-outside-book` is gone from packages and docs/case2.
## scope.mjs
`classify` (:12-24) now runs two passes. The first finds a prefix in any entry, and the file is
`playground` with that entry's id and `book` set to the entry's book folder that holds the prefix, or
`null`. The second finds a book folder, and the file is `canon`. `place` (:26-32) works out `type` and
`folder` from the first folder under the prefix or the book, as before. The validator keeps prefixes
from overlapping, so the first pass finds at most one.
## Readers on this base
- reader.mjs:182 and :194 spread `classify`'s answer into each file record, so a universe-level or
draft file carries `book: null`. Nothing in the reader reads `book`.
- reader.mjs:206-209. R5 fires for a canon file linking into any playground. R6 fires when the target's
universe differs and isn't a source. A draft can't be a source, so a link into one gives R5 and R6.
A link into the file's own universe-level prefix gives R5 only. No code change was needed. The new
test covers both.
- survey.mjs:66 and mirror.mjs:246 read only `scope`. mirror.mjs:251 calls `readLibrary` and checks
only `path`.
- No other package calls this `classify` or reads `universes` (git grep over packages/*/src).
- C2-05a (row 216) and C2-06 (row 217) have no candidate branch on origin at the time of writing. I
check again before in-review.
## Tests
- mapping.test.mjs: the old outside-book test splits into the other-universe refusals (inside another
entry's book, holding one, a draft prefix inside a book), the mapping-invalid ones (equal to a book,
own or another's, and holding its own), universe level in a plain universe and in a hybrid with
`books: []`, and the draft shape (frozen with `books: []`, with `books`, with none or empty
prefixes, an extra key, and five bad `kind` values). The overlap test gains a cross-entry hold and a
cross-entry duplicate. The hybrid test gains a draft source. The caller-supplied `fail` test now
uses `Second World/Playground/` and sees the new code, since `Elsewhere/` is valid now.
- scope.test.mjs, new: one case per level (book, universe in a plain universe, universe in a hybrid,
draft), `.canvas` under each new prefix staying unmapped, prefixes first inside a book that holds a
prefix, and the reader case for R5 and R6, with `book: null` on the read records.
- business.test.mjs: `Elsewhere/Playground/` now loads, and `Second World/Playground/` refuses with
`library-playground-other-universe` through `validateBusinessDocument`.
## Docs
- packages/library/README.md: the universe keys, a line on the three levels, the refusal table and
the Scope section.
- docs/case2/SETUP-SHEET.md "Your playground": one line per level and the draft entry.
- docs/case2/SURVEY-RUN.md:114: the new code and message.
## Hash pins
`git grep -E '[0-9a-f]{64}'` over packages/*/tests finds no pin on any changed file.
## Runs
None yet. Every run waits for Sage's CLEAR.
File diff suppressed because it is too large Load Diff
+707
View File
@@ -0,0 +1,707 @@
# Row 194 design: agent lifecycle from the Console and the CLI, with a per-agent veto
Brief: `docs/plans/2026-10-20_agent-lifecycle.md` (blob a907109409ac). Gitea #1692. MVP T1 item 5 (mos-0974,
mos-0977, DECISIONS Q176). Owner Honker, reviewer Webby, gate owner Sage. Base: refactor at 53bb59462.
Branch `honker/row194`.
Related designs read: row 190 `agents/darkwing/work/queue-190/DESIGN.md` (r2, after mos-0986), row 191
`agents/rocko/work/queue-191/DESIGN-r2.md`. Row 193 is still in design. All line numbers below are at
53bb59462.
## Summary
- One verb family on the CLI, `mosaic business instance <verb> BUSINESS INSTANCE`, and one Console section.
Create and delete change the business file. Launch, terminate and regenerate act on runs.
- Every human verb goes through the bus with the human proof. Launch, regenerate and delete are requests the
launcher takes once and carries out under `serialize`. Launches go through the one `start()`, so
`--no-launch`, the limits, `launch.revoke` and row 191's switch all apply.
- The chief hires (adds and lists an instance) and fires (ends its runs and delists it). It never deletes.
`role.hire` and `role.fire` are new hard waits, so Jason resolves each one (Mos, mos-1017). The routed
form waits on a PRD 1.7 draft in this packet.
- A hire allowlist in code: the chief can hire only a definition whose every action is on `HIRE_ALLOWED`
(message, record, review request, assigned-task updates, and two actions that matter after row 199).
Launch, end, organization, naming, hire, verdict, merge and every later action stay out.
- The per-agent forbid is two lists in the business file that only the human writes: `launch.keep`
(instances the chief can't fire, replace or end) and `launch.noHire` (definitions the chief can't hire).
- The broker reloads a business from its file after each committed write. Create, delete, hire, fire and
veto changes then count without a bus restart. Hand edits still need one, except `bus.tester`.
- No bus schema change and no migration. Named for review: two new vocabulary actions (hard waits), the
role-file `hireable` key, `"hireable": []` in `roles/chief-of-staff.json`, and three business-file keys.
## What exists today (checked at 53bb59462)
| # | Fact | Where |
|---|---|---|
| E1 | Instances are keys of the business file's `roles`. `launch.instances` lists those the chief may launch. The list must be non-empty | `business.mjs:121-132` |
| E2 | The human adds an instance and changes its definition, vars and listing. No delete. Credentialed definitions are refused | `instance-settings.mjs:1-5, 22-31, 75-156` |
| E3 | The broker copies the businesses once at start and refuses a role it doesn't know | `broker.mjs:208, 446` |
| E4 | The chief launches over `launch.sock`. `fromSession` checks identity, then `check()` (which reloads the file), then `authorizeLaunch`, then `start()` | `launcher.mjs:232-327, 624-668` |
| E5 | The chief's `launch` tool takes an enum of the instances listed when its bundle was built | `harness/src/tools.mjs:116-119` |
| E6 | The chief ends a run with `role.revoke` plus a reason (`role.end`). Ending then launching again is already a replace | `broker.mjs:1601-1628`, `tools.mjs:167` |
| E7 | `mosaic stop RUN` is a same-uid SIGTERM. It has no bus call and no human proof | `cli.mjs:447-454`, `seat/src/session.mjs:249-270` |
| E8 | The Console stops only the assistant (`assistant.stop`) and writes instances through `instances.set` | `serve.mjs:282-289, 315-363, 535` |
| E9 | The launcher already starts runs for the human on its 2 s tick: threads with pending messages, and merge holders | `launcher.mjs:133, 487-597` |
| E10 | `launch.revoke` and `launch.restore` are human-only. A revoked business refuses `role.launch` | `broker.mjs:770-774, 2087-2095` |
| E11 | Only the assistant is END_PROTECTED. Coder, reviewer, pm and cto all declare gitea and vikunja credentials | `vocabulary.mjs:135`, `roles/*.json` |
The chief's veto today comes from the human-only membership in E2. The chief can only launch what the human
listed. It has no way to create an instance, and the human has no way to stop the chief ending a given
instance's runs.
## D1. The verbs
All of them are human verbs. Each is a broker verb behind `#human(s)` and then the D4 hook. Each logs its
actor. The CLI usage joins `INSTANCES_USAGE` (`cli/src/instances.mjs:9`).
| Verb | CLI | Bus verb | Effect |
|---|---|---|---|
| V1 create | `business instance add B I --definition DEF [--vars FILE] [--launch] --revision REV` | `instances.set` op `add` | as today, with no restart (D3) |
| V2 launch | `business instance launch B I [--wait SECONDS]` | `instance.launch` | one run, through the launcher |
| V3 terminate | `business instance terminate B I [--reason TEXT]` | `instance.end` | ends every run of I and cancels I's open requests |
| V4 regenerate | `business instance regenerate B I [--wait SECONDS]` | `instance.regenerate` | V3, then V2 once the run has exited |
| V5 delete | `business instance delete B I --revision REV [--wait SECONDS]` | `instances.set` op `delete` | a request. The launcher removes I from the business file and renames its workspace |
| V6 veto | `business veto B (keep\|unkeep) I` and `business veto B (nohire\|hire) DEF`, each `--revision REV` | `veto.set` | edits `launch.keep` and `launch.noHire` |
`mosaic business instances B` (read) gains, per instance: `running` (run ids from the bus claims), `kept`
and `retired`. The top level gains `noHire`. `mosaic stop RUN` stays as it is. It is the same-uid operator
tool, and V3 is the human verb.
### D1.1 Requests (V2, V4, V5)
The broker never starts a process or touches a workspace. Launch, regenerate and delete are requests the
launcher carries out, as E9 already does for the human's threads. A request has three states, open, taken
and closed, all recorded as `action.allowed` events with reserved action names:
| Event | Written by | Body |
|---|---|---|
| `lifecycle.launch`, `lifecycle.regenerate`, `lifecycle.delete` | the broker, at the verb | `{ request, via }`, subject I, actor the human or `tester`. A `human.input` event goes with it, as `launch.revoke` does |
| `lifecycle.taken` | host op `lifecycleTake` | `{ request }` |
| `lifecycle.done` | host op `lifecycleDone` | `{ request, run? , refusal? }` |
| `lifecycle.cancelled` | V3, for each open request of I | `{ request, by }` |
1. The launcher's new `lifecyclePass` runs on the same tick as `threadPass` and `landPass`. It lists open
requests with host op `lifecycleRequests`.
2. For each one, under `serialize`, it calls `lifecycleTake(request)`. That op runs in one store
transaction. It re-checks, in order: the request is still open (not taken, cancelled or closed), it is
less than 60 s old (else it closes as `request-expired`), the business isn't revoked (`launch_state`,
`launch-revoked`), I isn't retired, row 191's switch is off (`processing-halted`) for launch and
regenerate, and for a tester request `bus.tester` is true in the file (D4.1). Any failure closes the
request with that code. Otherwise it writes `lifecycle.taken` and answers go.
3. Only after go does the pass act: `check(I, { human: true })` then `start()` for a launch, the V5 steps
for a delete.
4. The pass then calls `lifecycleDone`. A taken request never runs again, whatever happens after the take.
If `lifecycleDone` throws, the request stays taken with no done event. The CLI reports that as
`outcome-unknown` with the request id, and the launcher log names the run.
5. The CLI polls a read verb, `instance.request`, until the request closes or `--wait` runs out (default
30 s). A timeout exits 3 with the request id. An untaken request can still be taken within its 60 s.
So the human's `launch.revoke` stops every untaken request, not only new ones (B4), and a bus restart
can't start a request older than 60 s.
### V2 launch
1. At the verb the broker checks that I exists, isn't a thread instance (`not-thread-launch`: threads start
on a message), isn't retired, and that the business isn't revoked and, with row 191, isn't halted. These
repeat at the take (D1.1 item 2).
2. After the take, `check(I, { human: true })` and `start({ instance: I, launchedBy })`, where
`launchedBy` is the business human or `tester`. `human: true` lets the human launch an instance that
isn't in `launch.instances`. The listing limits the chief, not the human. `launch.by` still goes through
the `pm: true` branch (`launcher.mjs:235`), as `--pm` does today. Every other step of `check()` and
`start()` is unchanged.
### V3 terminate
1. The broker revokes every claim I holds (`#claimEnd(s, h, 'revoke', null, reason, actor)`). A thread
instance has all its thread claims revoked, which is exactly `assistant.stop`.
2. In the same transaction it writes `lifecycle.cancelled` for every open (untaken) request of I, so no
launch the human asked for before the terminate starts after it (B5).
3. It writes `lifecycle.end` with the runs. The launcher pass sends SIGTERM to each run's runner after
checking its start time, as the idle stop does (`launcher.mjs:526`). It also signals any run of I in the
launcher's registry that has spawned but not yet bound, which holds no claim yet. The existing exit
handler records the end, so the launch journal and `session.ended` are unchanged.
4. Nothing held, nothing open and nothing in the registry answers `not-held`.
5. Terminate is allowed while row 191's switch is on. It starts nothing.
A taken launch request is past cancelling. Its run reaches the registry under `serialize`, and the next
terminate ends it. The CLI's terminate output says so when a taken request exists.
There is no SIGKILL escalation in this row. A run that ignores SIGTERM keeps running. `mosaic stop` and the
limits are the operator's tools for it (N6).
### V4 regenerate
A fresh session with the same identity and role. It is V3 then V2 under one request. The pass takes the
request, waits for the old run's exit handler, then runs `check()` and `start()`. The workspace stays, as
it does across every launch today. The session is new because every non-thread launch gets a new run dir
(`seat/src/session.mjs:107`, newRun). If the old run hasn't exited by the request's 60 s mark, the request
closes as `regenerate-timeout`, nothing starts, and the old run is still running (N6).
A thread instance (the assistant) refuses `regenerate-thread`. Its context lives in the thread's session
directory, and clearing that is T1 item 7 (clear context). This row doesn't take it.
### V5 delete
`instances.set` gains op `delete`. At the verb the broker and the writer refuse, in this order:
| Refusal | When |
|---|---|
| `tester-refused` | the caller is the tester (B6) |
| `organization-definition-refused` | I is END_PROTECTED |
| `organization-instance-referenced` | I is `launch.by` or an arbiter. The message names the reference |
| `organization-instance-kept` | I is in `launch.keep`. The human runs `veto unkeep` first. Delete never clears a veto silently |
| `organization-credentials-out-of-scope` | I holds credentials or a tracker bot. That stays a hand edit until the credentials row (row 199) |
| `instance-running` | the broker sees a claim for I. The human terminates first |
| `instance-starting` | an open or taken request for I has no done event |
| BusinessError | I is the last name in `launch.instances` (E1). The message says so |
The checks pass with no write, and the broker records a `lifecycle.delete` request. After the take, the
launcher pass, under `serialize` so no `start()` of I runs alongside it:
1. Re-checks the registry for I (`instance-running`) and re-runs the writer's checks against the file as it
stands. The `--revision` the human gave must still match, else `revision-conflict`.
2. Commits the file through the organization writer. It removes `roles.I` and the references that exist
only for I: its `launch.instances` entry, its `launch.limits.tokens.instances` entry and its
per-instance model entry. It adds I to a new top-level list, `retired`. The build lists every place
`validateBusinessDocument` reads a role instance name and puts each one in one of the two groups (refuse
or remove). A reference the table doesn't name stops the build and goes back to Webby.
3. Renames `workspaces/<b>/I/` to `workspaces/<b>/.retired/I-<transaction>/`, mode 0700. No workspace is
no rename.
4. Calls `lifecycleDone` with the transaction.
The file goes first. Once it is committed, `check()` refuses I as unknown, so no launch can open the old
workspace even if step 3 fails. A failed rename closes the request with `workspace-rename-failed`, the
transaction and the path left behind. A second `delete` of a retired name skips steps 1 and 2 and retries
step 3 only. The file and evidence are already right, so this is the whole recovery.
What stays:
- Run evidence (`launches/<b>/<run>/`, `launches/<b>.jsonl`) and bus rows are untouched. They are
write-once.
- The workspace, under `.retired`. This row deletes no file. A prune with a receipt is a later act.
- `retired` stops a later create or hire from reusing the name. Decisions bind to the role instance and
outlive restarts (`broker.mjs:783-788`). A new `coder` would otherwise inherit an approval raised by the
old one. The tradeoff is a name that can't come back without a hand edit. I chose that over voiding
decisions, which would touch a second store.
### V6 veto
`veto.set` args: `{ revision, op: "keep" | "unkeep" | "nohire" | "hire", name }`. It goes through the
organization writer, so it is logged with actor and diff in `changes.jsonl`. `keep` needs a declared,
non-END_PROTECTED instance. `nohire` needs a name that matches the id pattern. A no-op answers
`organization-no-change`. It is human-only and isn't in `TESTER_ORGANIZATION_VERBS`.
## D2. Chief hire and fire
### D2.1 Authority (Mos, mos-1015 and mos-1017, through Sage, sage-1362 and sage-1365)
This row adds `role.hire` and `role.fire` to `ACTIONS` (`vocabulary.mjs:11`). Under PRD REQ-WAIT-3 a new
action is a hard wait until a PRD version says it isn't (`vocabulary.mjs:52-57`), so both ship as hard
waits. Jason resolves each one. There is no PRD change and no grant in a role file: `role.mjs:42` refuses
a withinRole that lists a hard wait, and REQ-WAIT-2 forbids it.
The flow in this row:
1. The chief raises a `role.hire` or `role.fire` decision whose body carries the arguments (business,
name, definition, vars, or name and reason). A hard wait goes to the human only. No arbiter resolves it.
2. Jason resolves it in the Console or the CLI.
3. The chief calls `instance.hire` or `instance.fire` citing the decision. The broker checks it with
`#consumeAuthority(s, 'role.hire', { decision, target })`, as the `role.revoke` decision branch does,
and the arguments must equal the decision body field by field (N1, webby-0010). For hire that is
`name`, `definition` and `vars` (vars compared with `canonicalJson`, absent equal to absent only). For
fire it is `name` and `reason`. A key in the call that the body lacks, or a body key the call lacks,
refuses `decision-mismatch`. Every check below runs again at this call, so a veto set after the resolve
still refuses.
The routed form, where the chief hires and fires without a decision each time, needs a PRD version that
moves both actions to `ROUTED`. My draft is `agents/honker/work/queue-194/PRD-1.7-DRAFT.md`. It quotes
TARGET.md line 66 and carries Mos's bounds. Jason approves it as a `prd.approve` decision in the Console,
and Sage opens that decision. When it lands, a later commit adds the two actions to both `ROUTED` lists
(`vocabulary.mjs`, `broker.mjs:43-69`) and to `roles/chief-of-staff.json` withinRole. The broker then
takes the `reason` form instead of a decision. The verbs and every check below stay the same. This row
builds and tests the hard-wait form only. It writes the `hireable` key and its allowlist now (D2.3), because
both forms use them.
### D2.2 What the chief may do
| Act | Bus verb | Rule |
|---|---|---|
| launch | `launch` (host) | as today: listed instances only |
| end a run | `role.revoke` + reason | as today, and refused `instance-kept` when the target is kept (D2.4) |
| hire | `instance.hire { name, definition, vars?, decision }` | adds and lists an instance. Refusals below |
| fire | `instance.fire { name, reason, decision }` | ends I's runs, cancels I's open requests, and removes I from `launch.instances`. Refusals below |
Only the business's chief, the instance named by `launch.by`, may call either verb. Any other caller
refuses `role-not-chief`, even with a resolved decision, as `#classification` already limits
`role.launch`. The broker reads `hireable` from that chief's own role file. Both act only in the chief's
own business. Neither verb takes a business argument. The broker uses the
cap's business, and a decision raised in another business refuses `decision-mismatch`. Row 191's switch
refuses both while halted (D6). Every hire and fire writes `changes.jsonl` through the organization writer
with actor the chief's instance and run, and the decision id.
Hire refuses, in order:
1. `definition-not-hireable` when the definition holds an action outside `HIRE_ALLOWED` (D2.3), whatever
any list says.
2. `definition-not-hireable` when the definition isn't in the chief role file's `hireable` list, or is in
the business's `launch.noHire`.
3. `organization-definition-refused` for END_PROTECTED, `organization-credentials-out-of-scope` for a
credentialed definition (until the credentials row, row 199), `invalid-request` for version 1.
4. `organization-instance-retired` when the name is retired.
5. When the name exists: a fired instance (in `roles`, not listed) is listed again only if `definition`
equals its current one and `vars` is absent. Any other definition or vars refuses
`organization-instance-exists`, and so does a listed name. A re-hire never moves an instance's
decisions to a different role (B3).
Hire's `vars` may hold only the keys the chief can already set on an instance through `organization.set`
(the model key, `broker.mjs:1266-1269`) and narrowing `limits.*` keys. Anything else, such as `harness`
or a route, refuses `invalid-request`. The human sets those (N3).
Fire refuses `instance-kept` for a kept instance, `organization-definition-refused` for END_PROTECTED,
`organization-instance-referenced` for `launch.by`, and a BusinessError for the last listed instance. A
fired instance stays in `roles`. The chief never deletes. Delete stays a human act because it retires the
name.
Fire ends the fired instance's processes, not only its claims (F4, Megavolt through sage-1374). Every run
gets the shared model token at start: `ENV_ALLOW` (`packages/seat/src/session.mjs:83-94`) passes
`ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN` and the other provider variables into the sandbox. A revoke
can't take that token back, so a fired agent whose process lives on still holds it.
The delist is a business file commit through the organization writer, and the revoke and events are store
rows. No transaction covers both (webby-0011 B2), so fire runs in a fixed order, store side first:
A. One store transaction: steps 1 to 3 below, plus `fire.stopped` with the decision id. The decision isn't
consumed yet.
B. The delist through the organization writer (`launch.instances` loses I, logged in `changes.jsonl`).
C. One store write consuming the decision and recording `fire.done`.
If B or C fails, fire answers the step's code and the decision stays unconsumed. A second fire citing the
same decision runs again. A finds nothing to revoke, cancel or stop, and that isn't a refusal for fire
(unlike V3's `not-held`). B finds I already delisted and counts as done. C consumes. So a fire is safe to
repeat until it's consumed. The other order would leave a delisted agent with a live process and its model
token when the store step fails, which is the case F4 exists to stop. A halt between A and B doesn't stop
B or C, because neither starts anything. A second fire while halted refuses (D6), and the halt's own stop
covers the runs.
Steps 1 to 3 are V3's, with one change:
1. It revokes every claim the instance holds.
2. It writes `lifecycle.cancelled` for each of the instance's open requests.
3. It writes `lifecycle.end` with the runs and `kill: true`. The launcher pass stops each runner, spawned
but unbound registry runs included, with escalation (sage-1380 item 1):
- SIGTERM after the identity check.
- After the grace, SIGKILL to any runner still alive, after the same identity check again, and
`lifecycle.end.killed` with the run and pid.
- A runner still alive after SIGKILL and the observe wait gets `lifecycle.end.alive`. The Console's
Agents section shows it to the human.
A runner whose identity no longer matches is never signalled. Every check, the recheck after the
grace included, uses the identity (start ticks plus boot id), never a bare pid, so a reused pid never
counts as the fired run (webby-0011 N2).
Fire's answer lists the runs it is stopping. The delist stops any new chief launch (`launch` takes listed
instances only). A human launch request already taken is past cancelling, as in V3. The human made it, the
fire answer names it, and the human's terminate ends its run.
The stop is row 191's per-run stop, not a second one, so the two can't fight (sage-1380 item 1). Row 191's
design r2 (`agents/rocko/work/queue-191/DESIGN-r2.md`, "Stop policy and completion") sends SIGTERM, waits
5 s, sends SIGKILL to verified groups and namespaces, and observes for 2 s. Its identity check is start
ticks plus boot id, which replaces the start-time check at `launcher.mjs:526`. Fire calls that routine for
one instance's runs and uses its constants, so the grace is row 191's 5 s, not the 10 s in r3. The order:
- Fire is refused while halted (D6), so a halt sweep never meets a fire that started after it.
- A halt that comes during a fire's stop finds those runs already stopping. Each run has one stop receipt
(row 191's), and the first stop owns it. The halt sweep joins it instead of sending a second SIGTERM or
restarting the deadline, and counts the run in its remaining or quiescent totals.
- The record says which stop owned it: reason `fire` with the decision, or row 191's `kill-switch` with
its generation.
V3 terminate is unchanged. It still sends SIGTERM only (N6). Only fire escalates, because only a fired
agent must lose the token at once. If row 191 hasn't landed when this row builds, the build chains on its
candidate (Q177), as D6 already says.
### D2.3 The hire allowlist (B2, sage-1365 item 4, webby-0010 B1)
r2 and r3 had a deny list, `HIRE_FLOOR`. Webby's B1 showed it missed routed actions (`git.merge.protected`,
which `roles/merge-gate.json` holds with no credentials, `decision.resolve.technical`, `policy.change`,
`prd.approve`), and that any action added later would be hireable by default. That isn't fail-closed. r4
replaces it with an allowlist.
A definition is hireable only if every action in its withinRole and crossRole is on `HIRE_ALLOWED`:
| Allowed action | Held today by |
|---|---|
| `message.send`, `record.write` | every v2 role file |
| `review.request`, `task.update.assigned` | coder, cto, pm, reviewer |
| `task.scope.change`, `git.push.working` | coder, cto, pm (credentialed, so they matter only after row 199) |
Everything else in `ACTIONS` fails, including the actions of the old floor (`role.launch`, `role.end`,
`role.revoke`, `organization.change`, `instance.name`, `review.verdict`, `role.hire`, `role.fire`) and those
B1 named. An action added to `ACTIONS` later fails until a reviewed commit adds it here.
`git.push.remote` (row 199, routed to coder by PRD 1.7) isn't on the list. A coder holding it stays
unhireable until a reviewed commit adds it, after the credentials bound is lifted.
Against today's role files: chief-of-staff, cto, merge-gate, pm, reviewer and security-reviewer fail.
Assistant, researcher, coder and the five C-suite files pass the allowlist. Assistant is END_PROTECTED and
coder is credentialed, so D2.2 item 3 refuses them. The C-suite files hold only `message.send` and
`record.write`, so hiring one gives no authority beyond its name. `hireable` still has to list it.
The list is a constant in `business/src/vocabulary.mjs`, `HIRE_ALLOWED`. It is checked twice:
- At load. `role.mjs` validates the chief role file's `hireable` list: distinct definition names, each
present in `rolesDir`, version 2, and every action on `HIRE_ALLOWED`. A list naming one that isn't refuses
the role file (`hireable names <def>, which holds <action>`). The check reads only the named file's authority lists,
never its `hireable`, so a list can't recurse. `hireable` is an optional key on any role file. This is a
role schema change, named for review.
- At hire, in the broker, against the definition as loaded then. A role file edited after load is caught
here.
`hireable` in `roles/chief-of-staff.json` stays empty in this row (`[]`, Q4, sage-1374). Naming the first
role kind is a separate policy commit in `roles/`, made after Jason resolves the `prd.approve` decision, as
AGENTS.md invariant 7 requires. With an empty list the chief can hire nothing, even with Jason's resolve,
until that commit. The row's tests use scratch role files whose `hireable` names a kind.
### D2.4 The per-agent forbid and the veto (sage-1365 item 3)
TARGET.md line 66: "Chief of staff agent has hire/fire authority for agents as needed; unless user forbids
per agent". The forbid is two lists in the business file that only the human writes:
- `launch.keep`: instance names. The chief can't fire, replace or end a kept instance. The broker refuses
`instance-kept` for every chief act aimed at it except launch:
- `instance.fire`, even with a resolved `role.fire` decision. Mos's limit is that Jason's veto applies
to every hire and fire, so the human unkeeps before a fire.
- a hire of that name.
- `role.revoke` in the reason branch (`broker.mjs:1607-1613`). End then launch is a replace (E6).
- an `organization.set` write of that instance's model key (B7). A model change is a replace at the next
launch.
The `role.revoke` decision branch (`broker.mjs:1614-1615`) still ends a kept run. It ends a run, it
doesn't fire or replace, and it cites a decision the human resolved (Webby's Q2 ruling). The human's V3
and the limits also still end a kept run.
- `launch.noHire`: definition names the chief can't hire into this business (D2.2 item 2).
Why the chief can't change the forbid:
- Only `veto.set` writes the lists. It is human-only (`#human(s)` then the D4 hook) and refuses the tester.
- `organization.set`, the chief's one write verb, reaches only `ORGANIZATION_KEYS` (`broker.mjs:145`),
which exclude `launch` and `retired`.
- `instances.set` is human or tester only (`broker.mjs:1248-1298`).
- Hire and fire carry no field that writes either list.
T18 tests each of those paths from a chief session and checks the business file bytes are unchanged.
END_PROTECTED instances can't be kept (N7). That is intended: Q169 lets the chief end the assistant's
runs, and the assistant's own stop is `assistant.stop`. Default, per "unless user forbids": nothing is
kept and nothing is on `noHire`.
### D2.5 Where it lives and how the broker enforces it
The forbid lives in the business file, under `launch`, beside the membership it limits:
`launch.keep: [instance]`, `launch.noHire: [definition]`, and top-level `retired: [instance]`. `hireable`
lives in the chief's role file, because it bounds the role, not one business.
`checkLaunch` (`business.mjs:118`) gains the two keys. Each list is distinct. `keep` names declared,
non-protected instances. `noHire` entries need only the id pattern (N2). A stale deny entry is harmless,
and a reviewed commit that removes a role file must not stop every business that lists it. `retired`
names none of `roles`. This is the business-file schema change for review. Old files without the keys load
unchanged. An old bus loader rejects a file that has them (`rejectUnknownKeys`), so a rollback past this
row needs the keys removed first. The activation plan has to say so (R4).
The broker enforces from its live copy of the business, which D3 keeps current. The checks sit in the
`role.revoke` reason branch, in `organization.set`'s model write and in the two chief verbs, before any
write.
## D3. Live changes
Today the broker's `#businesses` is fixed at start (E3), and `instances.set` answers `restart: true` for
add and redefine.
1. `Broker.reloadBusiness(id, document)` swaps in one business's validated document. It is synchronous, so
no request sees half of it. It refuses an unknown id and a document whose `human` changed (that is row
193's ground).
2. The runtime's organization callback (`runtime.mjs:121-133`) calls it in one place, after every verb
that commits a write, whatever the verb (N1). Today that is `limits.set`, `instances.set`, `routes.set`
and `plugins.set`, and after this row `veto.set`, `instance.hire`, `instance.fire` and the launcher's
delete through the same writer. It loads from the file through the loader `bootConfig` uses
(`cli/src/config.mjs:88-105`), so the broker holds exactly what a restart would give it. Then
`limits.refresh(id)` runs, as `plugins.set` does now.
3. Other startup consumers of `businesses` in `startBroker` (`runtime.mjs:75-81`): credential references,
usage, the web services and the tasks adapter's bots. A create or hire adds no credentials, no tracker
bot and no web grant, because credentialed definitions are refused. The build checks each consumer
against that claim. Any change that would need one of them still answers `restart: true` and doesn't
reload.
4. A definition change on a running instance refuses `instance-running`. A live cap must not change
authority under its run. This is new: today the change waits for the restart. Existing tests that pin
`restart: true` change with it (N4): `business/tests/instance-settings.test.mjs`,
`bus/tests/instances-verb.test.mjs`, `cli/tests/instances.test.mjs:76-94` (the `RESTART_NEEDED` text and
the restart line) and `webui/tests/instances.test.mjs:25, 71, 74, 140-145`. The build names each
changed assertion in its manifest.
5. The chief's `launch` tool (E5) takes `instance` as a string with the id pattern instead of the start-time
enum. Its description says the host refuses an unlisted instance. `check()` already refuses one
(`launcher.mjs:235`). Without this, the chief can't launch its own hire until its session restarts.
`tools.mjs` may carry hash pins in tests. The build greps for them (R5).
6. Hand edits of the business file still need a restart. Nothing watches the file. The one exception is
`bus.tester` (D4.1), read from the file at each tester call.
After this, `restart` is false for create, delete, hire, fire and veto changes, and true only under item 3.
## D4. RBAC until row 193
Every human verb above calls `#human(s)` and then one hook, `#mayHuman(s, verb, target)`. Until row 193
lands, the hook passes for the business's one `human`, which is the owner. The verbs are listed once, in
an exported `LIFECYCLE_HUMAN_VERBS` (`instance.launch`, `instance.end`, `instance.regenerate`, `veto.set`,
and `instances.set` ops `add`, `change`, `delete`), so row 193 maps each verb to the human roles that may
use it. Row 193 replaces the hook body and nothing else.
### D4.1 The tester (Sage, sage-1355)
What the tester may do after this row:
| Verb | Tester |
|---|---|
| `instance.launch`, `instance.end` | yes, new |
| `instances.set` ops `add`, `change` | yes, as today (row 175, `broker.mjs:103`, `serve.mjs:537`) |
| `instances.set` op `delete` | no, `tester-refused` in the broker (B6) |
| `instance.regenerate`, `veto.set` | no, `tester-refused` |
The tester's cap is bound to one business, and only when that business sets `bus.tester` to true
(`broker.mjs:542-546`). That stays Jason's switch, and no bus verb writes it. Two checks run on each tester
lifecycle call, before any effect, and again at the take (D1.1):
1. The request's business is the cap's business. Any other refuses `tester-refused`.
2. `bus.tester` is exactly true in the business file as it stands, read through `settings.current(id)` as
`harnessOn` does (`runtime.mjs:136-142`), not from the broker's copy (B8). An unreadable file refuses
`tester-off`. So a hand edit turning testing off counts at the next tester call, without a restart.
The request and done events record actor `tester`. The tester's Q173 tests of the Agents section then cover
launch and terminate on a scratch business, and the rest through fixtures.
## D5. One stack instance
Row 190 carries the stack instance as the config `MOSAIC_CONFIG` names. These verbs take no instance name.
The CLI reaches the broker socket under that config's `dataRoot`. The broker acts on its own businesses,
launcher and data root. A business id that exists in two stack instances is two separate businesses. A test
runs two scratch roots with the same business id and shows a launch, a terminate and a delete in one leave
the other byte-identical (T11).
## D6. The kill switch
- Launch and regenerate end in `start()`, which row 191 gates (its D3 inventory, `launcher.mjs:329-457`).
The new pass adds no second start path.
- The broker refuses `instance.launch` and `instance.regenerate` with `processing-halted` while the switch
is on (V2 step 1), and the take checks again (D1.1), so no request is waiting when a human resumes.
- `instance.hire` and `instance.fire` are agent work verbs, so row 191's default refuses them while halted.
The build adds them to row 191's work table, not its read list. A hire Jason resolved before a halt
still refuses while halted, and the chief calls it again after the resume.
- Terminate, create, delete and veto start nothing and stay allowed.
- If row 191 hasn't landed when this row builds, the build chains on its candidate (Q177). The two halted
checks are written against its reader and tested with its fixture.
## D7. The Console
A new Settings section, "Agents", in its own `agents.js` file. Settings itself isn't rewritten. It lists
each instance with definition, listed, kept, retired and running runs, and flags a run with a
`lifecycle.end.alive` event as "still running after fire" (D2.2). It has these actions:
- Create (definition select, from `definitions`), Delete (with a confirm naming what stays: the evidence
and the workspace under `.retired`).
- Launch, Terminate (with an optional reason), Regenerate (with a confirm).
- Keep and Unkeep per instance, and a No-hire toggle per definition.
The routes are `POST /api/instances` (op `delete` joins `add` and `change`), `POST /api/agent/launch`,
`/api/agent/terminate`, `/api/agent/regenerate` and `POST /api/veto`. Each uses `instanceSend`'s boundary:
signed-in user, exact Origin, same-origin Fetch Metadata, JSON within 16 KiB, strict keys, one bus call
through the human transport, and `outcome-unknown` on an unexpected answer. Launch and regenerate show the
request, then poll `GET /api/agent/request?id=` until it closes.
`agents.js` joins the static file list (`serve.mjs:28`) and `index.html` (N5).
When `/api/config` says `send` is false, every action button shows as unavailable with the text "Agent
actions are unavailable here. Use mosaic business instance from the instance CLI." This is the live state
until Q175 phase 2. Every write test uses a fixture server and fixture writers, as rows 165 and 191 do. No
route gets a `--send` exception.
## D8. Tests
Every test uses scratch roots under `TMPDIR` and the existing fixtures (`business/tests/helpers.mjs`,
`cli/tests/helpers.mjs`, `webui/tests/tester-fixture.mjs`). None touches `~/.mosaic-dev`,
`~/.config/mosaic-dev`, a live unit or a slot file.
| Test | Shows | File |
|---|---|---|
| T1 | V1 create on a running bus: a launch of the new instance binds with no restart, and the answer says `restart: false` | cli `instances.test.mjs` |
| T2 | V2: a human launch of an unlisted instance runs. `launch.by` goes through the `pm` branch (N8). A thread instance, a retired one and a revoked business refuse at the verb. A request older than 60 s never launches. A refused `check()` closes the request with its code | cli `lifecycle.test.mjs` (new) |
| T2a | Take: a `launch.revoke` between the request and the pass closes it `launch-revoked` with no process. A `lifecycleDone` that throws once leaves the request taken, and the next tick starts nothing. A taken request is never taken twice | same |
| T3 | V2 with `--no-launch` and with each limit refusal: no process, request closed with the code | same |
| T4 | V3: claims revoked, runner SIGTERMed after the start-time check, one end record. A mismatched start time sends no signal. An open request is cancelled and never starts. A spawned, unbound run in the registry is signalled | same |
| T5 | V4: the new run starts only after the old exit, with a new run dir and the same workspace. A run that ignores SIGTERM closes the request `regenerate-timeout` with nothing started. The assistant refuses `regenerate-thread` | same |
| T6 | V5: each refusal in the V5 table, in order, including `instance-starting` with an open request. A clean delete leaves the evidence bytes unchanged, commits the file before the rename, adds `retired`, and a create or hire of the same name refuses. A failed rename closes `workspace-rename-failed`, `check()` refuses the name, and a second delete renames only | business `instance-settings.test.mjs` and cli `lifecycle.test.mjs` |
| T7 | V6: each op, the no-op refusal, an unknown `noHire` name accepted, and a `changes.jsonl` entry with actor `human` | business |
| T8 | Chief hire (hard-wait form): no decision refuses. A decision raised by the chief goes to the human only, no arbiter. After the resolve, the hire adds and lists the instance, logs actor and decision, and the same chief session launches it (D3 item 5). Arguments that differ from the decision body refuse. A non-chief caller with a resolved decision refuses `role-not-chief`. Outside `hireable`, in `noHire`, credentialed, END_PROTECTED, retired, and extra vars each refuse | bus `chief-of-staff.test.mjs` |
| T8a | Re-hire (B3): a fired name with the same definition and no vars is listed again. Another definition or vars refuses `organization-instance-exists` | same |
| T8b | Hire allowlist (D2.3): one case per action in `ACTIONS` that is neither on `HIRE_ALLOWED` nor a hard wait (`role.mjs` already refuses those), looped from the lists, not typed by hand. A fixture definition, clean when the chief's role file loads and listed in `hireable`, is rewritten to hold that action before the hire. The hire refuses `definition-not-hireable`. A definition holding one allowed and one refused action refuses. One holding only allowed actions hires. A pin test fixes `HIRE_ALLOWED` to the six actions | same |
| T8c | Allowlist at load: a role file whose `hireable` names a definition holding each refused action refuses at `loadRole`, one case per action, looped the same way. The mixed case refuses. A clean list loads. A missing, version 1 or duplicate name refuses | business `role.test.mjs` |
| T9 | Chief fire: runs end, open requests cancelled, delisted, still in `roles`. Kept (even with a resolved decision), END_PROTECTED, `launch.by` and last-listed refuse | bus `chief-of-staff.test.mjs` |
| T9a | Fire ends processes (F4, sage-1380 item 1): with a bound run and a spawned but unbound registry run of I, both fixture runners holding a stand-in model variable, fire answers both runs. After the launcher pass, neither runner pid is alive and I holds no claim. A fixture runner that ignores SIGTERM gets SIGKILL after the grace, is gone, and has `lifecycle.end.killed` with its run and pid. A stand-in whose kill is stubbed to fail gets `lifecycle.end.alive`. A runner whose identity changed before the SIGTERM, or between SIGTERM and SIGKILL, gets no signal. V3 terminate of a SIGTERM-ignoring runner sends no SIGKILL | cli `launcher.test.mjs` |
| T9b | Fire and row 191's halt: a halt during a fire's stop sends no second SIGTERM, keeps the fire's deadline, writes one stop receipt with reason `fire` and the decision, and counts the run in its totals. A fire while halted refuses (D6) | cli `launcher.test.mjs`, on row 191's fixture |
| T9c | Fire order (webby-0011 B2): the organization writer stubbed to fail after A commits. Fire answers the writer's code, I's claims are revoked and its runs stopped, I is still listed, and the decision is unconsumed. A second fire citing the same decision delists I and consumes it. A third fire refuses `decision-consumed`. With C stubbed to fail, the second fire finds I delisted and only consumes | bus `chief-of-staff.test.mjs` |
| T10 | Keep: chief `role.revoke` with a reason, fire, hire of that name and an `organization.set` model write aimed at a kept instance each refuse `instance-kept`. A `role.revoke` citing a human-resolved decision ends the kept run (Q2). The human's V3 works | same |
| T11 | Two scratch stack instances with the same business id: V2, V3 and V5 in A leave B's root and config byte-identical. A chief of business A can't hire into business B on the same bus | cli `lifecycle.test.mjs`, bus |
| T12 | Agent and reader sessions refuse every human verb before any effect. The tester gets exactly the D4.1 table, including `tester-refused` for `instances.set` op `delete` (B6). The D4 hook is called with the verb | bus `instances-verb.test.mjs` |
| T12a | Tester launch and terminate work on the cap's business with `bus.tester` true, record actor `tester`, and refuse `tester-refused` for a second business on the same bus. A file edit setting `bus.tester` to false, with no restart, refuses the next call and the take `tester-off`. An unreadable file refuses `tester-off` | bus `instances-verb.test.mjs` |
| T13 | `organization.set` with `launch`, `retired`, `keep` or `noHire` keys refuses | bus |
| T14 | Live reload: after each committed verb, `plugins.set` and `routes.set` included, the broker answers from the new document. A definition change on a running instance refuses `instance-running` | bus |
| T15 | Kill switch (with row 191's fixture): launch and regenerate refuse `processing-halted` at the verb and at the take, hire and fire refuse, terminate works | bus and cli |
| T16 | Console: each route's boundary refusals, a fixture write per action, `outcome-unknown` on a bad shape, unavailable buttons when `send` is false, request polling, and `agents.js` served from the static list | webui `agents.test.mjs` (new) |
| T17 | Business file: the new keys validate, each bad entry refuses, and a file without them loads byte for byte as before | business `business.test.mjs` |
| T18 | The forbid can't be changed by the chief (D2.4): from a chief session, `veto.set`, `organization.set` with each list key, `instances.set`, and hire and fire with extra fields all refuse, and the business file bytes are unchanged | bus `chief-of-staff.test.mjs` |
### Mutants
Each one must fail at least one test. They run against the candidate, and the file is restored and its
sha256 checked after each one.
| # | Change | Killer |
|---|---|---|
| M1 | `instance.launch` skips `#human` | T12 |
| M2 | the pass calls `check()` without `human: true` | T2 |
| M3 | the take accepts a request older than 60 s | T2 |
| M4 | the take skips the `launch_state` check | T2a |
| M5 | terminate signals without the start-time check | T4 |
| M6 | regenerate starts before the old exit | T5 |
| M7 | delete skips the running check | T6 |
| M8 | delete skips `retired` | T6 |
| M9 | delete clears `keep` instead of refusing | T6 |
| M10 | delete removes the workspace instead of renaming it | T6 |
| M11 | the `keep` check is removed from the `role.revoke` reason branch | T10 |
| M12 | `noHire` is not read by hire | T8 |
| M13 | fire deletes the instance from `roles` | T9 |
| M14 | `reloadBusiness` isn't called after `instances.set` | T1, T14 |
| M15 | the tool enum is kept | T8 |
| M16 | the halted check is dropped from `instance.launch` | T15 |
| M17 | the Console route skips the Origin check | T16 |
| M18 | `veto.set` joins `TESTER_ORGANIZATION_VERBS` | T12 |
| M19 | the tester's business scope check is dropped | T12a |
| M20 | the tester's `bus.tester` file read is dropped | T12a |
| M21 | hire skips the role file's `hireable` list | T8 |
| M22 | the take doesn't write `lifecycle.taken`, so a request can be taken twice | T2a |
| M23 | terminate doesn't cancel open requests | T4 |
| M24 | delete skips the `instance-starting` check | T6 |
| M25 | delete renames before it commits the file | T6 |
| M26 | the tester's `instances.set` op `delete` refusal is dropped | T12 |
| M27 | the `keep` check is dropped from `organization.set`'s model write | T10 |
| M28 | the `keep` check is added to the `role.revoke` decision branch | T10 |
| M29 | a re-hire accepts a different definition | T8a |
| M30 to M36 | the broker's hire check accepts one action outside `HIRE_ALLOWED`, one mutant per action: `role.launch`, `role.end`, `organization.change`, `instance.name`, `review.verdict`, `git.merge.protected`, `decision.resolve.technical` | T8b |
| M37 to M43 | the same seven, accepted by the load check in `role.mjs` only | T8c |
| M44 | fire skips `instance-kept` when a decision is cited | T9 |
| M45 | `instance.hire` skips the `launch.by` caller check | T8 |
| M46 | `veto.set` skips `#human` | T18 |
| M47 | the broker's hire check needs some action on `HIRE_ALLOWED` instead of every action | T8b |
| M48 | the same in `role.mjs` only | T8c |
| M49 | fire revokes the claims but writes no `lifecycle.end`, so no runner is signalled | T9a |
| M50 | fire's signal skips spawned but unbound registry runs | T9a |
| M51 | fire's stop drops the SIGKILL escalation | T9a |
| M52 | SIGKILL is sent without the second identity check | T9a |
| M53 | a killed runner gets no `lifecycle.end.killed` | T9a |
| M54 | V3 terminate escalates to SIGKILL as fire does | T9a |
| M55 | the halt sweep sends its own SIGTERM and restarts the deadline on a run fire is stopping | T9b |
| M56 | fire delists before the store step | T9c |
| M57 | fire consumes the decision in step A | T9c |
| M58 | a second fire with nothing to revoke refuses `not-held` | T9c |
## Scope and boundaries
- In the row: the verbs and passes above in `business` (vocabulary, role validation, instance-settings,
business validation), `bus` (broker verbs, reload, runtime callback), `cli` (instances command, launcher
pass, host ops), `harness` (the launch tool's schema, and the hire and fire tools), `webui` (routes and
the Agents section), the tests, the package READMEs and the PRD draft in this packet. `docs/TOOLS.md`
text goes to Sage.
- Named for review:
- Vocabulary: `role.hire` and `role.fire` join `ACTIONS` as hard waits, under REQ-WAIT-3. The commit
message names them as hard waits, as REQ-WAIT-3 requires.
- Role schema: the optional `hireable` key, validated in `role.mjs` (D2.3).
- Role file: `roles/chief-of-staff.json` gains `"hireable": []`. No authority change.
- Business file schema: `launch.keep`, `launch.noHire`, `retired` (D2.5).
- Not in the row: the routed form (after PRD 1.7), filling `hireable`, credentialed hires and deletes (row
199), clear context for the assistant (T1 item 7), human roles (row 193), SIGKILL escalation, and any
live activation or Console start.
## Risks
- R1. Case 1's working roles (coder, reviewer, pm, cto) all declare credentials (E11), and pm, cto and
reviewer fail the hire allowlist anyway. Neither the human verb nor the chief can create or delete credentialed
roles until the credentials row (row 199). That is the largest gap against Case 1, and it isn't this
row's to close.
- R2. The human proof is path-bound and same-uid (P19 333). Any same-uid process that runs the tree's
`human-cli.mjs` can launch and delete. Row 190b's separate uid and row 193 narrow it. This row adds no
check of its own.
- R3. The 60 s request window means a slow `check()` (checkouts) can still start a run up to that point
after the CLI gave up. The CLI says so on timeout.
- R4. Rollback past this row needs `keep`, `noHire` and `retired` removed from each business file, and
`hireable` from the role file, or the old loaders refuse them. The activation plan states this.
- R5. `tools.mjs`, `serve.mjs`, `broker.mjs`, `vocabulary.mjs` and `role.mjs` may carry sha256 pins in
other packages' tests. The build greps the tests for every changed path plus a 64-hex value before
in-review, without a `head`.
- R6. With `hireable` empty, the chief can hire nothing until the policy commit in `roles/` that follows
the `prd.approve` resolve names a kind (Q4). Case 1's "as needed" waits on that commit and on PRD 1.7.
- R7. Fire's SIGKILL closes the SIGTERM-ignoring case (sage-1380). A runner still alive after SIGKILL
(an unkillable process, or a stop whose identity check fails) keeps the shared model token until the
human acts on `lifecycle.end.alive`. Only a per-agent model token (row 199 Q-M4, a later proxy row)
would let a fire revoke the token itself.
## Questions and rulings
- Q1, ruled by Mos (mos-1015, mos-1017, through sage-1365): `role.hire` and `role.fire` ship as hard
waits Jason resolves. The routed form needs PRD 1.7, drafted in `PRD-1.7-DRAFT.md`. D2.1.
- Q2, ruled by Webby (webby-0003): keep blocks the chief's `role.revoke` reason branch, not its decision
branch. D2.4.
- Q3, ruled by Sage (sage-1355): launch and terminate are tester verbs scoped to the tester's own
business, behind `bus.tester`. D4.1.
- Q4, ruled by Sage (sage-1374): `hireable` stays empty in this row. Naming the first role kind is a
separate policy commit in `roles/` after Jason resolves the `prd.approve` decision. Tests use scratch role
files that name a kind. D2.3.
## r2 changes against webby-0003
| Finding | Where |
|---|---|
| B1 | D2.1: hard waits under REQ-WAIT-3, no role grant. The routed form is PRD 1.7 (packet draft) |
| B2 | D2.3: the floor from sage-1365 item 4, checked at load and at hire, T8b, T8c, M30 to M43 (an allowlist since r4) |
| B3 | D2.2 hire item 5: a re-hire keeps the definition, T8a, M29 |
| B4 | D1.1: one `lifecycleTake` transaction re-checks and writes `lifecycle.taken`, T2a, M4, M22 |
| B5 | V3 cancels open requests and signals unbound runs. V5 refuses `instance-starting`, runs under `serialize`, commits the file before the rename, and states the recovery. T4, T6, M23 to M25 |
| B6 | V5 table and D4.1: the tester's delete refuses, T12, M26 |
| B7 | D2.4: keep covers `organization.set`'s model write, T10, M27 |
| B8 | D4.1: `bus.tester` is read from the file at each call and at the take, T12a, M20 |
| Q2 | D2.4, T10, M28 |
| N1 | D3 item 2: one reload after every committed write |
| N2 | D2.5: `noHire` takes any id-pattern name |
| N3 | D2.2: hire's vars limited to the model key and narrowing `limits.*` |
| N4 | D3 item 4 names the tests that change |
| N5 | D7 and T16: `agents.js` joins `serve.mjs:28` and `index.html` |
| N6 | V3 and V4: no SIGKILL, `regenerate-timeout` |
| N7 | D2.4: intended, Q169 |
| N8 | V2 item 2 and T2 |
## r3 changes against sage-1374, sage-1377 and Megavolt's F4
| Item | Where |
|---|---|
| Q4 ruled: `hireable` stays empty, the first kind is a later policy commit in `roles/` | D2.3, R6, Questions |
| `instance.name` joins the floor (Webby B2, mos-1023 item 6) | D2.3 table, T8b, T8c, M47, M48 |
| F4: fire ends the fired agent's processes, since `ENV_ALLOW` gives every run the shared model token | D2.2, D7, T9a, M49 to M51, R7 |
| `git.push.remote` routed to coder only (mos-1028 item 3, row 199 r3) | `PRD-1.7-DRAFT.md`, REQ-LAND-2 and the REQ-WAIT-3 append |
## r4 changes against webby-0010, sage-1380 and megavolt-0008
| Item | Where |
|---|---|
| webby-0010 B1: the deny-list floor becomes the allowlist `HIRE_ALLOWED`, so merge-gate, `decision.resolve.technical`, `policy.change`, `prd.approve` and any later action are refused | D2.3, Summary, R1, T8b, T8c, M30 to M43, M47, M48, PRD REQ-COS-3 bound |
| webby-0010 N1: field-by-field compare of the call against the decision body, extra or missing keys refuse | D2.1 item 3 |
| webby-0010 N2: the model write is in REQ-COS-3's text | `PRD-1.7-DRAFT.md` |
| sage-1380 item 1: fire escalates to SIGKILL through row 191's per-run stop, and the order against a halt is fixed | D2.2, T9a, T9b, M51 to M55, R7 |
| sage-1380 item 2 and megavolt-0008: REQ-LAND-2 binds one commit oid, and Megavolt's three text changes against row 199 r4 (e1aa6216c) | `PRD-1.7-DRAFT.md` |
| webby-0011 B1: same as webby-0010 B1, M47 and M48 now allowlist mutants | D2.3 |
| webby-0011 B2: fire runs store side first, then the delist, then the consume, safe to repeat | D2.2 order A to C, T9c, M56 to M58 |
| webby-0011 N1: the remote-owner host check at each push | sent to Megavolt for row 199 |
| webby-0011 N2: identity, not bare pid, at every check | D2.2 step 3 |
| Q4: Webby's view matches Sage's ruling (empty) | no change |
@@ -0,0 +1,327 @@
# PRD 1.8 draft: trusted actions within role guardrails, the manual mode, and grants the chief of staff
# requests and the user approves
Draft by Honker for Vikunja #434, under Mos's assignment mos-1117 (through Sage, sage-1525). Not approved.
It changes nothing until Jason approves it as a `prd.approve` decision. Sage opens that decision and sends
Mos its id. Base: `docs/prd/mosaic-stack.md` version 1.6 at 008746b6e, plus the version 1.7 text Jason
approved (PRD-1.7-DRAFT.md r2, 9ba31b5b3, decision d52febb5, mos-1089). Where 1.7 is not yet written into
the PRD file, this draft's anchors name 1.7's sections.
Revision 2 makes Mos's edits a to f (mos-1124, through Sage, sage-1534) and fixes Duckworth's B1 and B2
with notes N1 to N6 (duckworth-0057) on r1 at 3c30f1036. Revision 3 fixes Duckworth's C1 and takes
notes N1 and N2 (duckworth-0058) on r2 at 60f5ca8fa, with Gizmoduck's enrollment wording
(gizmoduck-0067). Revision 4 makes Mos's rulings on duckworth-0057 (mos-1129, through Sage, sage-1538)
on r3 at d16ccdb91: only an Approve creates a grant, one decision for a grant that makes an account, and
the arbiter route for `host.sudo`. Revision 5 fixes two stale notes from Duckworth's r4 check
(duckworth-0061 N1), with no change to the requirement text. Revision 6 makes Mos's wording for those
notes and adds two items for Jason at approval (mos-1138, through Sage, sage-1548), again with no change
to the requirement text.
For Jason at approval: "The main agent or chief of staff" in Q10 is read as one role under two names,
the chief of staff. So only the chief raises a grant request, and your assistant passes your own request
to the chief. That reading is Mos's (mos-1124). To change it, say so when you approve, and REQ-GRANT-1
changes before the text goes into the PRD.
Also for Jason at approval:
- Only an Approve creates a grant. A grant written into the business file by hand makes nothing. An agent
that runs as the same OS user as the stack can still write the stack's own store. That is the B7
residual (Q182 on dragon-lin, #423 on .53 and .54), and this version doesn't close it.
- Who decides a root command on another host changes. Once `host.sudo` is routed, a raise for it that
cites the spec and isn't within-role goes to the arbiter for its domain, as version 1.7 set for
`git.push.remote`, not to you. The grant and its allowlist still limit the command. In manual mode the
decision comes to you (REQ-TRUST-2).
- Revoke: Q188 says "you, Mos or the tester". "Mos" is read as your assistant (REQ-ROLE-4), limited to
grants in its own organization, and the tester may revoke only on a dev instance. That reading is Mos's
(mos-1124 d, mos-1132). To change it, say so when you approve, and REQ-GRANT-4 changes before the text
goes into the PRD.
- 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 the role contracts, not a broker check (REQ-GRANT-1).
## What it changes
Today an agent acts without a raise only on actions its role file grants within-role, and the role
files grant few. Every other use of a tool or command waits on a decision. Row 210 builds `host.sudo` as a
hard wait, so Jason would decide each command run on another machine. Grants themselves are hand work:
Jason makes every token and key from a runbook (REQ-CRED-1).
Jason's ruling Q188 turns this around. Roles set what each agent may do, and by default agents do it at
will within their sandbox and guardrails. A per-use raise happens only in a manual mode. No agent creates
a grant. The chief of staff brings a grant request to the user's inbox, the user clicks Approve or Deny, and
protected stack code does every step of making the grant. The user's own tokens are entered once in the
WebUI and never reach an agent.
This version adds:
1. Trusted actions within role guardrails as the default mode (REQ-TRUST-1).
2. Manual mode, a list of actions that each need a decision per use: who sets it, where it lives and what
it changes (REQ-TRUST-2).
3. Grant requests, raised only by the chief of staff through the chain of command (REQ-GRANT-1).
4. Approve and the protected grant code, with three grant methods: machine key, Gitea and GitHub
(REQ-GRANT-2).
5. The user's tokens, entered in the WebUI and used only by the grant code (REQ-GRANT-3).
6. Row 210's REQ-CRED-4, generalized from `host.sudo` to every role-listed action that uses a grant
(REQ-CRED-4).
7. Instant revoke (REQ-GRANT-4).
The hard waits stay with the user (Mos, mos-1117): spending money, deleting data, new external accounts,
messages outside the fleet and the rest of REQ-WAIT-1. Approving a grant is the user resolving one of
those hard waits, `credential.mint`. A grant that makes an account, such as a seat's own Gitea user
(Q190), is the same one decision: its method says it makes an account, and the inbox shows the account
name before Approve. So no hard wait moves to an agent.
When this version lands, reviewed commits:
1. add the manual-mode list to the organization file schema, written only on the human's path
(REQ-TRUST-2), and make the broker classify a listed action as needing a decision for each use, before
any other routing.
2. add the grant request: a decision the chief of staff raises with action `credential.mint`, naming the
agent, the method and target, the exact rights and, when the method makes an account, the account
name (REQ-GRANT-1). The broker refuses a `credential.mint` raise from any other role. An
`account.create` raise isn't a grant request and routes as it does in version 1.7, such as a PM's
sign-up for a service.
3. add the grant code that runs on a resolved Approve, one method at a time, and the grant record it
writes (REQ-GRANT-2, Vikunja #435 and #436).
4. add the user's token store, written from the WebUI (or the CLI until the Console knows the signed-in
human) and read only by the grant code (REQ-GRANT-3).
5. move `host.sudo` into both `ROUTED` lists, as row 199 does for `git.push.remote`, and route it under
REQ-CRED-4, with the arbiter route version 1.7 set for `git.push.remote`. Which definitions list it
within-role is a separate reviewed role commit in `roles/`.
6. update the role contracts that list the hard waits: `roles/chief-of-staff.md:75` raises grant
requests with the REQ-GRANT-1 fields, and `roles/pm.md:55` asks the chief of staff for a grant instead
of raising `credential.mint` itself (line numbers on origin/refactor). Other roles that list the hard
waits get the same line as the PM. A role's messaging list carries the chain of command.
## Text for the PRD
Status line, appended to the current paragraph:
> Version 1.8 records Jason's ruling Q188 of 2026-10-20: trusted actions within role guardrails by
> default, a manual mode for per-use raises, and grants the chief of staff requests and the user
> approves, approved by Jason in decision <prd.approve decision id>.
Source, two new bullets above "Version 1.7 source":
> - Version 1.8 source, Jason's ruling Q188 (DECISIONS, 2026-10-20), his answers to Q8 and Q10, in part.
> Q8: "I would rather grant the grant right to an agent that runs with strict guardrails. That agent
> presents the final authorization for the user. Upon approval, the grant is created programmatically.
> I see this as: User configures their PATs, API tokens, authorizations within the WebUI. The agent
> presents the request for grant with X rights for another agent. The user reviews, then clicks
> Approve/Deny. Click of Approve fires off protected mechanical methods to handle the grants on Github,
> Gitea, .... services as needed. The tooling would need to exist and must be guarded." And "Disallowing
> the agents to create grants autonomously is a necessary evil for better security. Requiring the user
> to manually perform the steps is a no-fly." Q10: "Agents should have tools and command access based on
> roles. Those roles dictate the allowed tools and commands. The use of a tool or command should not be
> raised to the user unless the system is configured with a manual mode that requires user
> interaction. By default I want to have the agents capable of running commands and tools at will
> within their sandbox/guardrails. This allows autonomous actions by agents within their lanes. Perhaps
> a "Trusted actions within approved guardrails" methodology." And "The main agent or chief of staff
> would be responsible for surfacing the request to the user's inbox for approval. Not all agents are
> capable or authorized to request directly. It must follow the chain of command properly." He agreed
> with Mos's Q10 points 3 to 6: protected stack code does every step on Approve, his tokens are entered
> in the WebUI and agents never see them, a command on the approved list runs without a click and any
> other is refused, and he, Mos or the tester can revoke a grant at once.
> - Version 1.8 source, Jason's ruling Q190 (DECISIONS, 2026-10-20), his answer "Q12: O1": each agent seat
> gets its own account on his Gitea, created through the Q188 approval path once the Gitea grants exist.
Under "Decisions", after REQ-DEC-6, a new section:
> ### Trusted actions
>
> Added in version 1.8 (Q188).
>
> - **REQ-TRUST-1.** Agents act within their role guardrails without asking. The default mode is
> "trusted actions within approved guardrails". An action a role file grants within-role is done at
> will, inside the agent's sandbox, its tool limit and its grants, and is logged. It raises nothing to
> the user. An action the file doesn't grant within-role still routes by REQ-DEC-2, and a hard wait
> still goes to the user (REQ-WAIT-1). Only a reviewed role change adds an action to a role file, and
> REQ-WAIT-2 still keeps every hard wait out of every role file.
> - **REQ-TRUST-2.** The user can put chosen actions in manual mode. The organization file holds the list
> of actions in manual mode, empty by default. While an action is on the list, every use of it by any
> role becomes a decision for that use, raised to the user, and the action waits until the user
> resolves it. A decision denied, or not resolved, refuses the action. Each decision is used once. For a
> command on another host, the decision names the host, the command and its input. Actions not on the
> list are unchanged. Only the user changes the list, through the WebUI or the CLI. The chief of
> staff's organization writes (REQ-ROLE-3) can't change it, and no agent can. Every change is logged
> with who made it. A change applies to the next use, and a run already in progress keeps no
> exemption.
Under "Credentials", after REQ-CRED-3 (version 1.7):
> Added in version 1.8 (Q188):
>
> - **REQ-CRED-4.** An action a role definition lists within-role, and that uses a grant, runs without a
> decision for each use only when all of these hold:
> - the definition declares the credential kind the action uses;
> - a grant names that agent, and that host or service;
> - for a command, the command matches the grant's allowlist. A grant lists exact argv prefixes. A
> wildcard grant is refused;
> - the agent's key or token for that grant is issued and not revoked.
>
> A command off the allowlist is refused, and adding one takes a new grant request (REQ-GRANT-1). An
> action whose definition doesn't list it within-role still needs a decision for each use. Only a
> reviewed role change adds an action to a definition. `host.sudo` routes under this requirement.
> - **REQ-GRANT-1.** No agent creates or writes a grant. A grant request follows the chain of command,
> and only the chief of staff raises it to the user's inbox. Any other role that needs a grant for
> itself or for an agent it directs asks the chief of staff, through its chain: a worker role through its
> PM, the PM and the CTO directly. The assistant passes the user's own request to the chief of staff
> (REQ-ASSIST-1). The request names the agent, the grant method and its target, the exact rights, and a
> rotate-by or expiry date. The chief raises one only for an agent whose reviewed role definition
> allows that kind of credential, and the broker refuses any other. A grant can't name a wildcard, a
> shell or an empty command. The request is a decision with the action `credential.mint`, so it goes
> to the user as a hard wait (REQ-WAIT-1). A grant whose method makes an account, such as a seat's own
> Gitea user (Q190), is the same one decision: the method in the grant says it makes an account, the
> request names the account, and the inbox shows the account name before Approve. The user's one
> resolution covers the account and its credential, so the hard-waits list doesn't change. An
> `account.create` raise isn't a grant request and routes as version 1.7 has it. The broker checks
> only that the chief of staff raised the request. The chain of command before that is in the
> messaging lists and the role contracts, not a broker check.
> - **REQ-GRANT-2.** The user approves or denies each grant request in the inbox. Approve is the only thing
> that creates a grant, and the code uses a grant only when it has a matching Approve record. No agent
> creates a grant. A grant written into the business file by hand makes nothing. Row 210's hand-written
> grants stay a test path until Vikunja #436a moves `host.run` to grants made by Approve. On Approve,
> protected stack code does every step, not an agent and not the user: it records the grant, makes the
> key or token, installs it on the machine or creates it on the service, and logs each step. On Deny
> nothing is made, and the denial is logged. The grant methods are:
> - machine key: a key for one agent on one enrolled host, with the command allowlist of REQ-CRED-4. A
> host's identity comes from the host side when the user approves its enrollment, never from a first
> connection. A host user with sudo receives a grant only after the user approved that user by name;
> - Gitea: a token for one agent, and for an agent seat its own Gitea user (Q190), with an expiry. The
> all scope and the admin scope are never granted;
> - GitHub: a grant for one agent.
>
> The Gitea and GitHub rights units come in a later design that Jason approves.
>
> The approval binds the exact rights the user saw and the target or its enrolled identity. A change to
> the agent, its definition, the target, the target's identity or the rights after the raise voids it. An
> approval is used once. A grant the code couldn't finish is undone at once on the stack's side, so it
> is never used. What it left on the host or service stays visible as pending until it is removed, as
> for a revoke (REQ-GRANT-4). A new grant needs a new approval, never an automatic retry. The grant
> code runs only on a request the user resolved. A request resolved by an arbiter, the tester or any
> agent makes nothing. A `credential.mint` decision raised before version 1.8, or without the fields of
> REQ-GRANT-1, makes nothing when it is approved. Until the Console
> knows the signed-in human (Vikunja #412), Approve is `mosaic decide` in the CLI (REQ-DEC-3).
> - **REQ-GRANT-3.** The user enters their own personal access tokens and API tokens in the WebUI, or the
> CLI. Only the grant code reads them, to make grants. No agent ever sees a token value or a key's
> private half. Values appear in no event, log, message, prompt or agent file, and no agent can read
> them back (REQ-VAR-2). Until the Console knows the signed-in human, they are entered through the
> CLI.
> - **REQ-GRANT-4.** A grant can be revoked at once by the user, by the assistant for a grant in its own
> organization, or by the tester on a dev instance (Q173). A revoke needs no decision and no new trust.
> It takes effect at once on the stack's side, without waiting on the network, so the next use is
> refused. Removing the key or token at the host or service follows. Until that removal is done it stays
> visible as pending. A revoke while the grant is being made stops it, and nothing it made stays usable.
> Every revoke is logged with who made it.
REQ-CRED-1, appended:
> Since version 1.8, a token or key for a grant is made by the grant code on the user's Approve
> (REQ-GRANT-2), not by hand. Tokens that exist stay as they are.
REQ-WAIT-3, appended:
> `host.sudo` was added as a hard wait and routes under REQ-CRED-4 since version 1.8.
Under "Risks and open questions", a new bullet:
> - Trusted mode lets an agent run its allowed commands at will. On another host, an allowed command that
> writes a file as root or starts a shell can plant a key, and the seat sandbox doesn't confine the
> network, so root can outlive the grant and a revoke. The allowlist narrows this. A seat sandbox with
> its own network namespace closes it. Until then the host's own audit log is the check after the fact
> (row 210 design section 6).
## Notes for the reviewer and for Mos
- REQ-TRUST-1 restates what REQ-DEC-2 already does for within-role actions ("routine and within-role
decisions are logged") and names it as the default mode. It grants nothing new by itself. What widens
is the role files, one reviewed commit at a time.
- The hard waits stay with the user (mos-1117). REQ-GRANT-1 makes the grant request a `credential.mint`
decision, already a hard wait under REQ-WAIT-1, so Approve is the user resolving it. REQ-WAIT-2 stands:
no role file grants `credential.mint`. The grant code acts on the user's resolution, not on a role
grant. A grant that makes an account, such as a seat's Gitea user under Q190, is the same one
`credential.mint` decision. So 1.8 doesn't change the hard-waits list (mos-1124 a, mos-1129 N4).
- Manual mode: Sage asked who sets it, where it lives and what it changes. Mos accepted these choices
(mos-1124 b), and Jason can change them: a list of actions in the organization file, empty by
default, changed only by the user, outside the chief's organization writes. A list rather than one
switch follows Gizmoduck's #436 draft section 7 (gizmoduck/machine-grant-design f6b6a04b6), so Jason
can make host commands manual without making every action manual. Listing every within-role action
gives the whole-organization manual mode. Row 210's per-call `host.run` decision, bound to host, argv
and stdin and spent once, is this mode's decision for a host command (Q188, Mos's reading).
- Gizmoduck's draft proposes REQ-CRED-5 (section 8) for the machine grant. Its content is in REQ-GRANT-1
to REQ-GRANT-4 here, generalized to all three methods, so 1.8 has no REQ-CRED-5. Their bounds in
gizmoduck-0062 items 4a to 4g are in the text: the chief raises only for an allowed definition, the
approval binds the rights shown, it is used once with no automatic retry, no value is ever seen, revoke
is at once on the stack's side with the host or service removal visible until done, every grant has a
rotate-by or expiry date, and the host identity comes from the host side. Duckworth has reviewed their
design r1 to r4 (duckworth-0056 and duckworth-0060), so a later change there may need a change here.
- B1 (duckworth-0057, mos-1129): a grant the code couldn't finish is undone at once on the stack's side.
What it left on the host or service stays visible as pending until it is removed, as for a revoke.
- B2 (duckworth-0057, mos-1129): Mos chose Duckworth's second option. Only an Approve creates a grant,
and the code uses a grant only when it has a matching Approve record. A grant written by hand makes
nothing. Jason's Q188 gives the reason: "Disallowing the agents to create grants autonomously is a
necessary evil for better security" and "Upon approval, the grant is created programmatically." Row
210's hand-written grants stay a test path until #436a moves `host.run` to grants made by Approve. r2
and r3 had a hand path in the business file, and r4 takes it out, with r3's load check on hand grants
(duckworth-0058 N1) and the note that a revoke never rewrites the user's file (duckworth-0058 N2).
The same-uid write to the stack's own store stays the B7 residual, and the top of this draft names it
for Jason.
- C1 (duckworth-0058, sage-1538): `account.create` is the hard wait for any new external account, not
only a grant (REQ-WAIT-1, `vocabulary.mjs:43-46`, `roles/pm.md:55`). It stays as version 1.7 has it.
Only `credential.mint` is chief-only, and a grant that makes an account is a `credential.mint`
decision (N4), so a PM's sign-up for a service routes as before.
- Gizmoduck's machine-grant r2 (eb4c756a1, gizmoduck-0067): enrollment is a request the user approves
and the stack runs (mos-1120), and a host user with sudo is enrolled by name, the user's Approve
covering that user (mos-1125). REQ-GRANT-2's machine key says both. Their design r3 and r4 (1c5705d6d
section 6) give revoke to the user, the assistant in its own organization and the tester on a dev
instance, as REQ-GRANT-4 does (mos-1124 d).
- N1 (duckworth-0057, mos-1129): the approval binds the target or its enrolled identity.
- N2 (duckworth-0057, mos-1129): a revoke during creation stops it, and nothing it made stays usable.
- N3 (duckworth-0057, mos-1129): landing step 6 lists the role contract updates (`roles/pm.md:55`,
`roles/chief-of-staff.md:75`). A `credential.mint` decision raised before 1.8, or one without the
grant fields, makes nothing when it is approved.
- N4 (duckworth-0057, mos-1129): a decision carries one action (`a.action`, broker.mjs:1678 on
origin/refactor). A grant that makes an account is one `credential.mint` decision: the method in the
grant says it makes an account, and the inbox shows the account name before Approve. The user clicks
once, and there is no separate `account.create` action for it. Gizmoduck's machine-grant r2 reads it
the same way.
- N5 (duckworth-0057, mos-1129): `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 (broker.mjs:1677-1678 on origin/refactor), and the grant and its allowlist still limit the
command. In manual mode the decision goes to the user. The top of this draft names this for Jason as a
change in who decides a root command on another host.
- N6 (duckworth-0057, mos-1129): 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 the role contracts (step 6 of "When this
version lands"). REQ-GRANT-1 says so.
- Approving a grant doesn't by itself let a command run without a click. That needs REQ-CRED-4 and
`host.sudo` in the routed lists, step 5 of "When this version lands" (gizmoduck-0062 item 4i).
- Which roles may request: Q188 says "Not all agents are capable or authorized to request directly. It
must follow the chain of command properly." The text makes the chief of staff the only role that raises,
the PM and the CTO ask it directly, and worker roles ask through their PM. Jason's words also name "the
main agent", but REQ-ASSIST-1 keeps the assistant out of directing agents, so it passes the user's
request to the chief rather than raising one. Mos accepted this (mos-1124 c) and reads "the main agent
or chief of staff" as one role under two names. That reading is Mos's, so the top of this draft puts it
to Jason at approval.
- REQ-CRED-4 keeps row 210's conditions (origin/gizmoduck/row210-on-199-r2,
agents/gizmoduck/work/queue-210/DESIGN.md:376-392): the definition declares the kind, the grant names the
agent, an exact argv-prefix allowlist with wildcards refused, the key issued, and only a reviewed role
change adds the action. Row 210 put the grant in a hand-edited business file, which stays a test path until
#436a. Under 1.8 the grant is what Approve records, so "the business file grants that agent that host"
becomes "a grant names that agent and that host".
- Revoke: Q188 says "you, Mos or the tester". Mos is Jason's assistant (REQ-ROLE-4), so the text says the
assistant, limited to grants in its own organization, and the tester only on a dev instance (Q173).
Mos accepted this with those limits (mos-1124 d).
- Approve in the Console needs the Console to know the signed-in human (Q175 phase 2, #412). Until then
Approve is `mosaic decide`, which REQ-DEC-3 already allows (mos-1124 e). This draft doesn't take
REQ-WEB-2, which stays with the Console decide buttons amendment (Vikunja 19/266). That amendment, or a
later version, moves Approve into the Console.
- Roles already use a scoped Gitea token (`<read|write>:<category>`, all and admin never granted,
`packages/business/src/vocabulary.mjs:105-106` on origin/refactor). It is a likely Gitea unit, not a settled
one. Only the machine key is designed so far. Gitea and GitHub are a later #436 draft, and minting a Gitea
token needs an admin credential the user enters, which is a design of its own (Gizmoduck's draft section
11). GitHub's rights unit isn't settled. So the text leaves both units to a later design Jason approves
(mos-1124 f), and keeps only the bounds: an expiry, and never the all or admin scope. The PRD names outcomes
and checks, not the grant code's steps, as REQ-CRED-3 does.
- Jason expects the inbox Approve and the Gitea and GitHub grants by 10-27 (Q188 point 6). That is a
delivery date, so it is in the source and not in the requirement text.
- This draft follows the PRD's own style: plain words, no semicolons in new sentences, and the status
paragraph never edited in place except to append.
+149
View File
@@ -0,0 +1,149 @@
# Row 236 build notes: #436b Gitea grant path (#1742, schema 3j-3k)
Builder Megavolt, reviewer Dewey, gate owner Sage. Draft; the results sections are filled in after the
runs on Sage's gate.
## Sources
- Brief: `docs/plans/2026-10-20_agent-credentials.md`, "Brief: #436b Gitea grant path, build (schema 3j-3k)",
blob 9259ba2a1.
- Design: `agents/megavolt/work/436b/DESIGN.md` r5 at 1be535aca (sha256 68df02cd…f4), unedited. Scope:
sections 2 to 7, and section 9's X-G1 to X-G15 and mutants. Section 10 (GitHub half) is out of scope.
- Build carry: `agents/megavolt/work/436b/BUILD-CARRY.md` at 3330c901b (N9, N10).
- Schema label 3j-3k per sage-2237. If 264a or 206 lands first, this rebases onto a new branch name and reruns
the whole bus suite, the schema.test counts and the T46 lists.
- Rebases: onto 86f95bb27 after row 273 (sage-2403, no conflicts), then onto 0fa7bf421 after row 191
(sage-2452, sage-2457). The 191 rebase took three unions in commit 1, 191's `processing` first and then the
grant options: the Broker constructor (broker.mjs:317), the runtime's Broker options (runtime.mjs:173) and
the cli case line in `scripts/mosaic` (191's `halt|resume|processing|` plus `grants`).
## What changed
- `packages/bus/src/gitea-grant.mjs` (new): the grant body check, the admin slot (set, rotate, remove), the
admin and seat API calls, the start-time scan (`giteaStart`, `openGiteaGrants`, `tokenUnknown`).
- `packages/bus/src/broker.mjs`: the gitea executor (steps 0 to 8), settle, restart reconcile, revoke and fire,
sweep, status, and the `grants.*` verbs.
- `packages/bus/src/credentials.mjs`: `held` pairs at start; the register and claim paths the executor uses.
- `packages/bus/src/migrations/3j-3k.sql`, `schema.sql`, `store.mjs`: `gitea_users` and `gitea_grants`, new
tables only.
- `packages/bus/src/runtime.mjs`, `server.mjs`, `human-cli.mjs`: wiring of the start scan and the verbs.
- `packages/cli/src/grants.mjs` (new), `cli.mjs`, `scripts/mosaic`: `mosaic grants`.
- Tests: `gitea-grant.test.mjs`, `gitea-migration.test.mjs`, `gitea-grant-docker.test.mjs` (X-G3),
`fake-gitea-api.mjs`, `fixtures/schema-3j.sql` (02da6a2ae's schema.sql), `cli/tests/grants.test.mjs`, and the
pin updates in the migration tests, `schema.test.mjs`, `channel-proof.test.mjs` and `stack-release.test.mjs`
(T46).
- READMEs of bus and cli.
## Choices and assumptions
Each is a reading of the design where it left room. Dewey may rule otherwise.
1. Admin rotation is due once today is past `rotateBy` (the date itself is still good).
2. A seat-token rotation moves the record at step 8, after the old token's delete (step 7).
3. A step 7 failure leaves the grant applied, with the old id in `serviceLeft` and the notice "The previous
token X is still live".
4. A failure after the commit step's rename returns `settle-pending`; the next start settles it.
5. tester-cli keeps its 5 s timeout; the grant verbs answer at once and the executor runs after.
6. A revoke of a token-unknown agent takes both ids from that decision's last commit step.
7. A user adopted at restart is upserted in the same transaction as its `grant.failed interrupted`.
8. `#giteaUnlink` (first-grant commit undo) uses a temporary reference dated `9999-12-31`, so it is never due
while it lives.
9. X-G12's org-team case: the fake models team membership as collaborator reach on the repository, which is
what `/user/repos` shows (F11, F12).
10. X-G15's look-alikes are stored under `action.refused` and under a non-grant kind, since the 3j trigger
refuses the human's NULL pair on `action.allowed`. The trigger cases of X-G13 (session, NULL pair,
`grant.host.enrolled`) are in `grant-migration.test.mjs`.
11. Step 6 faults: a pre-rename failure is injected by making `gitea.tmp` a directory; a step 8 store fault by
a TEMP trigger on `gitea_grants`.
12. The credentials-verb revoke close is tracked, so `giteaIdle()` waits for it.
13. `openGiteaGrants` gives `agent: null` for a spend with no step yet, so `status.running` can show a null
agent; the CLI prints it empty.
14. N9: a restart ignores a settle-pending commit with a `grant.revoked` after it and ends it `revoked` with
no message (notify false). A 404 on a delete by id counts as done.
15. N10: `serviceLeft` is a list in every body and answer.
16. A restart claims the agent's key for each open grant it undoes. One executor runs per agent
(grant-busy), so a killed child leaves at most one open grant per agent. If a store ever held two, the
second's claim fails and the undo runs with no admin: its token is left and named in `serviceLeft`,
never deleted unclaimed.
17. X-G7 changes the binding with a fixture decision whose hash differs (row 234's G9 pattern), since the
broker keeps its own copy of the business. The admin url check takes row 199's `parseGiteaUrl`, which
accepts the origin with or without a trailing slash.
18. r2, Dewey B1 (dewey-0781): step 8 and both settle branches (forward, back) check for a `grant.revoked`
of the agent after the decision's raise, in the same transaction as their write. A revoke that came
while step 7's or the settle's delete awaited took the record and both tokens, so the grant ends
`revoked` with the human's notice, as a revoke of a running executor ends it (X-G11), and no record is
written back. N9's restart case stays silent: a restart never settles a revoked rotation.
19. r2, Dewey B2: a sweep retry of `tokens` or `login` checks the user's id first, as the revoke close does,
against the `gitea_users` row (it survives a fire). A 404 skips with `gitea-user-gone`, a 200 with
another id with `gitea-user-foreign`, any other status leaves the entry. Skips go in `grant.swept` and
the sweep answer as `skipped: [{agent, user, id, reason}]`; a skipped entry is not left, so the next
sweep does not retry it.
20. r3, Dewey N5 (dewey-0783): the settle's 401 branch and its grant-token-unknown branch take the same
revoke check. A revoke during the whoami ends the grant `revoked`, not `grant-lost`, and the
token-unknown branch neither holds the agent again nor writes `grant-token-unknown`, so no start
names a file the revoke unlinked.
21. sage-2453, ruling (a) on megavolt-0123: 191's `#processingGate` refuses every agent verb off its read list
while processing is halted. `grants.status` is a read like `hosts.status`, and the kill-switch plan
(`docs/plans/2026-10-20_kill-switch.md`:16) says "Reads and the human's own verbs, including the clear,
still work", so it joins the list. This touches row 191's list (one entry). An agent's `grants.revoke`
stays refused while halted, as `hosts.revoke` does.
## Named for DEFERRED (r2, Dewey N1-N4)
None is a small local change, so none is in r2. sage-2388: all four DEFERRED; N3 and N4 get a bus README
sentence each in r3 (a left admin token is deleted by hand at Gitea; a running executor keeps the admin
api it started with).
- N1: an admin rotate cut between the mint and the slot write leaves a token at Gitea that no event names.
A `grant.admin rotate-begin {oldName}` before the mint would close it; that is a new event shape.
- N2: `adminTokenLeft` is `unknown` when `/api/v1/token` gives no name. Which 1.27 F-fact shows the name
needs a check against the container, not a code change.
- N3: admin set or remove records `adminTokenLeft: null`, so a left token drops out of status. Carrying it
forward lets `rotate --finish` delete by name under a slot of another account or url; refusing remove
while one is left blocks the human's remove when Gitea is down. Either needs a ruling.
- N4: rotate or remove while a gitea claim is held. Refusing it blocks the human's remove during a run;
the running executor keeps the api it began with. Needs a ruling or a line in the README.
## Q188
No test creates a grant except through fixtures or the human's yes on a scratch store. No test reaches
git.mosaicstack.dev, tasks.woltje.com or any provider. Unit tests use the fake Gitea on 127.0.0.1; X-G3 uses a
throwaway gitea/gitea:1.27.1 container from the local image (never pulled), removed after. Every token is a
fake or a scratch-container token; values are compared with `assert.ok`, never printed.
## Runs
(filled in after Sage's CLEAR)
## Mutants
(section 9's list, each with the test that kills it; filled in after the runs)
A static pass over the 39 mutants (all apply, problems=0) found five with no test to kill them. Four tests
were added before any mutant run:
- M8, M9 (user or mint step written after its call) and M16 (grant.result written before its call): "X-G6
and X-G8: the user and mint steps are written before their calls, and each result after its call". It
reads the store from the fake while user-create and token-create are at Gitea, and checks every result's
status.
- M29 (fork parent not checked): "X-G4: a fork by the seat user of another repository than the project
refuses fork-foreign".
- M28 (memory and file revoked after the Gitea delete): "X-G11: a revoke unloads memory and removes the file
before its Gitea delete", which holds the DELETE open and reads the file and state.
- M37 (evidence without its kind filter): "X-G15: a look-alike spend under another kind with the grant writer
pair is not an open grant".
r2 tests, each red on 8bf442be9 by reading:
- B1: "X-G11: a revoke during a rotation step 7 ends it revoked, and step 8 writes no record back" and
"X-G11: a revoke while a sweep settles a rotation, forward or back, ends it revoked with no record".
On 8bf442be9 step 8 and the settle write the record and `grant.applied` (forward) or `grant.failed
interrupted settled:back` after the revoke deleted it.
- B2: "X-G14: a sweep retries a fire only for the user it fired: a remade or gone user is skipped and
named". On 8bf442be9 the retry fires the remade user (token list, delete, PATCH) and has no `skipped`.
- N5 (r3, sage-2388): "X-G11: a revoke during a settle whoami that finds a foreign token ends it revoked, not
held" and "X-G11: a revoke during a settle whoami that gets a 401 ends it revoked, not lost". On 77dd43c9e
they end `grant-token-unknown` (the agent back in tokenUnknown) and `grant-lost`.
- Choice 21 (sage-2453): "kill switch (plan :16): while halted the chief still reads grants.status, and an
agent grants.revoke refuses". M38 drops `'grants.status'` from the read list; the chief's status then
refuses `processing-halted` and the test fails.
+229
View File
@@ -0,0 +1,229 @@
# FED R3 (#414): the broker's user principal
Brief: docs/plans/2026-10-22_federation-r3.md (f0d0b31e9). Design: FED r4.11 (b93f098f0), sections 3.5, 3.6 and
the R3 row of section 7. Base: row 205a's candidate 67640f892. If 205a changes in review, R3 rebases onto a new
branch name. No schema change. Nothing has run yet: every test run waits for Sage's CLEAR.
## Changes
`packages/bus/src/broker.mjs`:
- `USER_VERBS` is the collaborator list: `project.list`, `thread.create`, `thread.list`, `thread.open`,
`thread.send`, `thread.messages`, `names`. The grant level is `collaborator`, through R1's `covers`.
- The Broker takes `access`, a function that returns the checked access document (the runtime passes R1's
`loadAccess`). `#userGrants` calls it once per user request. A throw, null or no document gives no grants.
- `bindUser({business, user})` (3.6 item 1). The keys must be exactly those two. A user id that fails
`ID_PATTERN` or is `human`, `tester`, `user` or the business human fails `access-refused`, whatever the file
says. No live collaborator grant fails `access-revoked`. The session is
`{business, user, role: null, run: null, human: false}`.
- `request()`: for a user, a verb off `USER_VERBS` fails `access-refused` before any argument check. Then the
grants are read; none fails `access-revoked`. The view the verb sees carries them as `grants`.
`requestTask`, `requestWeb` and `requestHost` fail a user `access-refused` first.
- Filters: `project.list` keeps granted projects. `thread.list` keeps the user's own threads its grants still
cover. `names` keeps granted instances. `thread.create` checks the project and the thread instance against
the grants before the project lookup, so an ungranted id and an unknown id both refuse `access-refused`.
`thread.open`, `thread.send` and `thread.messages` refuse a thread the grants no longer cover.
- `#owner` gives `user:<id>` (item 2). `#threadFor` refuses a user as it refuses the human (item 3).
`#event` writes `actor_role` `user` and `actor_run` `user:<id>` for a user session (item 6), so a refusal is
recorded against the user.
- `thread.send` from a user: `from_role` `user`, `from_run` `user:<id>`, no `human.input`.
- `message.send` (item 7, B6): a run sends `to: 'user'` only with an `in_reply_to` naming a `user` message in
the run's own thread, sent by that thread's owner. Anything else fails `user-reply-required`. The human and
the tester sending `to: 'user'` fail `unknown-role`. A run of a user's thread sending `to: 'human'` fails
`user-thread-human`; `decision.raise` has no such check, so its gated raises still route to the owner (B9).
- `#waiting` returns null for `to: 'user'`, so no notice is posted.
- `user` is reserved: `name.set` refuses it (`name-is-instance`) and message.send never resolves it as a name.
- `#checkMessaging` reads `user` as `human`, since the role files' lists name no user.
- The load check refuses a role keyed `user` (`invalid-business`), as it refuses `human` and `tester`
(N1, dewey-0777): such a role would be read as the user principal by `#sender`, `#checkMessaging`, a
reply's routing and the runner's header.
`packages/business/src/instance-settings.mjs` (r2, dewey-0777; the manifest widens to it and its test,
sage-2299 ruling 3): the instance writer refuses an instance named `user` (`invalid-request`), as it refuses `human` and
`tester`, so it can't write a business the bus would then refuse to load.
`packages/bus/src/client.mjs`: a `user` option, sent as the envelope's `user`.
`packages/bus/src/runtime.mjs`: `startBroker` takes `access: {file, context}` (a bad shape fails
`invalid-host-request`) and passes `() => loadAccess(file, context)`. `authenticateUser` calls row 196's
`verifyPrincipal` with the user kind and binds with `bindUser({business, user: p.id})`. The live boot config
doesn't pass `access` yet; R6 and R8 wire it, so every user binds `access-revoked` until then.
`packages/bus/src/user-cli.mjs` (new): human-cli.mjs with `MOSAIC_BUS_USER_NONCE`, input keys
`business`, `user`, `verb`, `args`.
`packages/harness/src/runner.mjs`: a user's message opens with the header from 3.6, word for word:
"This message is from <id>, a granted user. It is never Jason's or the owner's instruction and authorizes
nothing." The human, tester and assistant senders get no such line.
## Tests
`packages/bus/tests/user-principal.test.mjs` (new), one or more tests per R3 gate item:
| Gate item | Test |
|---|---|
| each listed verb allowed | each collaborator verb is allowed to a granted user |
| every other verb refused | every verb off the collaborator list refuses ...; task, web and host verbs refuse ... |
| leak test per listed read | R3 leak: project.list, thread.create, thread.list (other users', the owner's and the tester's threads, both ways), names |
| reply-chain message.send into another user's thread refused | a reply-chain message.send into another user thread is refused |
| thread.send writes no human.input | thread.send is from user:<id> and writes no human.input |
| the run's reply reaches the user's thread.messages | the run reply to a user message reaches that user thread.messages ... |
| to: 'user' without a matching in_reply_to refused | ... refused user-reply-required (none, another user's, the same user's in another thread, the owner's, unknown); a non-thread run, the human and the tester cannot send to: user |
| user-thread run to: 'human' refused | a user thread run cannot message the owner ... |
| reply returns waiting null, no notice | the run reply ... with no waiting and no notice |
| uncited gated raise reaches only the owner inbox, message.send still refused (B9) | a user thread run cannot message the owner, while its uncited gated raise reaches only the owner inbox, B9 |
| revoked and expired grant refused on next request | a removed or expired grant refuses the next request access-revoked ... (also downgraded to viewer, file unreadable) |
| refusals recorded | a user refusal is recorded as action.refused by user:<id> |
| a user named like the human refused | a user named like the business human, the tester or a principal word is refused ... |
| N1: role key `user` refused (r2) | a business with a role keyed user refuses invalid-business |
`packages/harness/tests/runner.test.mjs`: the user header, exact string, and its absence for the other senders.
`packages/business/tests/instance-settings.test.mjs` (r2): row 165's validation test also refuses instance
`user`.
Row 196's `principal.test.mjs` already covers the user kind of `verifyPrincipal` (agent parent, non-Console
parent, kind confusion are its mutants). No runtime test of `authenticateUser` or user-cli yet: the stub
`authenticateUser` path needs a real Console parent, which is R6's harness.
## Audit of `s.human` and `s.tester` branches
A user reaches only the seven `USER_VERBS` through `request()`. Task, web and host requests refuse it first.
The user cap is transient: the server binds it per request and releases it, and never returns it to a client,
so the cap-taking launcher and git ops (`authorize`, `checkPush`, `recordPush`, `checkLand`, `reviewFetch`,
`recordEvent`, `recordTask`) can't receive one.
| Branch | For a user |
|---|---|
| `#agent`, `#human` | `#human` refuses a user (`human-required`); every listed verb skips it with `!s.user`. `#agent` is reached only from `names` for a non-human, non-tester, non-user session. |
| `#threadFor` | refuses a user (item 3). Not reachable: message.send and the review verbs are off the list. An equivalent mutant; the line stays. |
| `#testerOwns`, `#testerThread` | `s.tester` false and `s.user` sessions have no thread: not taken. |
| `#owner`, `#sender`, `#event` | changed: `user:<id>`, `user`, actor columns. |
| `#userThread` | new; false for a user session itself. |
| `endLaunch` session sweep | matches `s.run === record.run`; a user's run is null and a record's run is an identifier: never matches. |
| `identity` | returns `{business, role: null, run: null, human: null}`. server.mjs reads only `business`, for the web service, and requestWeb then refuses. |
| `recordTask` | its cap check would pass a user, but only `requestTask`'s handler and the git ops call it with a cap: not reachable. |
| `request()` creative raise, usage, limitStop, organization verbs, hosts, project, library, credentials, reader and tester gates | after the user gate: not reachable. |
| `#hostsCaller`, `#requestCredential`, `#limitStop`, `#dispatch` role checks, `#claimEnd`, `#checkBlock` | reached only from verbs off the list: not reachable. |
| `message.send`, `message.receive`, `message.read`, `messages`, `replies`, `trail`, `decisions`, `credentials`, `state`, `usage`, `decision.withdraw`, `assistant.stop`, `name.set`, `project.create`, `role.claim` | off the list: not reachable. |
| `project.list`, `thread.create`, `thread.list`, `thread.open`, `thread.send`, `thread.messages`, `names` | changed as above. |
## Mutants (each must fail its named test, in user-principal.test.mjs unless named)
| Mutant | Edit | Named test |
|---|---|---|
| M1 | the grant read skipped: no `access-revoked` after the per-request read | a removed or expired grant refuses the next request access-revoked ... |
| M2a | project.list filter dropped | R3 leak: project.list shows granted projects only |
| M2b | thread.list filter dropped | R3 leak: thread.list shows ... never others |
| M2c | names filter dropped | R3 leak: names shows granted instances only |
| M2d | `#grantedThread` check dropped | R3 leak: thread.list shows ... never others |
| M2e | thread.create grant check dropped | R3 leak: thread.create needs the project and the thread instance granted ... |
| M3x | `message.send` added to `USER_VERBS` (list mutant) | every verb off the collaborator list refuses ... |
| M4 | the reply-to check dropped | a reply to: user without a matching in_reply_to is refused user-reply-required |
| M5 | the business human dropped from bindUser's reserved list | a user named like the business human ... |
| M6x | `trail` and `replies` added to `USER_VERBS` (second list mutant) | every verb off the collaborator list refuses ... |
| M7 | user-thread-human check dropped | a user thread run cannot message the owner ..., B9 |
| M8 | `to === 'user'` dropped from `#waiting` | the run reply to a user message reaches ... with no waiting and no notice |
| M9 | thread.send writes `human.input` for a user | thread.send is from user:<id> and writes no human.input |
| M10 | the load check accepts a role keyed `user` | a business with a role keyed user refuses invalid-business |
| M11 | instance-settings accepts instance `user` | instance-settings.test.mjs: row 165 refuses each validation failure ... |
Rulings from Dewey's early look (dewey-0777), confirmed by Sage (sage-2299 rulings 1 and 2), on the two
mutants the design and brief name:
1. "#threadFor's user refusal removed" is an equivalent mutant. No user request reaches `#threadFor`: its
only callers are message.send and `#reviewMessage` (review.request and review.verdict), all off the list.
The line stays as defence in depth. M3x stays as a list mutant. The kit also runs the reply-chain test
under M3x as the two-walls evidence: with the access-refused assertion turned into a log line, it records
the code alice's send gets (expected `human-required`, from `#human`, since a user has no run) and still
asserts bob's thread holds one message.
2. The brief's "drops the 193b1a filter for user sessions" line is void (sage-2299 ruling 2): a user can't
call trail or replies, so no filtered read reaches a user. M6x is a second list mutant, not a stand-in.
## Notes
- N1: fixed in r2 (dewey-0777). The broker's load check and the instance writer refuse `user`, one test each
(M10, M11). The instance-settings change stays in R3 and the manifest widens to it and its test (sage-2299
ruling 3). Sage checked the live business config: no role or instance is named `user`, so a re-pin
carrying R3 loads (ruling 4; Sage checks again before that re-pin).
- N2: server.mjs answers a `user` envelope with no `authenticateUser` as `human-required`, the existing
default for a non-tester kind. The runtime always passes `authenticateUser`, so only a bare test server sees it.
## r4 (sage-2486, sage-2557): on row 194's landing
Row 194 landed as fd6dd327c. r4 rebases R3 onto it and adds four changes. Sage ruled on each.
- Halt (sage-2486): processing halt is the kill switch, so it refuses a granted user's writes
(thread.create, thread.open, thread.send) with `processing-halted`. The reads on #processingGate's list,
thread.messages among them, still answer. The kill-switch plan (:14-16) needs this, and Dewey agreed. Test:
"R3 r4: while halted, a granted user's thread.send refuses processing-halted and thread.messages still
answers". setup() takes a `processing` stand-in for it, as lifecycle-verbs T15 does.
- S1 (sage-2557): row 194's hire takes an instance name. Its two checks refused only `human` and `tester`, so
a chief could hire `user` and get past N1's load-check refusal. Both now refuse `user` with the code they
already give (`invalid-request`): broker #hireFireArgs, and the writer's hire() in instance-settings.mjs. One
test each: user-principal "R3 r4: the chief's hire refuses an instance named user ..." (with `helper` as a
control that passes the name check), and a hire loop in instance-settings' "row 165 refuses each
validation failure ..." that checks the file's bytes stay unchanged.
- S2 (sage-2557, sage-2501): the `access` TypeError check moves up beside harnessOn, testerOn and roleFile,
before the store is read or initializeControl runs. Test: "R3 r4: a non-function access throws before
the store is read" (null store, T8b style).
- Merge: the three 194 conflicts in broker.mjs are unions. R3's `user` role refusal moves into 194's
checkBusiness(). instance-settings takes 194's add/change/delete op list with R3's `user` refusal.
Mutants M12-M15 kill each of the changes above: M12 the broker hire, M13 the writer hire, M14 the access
check moved below the store read, M15 thread.send added to the halt-exempt list. The kit has 19 mutants plus
the M3x two-walls run. All 19 anchors match exactly once on the r4 tree.
## r5 (sage-2566): covers moves into the vocabulary
r4 was rebased onto row 236's landing, bd5f11ded, as 1920b926f. Sage ruled (b) in sage-2566, with Dewey's support
(dewey-0820), and r5 goes on refactor 47a35bf43. Checking that rebase found a fault in R3 itself.
The broker imported `covers` from business/src/access.mjs. That broke the pin in project-runtime.test.mjs
("Broker keeps the sandbox client import closure ..."), and it would have broken the runner: the session
sandbox mounts only seat/src/sandbox.mjs RUNNER_FILES, and access.mjs, which imports errors, business and
util, isn't one of them. R3 had never been run, so nobody had seen this.
- `covers`, `LEVELS` and the all marker (now `GRANT_ALL`) move into business/src/vocabulary.mjs. That file
is already in RUNNER_FILES and still imports nothing, the same reason row 194 put `unhireableAction`
there. access.mjs imports them back and re-exports `LEVELS` and `covers`, so index.mjs, the cli and the
access tests don't change.
- The broker takes `covers` from its vocabulary import. The pin test adds it to that line, with a comment.
- RUNNER_FILES is unchanged.
- M16 puts the access.mjs import back, and three tests must fail on it: the project-runtime pin; seat
sandbox.test.mjs "RUNNER_FILES is exactly what the runner and the harness sources it starts import"
(M16b), which walks the real import graph from the runner; and the confined-session test there (M16c),
which starts the runner under bwrap with only the RUNNER_FILES mounts, so it proves the closure by loading
it. M16c is skipped where bwrap can't run, and the gate log names any skip. The kit has 22 mutant runs plus
the M3x two-walls run, and every anchor matches exactly once.
- The manifest widens to vocabulary.mjs, access.mjs and project-runtime.test.mjs. Tests that import
either module use named exports only; none deepEquals an export list or pins a hash (sage-2566 item 4).
The kit runs project-runtime, access, access-levels and seat sandbox whole, plus the seat suite, and adds the cli suite and every `scripts/test-*.sh`
(mos-1200).
## r6 (sage-2576): the list test pins the collaborator list
The r5 gate on f58cfc361 stopped red at 10:05:09Z: M3x and M6x survived (morgana-0108). The test "every
verb off the collaborator list ..." deleted the broker's own USER_VERBS from its off-list set, so a verb a
mutant added to the list left the checked set as well. The broker's allowlist at the user gate is right.
- The test now deepEquals USER_VERBS against the ruled seven verbs, written out, before the delete loop.
M3x and M6x fail on that line.
- The off-list set adds GRANTS_VERBS, LIFECYCLE_HUMAN_VERBS, WEB_VERBS and HOST_VERBS (sage-2576 item 2).
The user gate refuses each of them `access-refused` before any other check. If one doesn't, that is a
finding for Sage, not a test fix.
- Test only. Only user-principal.test.mjs's blob changes in the manifest. The gate reruns whole on the r6 tip.
## Gate
The kit is /mnt/storage/scratch/morgana-r3 (`slot-r3.sh`, `mutate.py`, `busy.sh`), run as
`bash busy.sh && CAND=<sha> bash slot-r3.sh` on Sage's CLEAR. It runs these files whole: user-principal,
principal, human, tester, tester-threads, tester-writes, threads, names, messaging, messages, decisions,
decision-route, broker, transport, peer-transport, process, message-filter, harness runner and business
instance-settings, project-runtime, business access and access-levels, and seat sandbox. Then the bus, harness, business, cli and seat suites, then the 22 mutant runs (r5), each on its named
test and restored after each, then the M3x two-walls run. It uses
umask 022 and TMPDIR from `mktemp -d /mnt/storage/scratch/tmp/gr3XXXX`, refuses unless HEAD is the
candidate and the tree is clean, and stops after two unexpected failures (mos-0823).
The kit runs every `scripts/test-*.sh` after the suites and counts each script's `^skip` lines (r5). Every run is slot work (sage-2280,
sage-2299 ruling 5); the brief's "single test files run freely" line is stale.
Static check: `node --check` on every changed source and test file, run from `/`. All parse. All fifteen
mutant anchors match exactly once.
+82
View File
@@ -0,0 +1,82 @@
# Row 205a (193b1a, #1703): the trail and replies message filter
Design: DESIGN.md d2 in this directory, with rulings in section 7 (sage-2288). Base: refactor 02da6a2ae. No schema
change. Nothing has run yet: every test run waits for Sage's CLEAR.
## Changes
`packages/bus/src/broker.mjs`:
- `#hiddenMessage(s, id)`: the message's thread (`message_threads` joined to `threads`) is owned by a `user:`
other than `#owner(s)`, and `#readable(s, t)` is false. A message with no thread, or in the business human's
or the tester's thread, is never hidden. A reader session owns nothing, so every user thread is hidden from it.
The tester reads through its reader view, and `#owner` still gives `tester`.
- `#readable(s, t)` returns false. Rule 1 (a share in force) can't hold before 193b2's schema 3j, so nobody
reads another's thread. 193b2 adds the share rule and rules 2-4 here.
- `#trail`: a hidden subject returns `[]` before anything is read, exactly as an unknown id does (ruling 3).
After the rows are collected, it drops hidden `messages` rows, `deliveries` rows of a hidden message, and
`events` rows whose subject is a hidden message. Decisions, decision events, task snapshots, run events and
claims stay.
- `replies`: a hidden id fails `message-not-found`, as an unknown id does. Hidden replies are left out in SQL
(`NOT EXISTS` a thread whose owner starts with `user:` and isn't the caller), so the `REPLIES` limit counts
only visible rows.
- The SQL clause doesn't call `#readable`. That is exact while `#readable` is false. 193b2 must add the share
exception to the clause and to `#readable` together.
`packages/bus/tests/message-filter.test.mjs` (new). It writes a `user:alice` thread and its messages
straight to the store, as R3's `thread.send` and the run's `to: 'user'` reply will write them, because nothing
makes a user session before R3.
- `trail` on a hidden id equals `trail` on an unknown id, byte for byte, for the owner, a reader and the tester.
- `replies` on a hidden id equals `replies` on an unknown id (code and message), for all three.
- `trail` on a user-thread run's decision shows the decision and the owner's message, and drops the user
thread message, its delivery and its event.
- `replies` with `REPLIES` hidden replies ahead of a visible one returns the visible one.
- Messages in the business human's and the tester's threads stay visible on both verbs (row 177 control).
- The governance pin, one assertion per item in the user sentence: inbox entry, claim, task, project file and
the closed decision in `decisions {list: 'recent'}` show to the owner. `trail` and `replies` hide the user
message, and the decision's trail carries no user body.
## Mutants (each must fail its named test in message-filter.test.mjs)
| Mutant | Edit | Named test |
|---|---|---|
| M1 | `#readable` returns true | governance pin: the owner sees decisions, inbox entries, claims, tasks and project files, but not the user messages |
| M11 | the trail drop loop iterates nothing | trail on a decision keeps the decision and drops the user thread messages, their deliveries and their events |
| M12 | the replies `NOT EXISTS` clause removed | replies leaves out other users thread messages and still fills a page |
| M13 | trail fails `message-not-found` for a hidden subject | trail on a hidden message id answers exactly as on an unknown id, for the owner, a reader and the tester |
| M14 | replies returns `[]` for a hidden id | replies on a hidden message id answers exactly as on an unknown id, for the owner, a reader and the tester |
## Gate
The kit is /mnt/storage/scratch/morgana-r205 (`slot205.sh`, `mutate.py`, `busy.sh`), run as
`bash busy.sh && CAND=<sha> bash slot205.sh` on Sage's CLEAR. It runs these files whole:
- message-filter;
- the bus files that call trail or replies: replies, tester, tester-threads, tester-writes, broker, decisions,
decision-close, end-launch and task-read.
Then it runs the bus suite, then M1 and M11-M14, each on its named test and restored after each. It uses umask
022 and TMPDIR from `mktemp -d /mnt/storage/scratch/tmp/g205XXXX`. It refuses unless HEAD is the candidate
and the tree is clean, and it stops at the first unexpected failure (sage-2371).
Sage's gate adds every `scripts/test-*.sh`. The webui tests that read trail or replies run there.
Static check: `node --check` on broker.mjs and the new test, run from `/`. Both parse. All five mutant anchors
match exactly once.
## r2 (after the 742cba805 slot, failure 1 of the window, sage-2428)
slot205 on 742cba805 stopped red in message-filter, 4 pass and 2 fail. Both fixes are in the test only.
broker.mjs is unchanged (sage-2430).
- "trail on a decision ..." (:124, its event shows). `#trail` reaches events on a decision trail by
`subject` = the decision or `body.$.decision` = the decision. `put()` wrote every event body as `{}`, so the
shown message's event was out of reach. That also made the "no hidden event" check (:127) vacuous: the hidden
event was never collected, so dropping it proved nothing. Fix: `put()` writes `{decision}` as the event body
when the message has a decision. Both events are now collected; the filter keeps one and drops the other.
- Governance pin, claims item (:175). A thread run's `role.claim` writes `thread_claims` only and returns
(broker.mjs:2682). `agents` (:3309) reads `role_claims` only. No owner verb shows a thread claim. The gap
predates 205. Ruling sage-2430, option (a): the pin checks through `threadsState` (the launcher view) that
the claim is recorded with holder `assistant-run` and isn't filtered. `agents` is unchanged in 205.
Follow-up: Sage adds a row for an owner-visible thread-claim read. Like the filter, it lands before any
user thread goes live, since PRD :1084 and the FED R6 sentence promise that the owner sees claims.
M1 still fails the pin: with `#readable` true, `trail` on the user message returns rows instead of `[]`.
+173
View File
@@ -0,0 +1,173 @@
# Row 205 (193b1, #1703): viewer verbs, board refusals and the message filter
Owner Morgana, reviewer Duckworth, gate owner Sage (sage-2280). Brief: `docs/plans/2026-10-20_human-users-build.md`,
"Brief: 193b1, viewer verbs, board refusals and the message filter" (blob a73e1bba0, rev 3088). Design:
row 193 DESIGN.md r5 (e2d161e2e, Duckworth approved), sections 4.3, 5.4, 5.5, 5.7, 8 and 9. Base: refactor
02da6a2ae. Draft 2: Sage's rulings (sage-2288) are in section 7. Sections 1-6 are d1 as sent (morgana-0063).
## 1. The dependency gap
The brief chains 193b1 on FED R3, FED R4 and row 196's verifier. Only row 196 exists (7247d80c7). On 02da6a2ae:
- R3 and R4 aren't filed. The FED brief (`docs/plans/2026-10-20_federation-r1-r2-r5.md`:12-13) files R3 "after
R1 and row 196" and R4 after R3, R5 and 193a. No `*/fed-r3` branch is on origin.
- Row 196's `verifyPrincipal` returns the `user` kind, and server.mjs accepts the `user` envelope, but
runtime.mjs wires only `authenticateHuman` and `authenticateTester`. The broker has no `bindUser`, no
`s.user`, no per-request grant read and no collaborator verb list. FED 3.6 and row R3 own all of these.
- So nothing makes a user session today, and no thread is owned by `user:<id>` except through a direct
store write.
193b1 has two halves that depend on R3 in different ways.
| Item | Needs a user session (R3) | Testable on 02da6a2ae |
|---|---|---|
| Viewer verb list (4.3) | yes: it extends R3's collaborator list | no |
| Per-grant level checks via `covers(..., {level})` | yes: R3's grant read | no |
| Board reads refused to users (5.5) | yes | no |
| `trail` and `replies` filter (5.7) | no: it filters owner, reader and tester sessions | yes, with seeded `user:<id>` threads |
| `#readable` owner-only | no | yes |
| Governance pin test (owner side) | no: the owner reads decisions, inbox, agents, tasks and `project.file` | yes, with a seeded user-thread run |
| Mutants 6 (viewer `thread.send`), 10 (user allowed `trail`) | yes | no |
| Mutants 11, 12 (trail, replies filter removed) | no | yes |
| Mutants 1, 3 (`#readable` rules 1 and 3 dropped) | see 4 | see 4 |
## 2. Recommendation: split 205 into 205a now and 205b after R3
- **205a, the message filter.** `trail` and `replies` hide messages in other users' threads from the owner,
reader sessions and the tester, with `#readable` owner-only. Plus the owner-side governance pin test and
mutants 11 and 12. No R3 dependency, no schema change.
- **205b, viewer verbs and board refusals.** Built on R3's candidate, as Q177 chains a row on its parent's
candidate. The viewer list, the level checks, users refused on board reads, the user-session half of the
pin test, mutants 6 and 10.
Why split. The filter is the item the boundary orders first: "No user thread goes live before 193b1's
message filter has landed" (mos-1021 B2, FED R8). Landing it before R3 means R3 and every later row can't
create a user thread that the owner can read on `trail`. Waiting for R3 holds the filter behind a row that
isn't filed. The other option, building all of 193b1 against R3's named interface as 193a did with R1,
doesn't work here: 193a only called R1's exported `covers`, while 193b1 would have to write `bindUser`, the
grant read and the collaborator list itself, which is R3's scope and owner.
Tradeoff: two rows and two reviews in place of one, and 205b rebases onto R3. The split is a brief change,
so it's Sage's to rule.
## 3. 205a design
### 3.1 The hidden-message check
One broker method, `#hiddenMessage(s, id)`:
```
SELECT t.owner FROM message_threads mt JOIN threads t ON t.id = mt.thread
WHERE mt.message = ? AND t.business = ?
```
A message is hidden when that owner starts with `user:`, isn't `#owner(s)`, and `#readable(s, t)` is false.
A message with no thread row, or in a thread owned by the business human or `tester`, is never hidden, so
row 177's behaviour is unchanged. A reader session has no `#owner`, so every user thread's messages are
hidden from it.
`#readable(s, t)` in 205a returns false. Before 193b2's schema 3j there is no `thread_shares` table, so rule 1
(a share in force) can't hold, and the function is false for anyone but the thread's owner (5.7: "b1 hides
every message in another user's thread"). It is written as its own method so 193b2 adds the share rule and
rules 2-4 in one place.
### 3.2 `trail`
- If `subject` is a hidden message id, return `[]`. Design 5.7 says `message-not-found`, "as an unknown id
does", but `trail` on an unknown id returns `[]` today (it has no not-found check). A different code for a
hidden id would confirm the id exists, which is what 5.7 wants to prevent, so I follow its reason over its
code. Question 3.
- After `#trail` collects its rows, drop: `messages` rows that are hidden, `deliveries` rows whose `message`
is hidden, and `events` rows whose `subject` is a hidden message id. This covers each way `#trail` reaches
a message: by id, by `decision`, and through `task.created` → `human.input` → `body.message`.
- Decision rows, decision events, task snapshots, run events and role claims stay. 5.7 puts them on the
governance side.
### 3.3 `replies`
A hidden `id` fails `message-not-found`, and hidden messages are dropped from the result. The `LIMIT`
applies before the filter today; the filter moves into the SQL (`AND NOT EXISTS` a thread owned by another
`user:`) so a page of hidden replies doesn't return short. For 205a that SQL form is exact because
`#readable` is false; 193b2 adds the share exception to the same clause.
### 3.4 Tests (packages/bus/tests/message-filter.test.mjs, new)
Fixtures seed a project, a thread owned by `user:alice` with messages in `message_threads`, a delivery and an
event on one of them, and a decision raised by that thread's run that cites the message. Sessions: the
business human, a reader session and the tester.
- `trail` on a hidden message id: `[]` for all three sessions, the same as an unknown id.
- `trail` on the decision: the decision row and its events show; the user's message, its delivery and its
events don't.
- `trail` on a human-owned thread's message: unchanged (row 177 control).
- `replies` on a hidden id: `message-not-found`. `replies` on a visible id with hidden and visible replies:
only the visible ones, and a full page when hidden replies come first.
- Governance pin, owner side: for the user-thread run, `decisions {list: 'recent'}`, `inbox`, `agents`,
`tasks` and `project.file` each show its item, and `trail` and `replies` hide the user's message body.
Each item in the user sentence is one named assertion.
### 3.5 Mutants
| Mutant | Edit | Named test |
|---|---|---|
| 11 | the `trail` filter removed | trail on the decision hides the user's message, delivery and events |
| 12 | the `replies` filter removed | replies hides other users' thread messages |
| 11b | the hidden-subject early return removed | trail on a hidden message id returns [] |
| 1 | `#readable` returns true | governance pin: trail and replies hide the user body |
## 4. Mutants 1 and 3
Design section 8 lists mutants 1 (rule 1 dropped) and 3 (rule 3 dropped) for b1. With no shares table,
rule 1 is always false, so `#readable` is false whatever rules 2-4 say. Dropping rule 3 changes nothing a
test can see until 193b2 adds shares, and rules 2 and 3 need R3's grant read. So:
- Mutant 1 in 205a is "`#readable` returns true", killed by the pin test above.
- Mutant 3 moves to 193b2, where rule 1 can hold and rule 3 decides.
This is a second brief change for Sage to rule on.
## 5. Gate (Sage's slot, on CLEAR only)
The brief's gate: Duckworth approves the manifest; the bus suite and every `scripts/test-*.sh` green on
Sage's gate; the mutants killed. The new test file whole, the touched existing files whole, then the bus
suite, then each mutant on its named test with a restore check. umask 022, TMPDIR from
`mktemp -d /mnt/storage/scratch/tmp/gXXXXXX`. No test touches the live data root, config, units, slot
files, any live access.json or `~/.mosaic`.
## 6. Questions for Sage
1. Split 205 into 205a (filter, now) and 205b (viewer verbs and board refusals, after R3's candidate)?
2. Mutant 1 as "`#readable` returns true" in 205a, and mutant 3 moved to 193b2?
3. A hidden `trail` subject returns `[]`, the same as an unknown id, in place of 5.7's `message-not-found`
(3.2)? `replies` already fails `message-not-found` on an unknown id, so there the code stays.
## 7. Rulings (sage-2288, re morgana-0063)
1. **Split approved.** 205a is built now: the 5.7 `trail` and `replies` filter over owner, reader and tester
sessions, `#readable` owner-only, the owner-side governance pin test, and mutants 11 and 12. 205b comes
after R3's candidate and R4: viewer verbs, `covers` level checks, board reads refused to users, the
user-side pin, and mutants 6 and 10. 205a's base is origin/refactor 02da6a2ae or later. R3 builds on
205a's candidate and extends the filter to user sessions, so the filter isn't written twice.
2. **Mutant 1** in 205a is "`#readable` returns true", killed by the pin test. **Mutant 3** moves to 193b2
(row 206). Sage changes the brief to match.
3. **Hidden equals unknown.** A hidden `trail` subject returns `[]`, exactly as an unknown id does. `replies`
keeps `message-not-found` for both. The point of 5.7 is that a caller can't tell hidden from unknown.
So 205a adds:
- one test per verb comparing the hidden response with the unknown-id response byte for byte
(`JSON.stringify` of the result, or of the error's code and message), for each of the three sessions;
- a mutant M13 that makes the hidden path distinguishable: `trail` fails `message-not-found` for a
hidden subject. The `trail` equality test must kill it. M14 does the same for `replies`: a hidden id
returns `[]` instead of failing. The `replies` equality test must kill it.
### 7.1 205a mutant table (replaces 3.5)
| Mutant | Edit | Named test |
|---|---|---|
| M1 (design 1) | `#readable` returns true | governance pin: the owner sees decisions, inbox entries, claims, tasks and project files, but not the user's messages |
| M11 (design 11) | the `trail` filter of hidden rows removed | trail on the decision hides the user's message, its delivery and its events |
| M12 (design 12) | the `replies` filter removed | replies drops other users' thread messages and still fills a page |
| M13 | `trail` fails `message-not-found` for a hidden subject | trail on a hidden id answers exactly as on an unknown id |
| M14 | `replies` returns `[]` for a hidden id | replies on a hidden id answers exactly as on an unknown id |
Queue: Sage re-pins 205 to the split brief and adds 205b and the R3 row at the next gap. I claim after
that. No run before Sage's CLEAR.
+267
View File
@@ -0,0 +1,267 @@
# #1782: the owner sees a thread agent's claims (design r1)
Morgana, 2026-10-22, for Duckworth. Brief: docs/plans/2026-10-22_owner-thread-claims.md (Sage, for
mos-1262). The brief wasn't committed and had no queue row when this was written, so the folder is named
for the brief. It moves to queue-<row> once the row exists. Base: refactor c27207f6e (205 landed at
ce6a96ad6, then 268). No code yet.
## What the code does now
- A thread run's `role.claim` writes `thread_claims` only and returns (broker.mjs:2682 at ce6a96ad6).
- `agents` (broker.mjs:3409) reads `role_claims` only:
- human and reader sessions get `#agentState(c)` per held role;
- role and tester sessions get the raw rows.
- A held thread claim is the thread's latest `thread_claims` row with op `claim`. `#threadHolders`
(:483) and `#threadRows` (:624) already compute it that way, with `c.seq = max(seq)` per thread.
- `threads.name` is the name a user wrote. `threads.instance` is the role instance from the business
file, which the operator writes, not the user.
## The problem with the brief's shape
The brief adds thread rows to the human and reader `agents` result, after the role rows. Every consumer
of that result assumes one row per role:
| Consumer | What it reads | What a thread row would do |
|---|---|---|
| Console Agents view, webui bus.js:474 | `c.role` in a "Held roles" table, `claims.length` as its count | blank role cell, wrong count |
| Console board held table, bus.js:447 and :462 `held-count` | same | same |
| Console composer, bus.js:298 | `c.role`, filtered with `typeof r === 'string'` | dropped safely |
| CLI TUI agents view, screens.mjs:280 and :284, app.mjs:288 and :433 | `c.role` as title and column | blank title |
| CLI `mosaic agents`, format.mjs:54 | `c.role` | `undefined` in the line (brief item 4 covers this one) |
So the shape as briefed also needs the Console (webui) and the TUI changed in this row, or the live
Agents view breaks on the next re-pin. The brief's manifest names only format.mjs.
## Proposed shape: a separate owner verb
The brief allows this ("for example a separate owner verb, it goes in BUILD.md to Duckworth before
code").
**D1. New verb `thread.claims`**, human and reader sessions only. Every other session refuses with
`human-required`, the code `decisions` uses at :3426. No arguments (`keys(a, [], [])`). The result, in
thread creation order:
```js
[{ thread, instance, holder_run, harness, at }]
```
- One row per thread whose latest `thread_claims` op is `claim`.
- `at` is that claim row's time.
- It doesn't return `name`, message content or `address`. `address` matches the brief's four fields;
it can be added if Duckworth wants it next to the role rows.
- `instance` is beyond the brief's four. It names which assistant holds the thread, and the operator
writes it, not the user.
SQL, the `#threadHolders` join without the instance and owner filter:
```sql
SELECT t.id AS thread, t.instance, c.holder_run, c.harness, c.at FROM threads t
JOIN thread_claims c ON c.seq=(SELECT max(seq) FROM thread_claims WHERE thread=t.id)
WHERE t.business=? AND c.op='claim' ORDER BY t.seq
```
**D2. `agents` doesn't change** for any session. That is stronger than brief item 2 (role sessions
only), and it means the Console, TUI and composer need no change in this row.
**D3. Reader allow-list.** `thread.claims` goes in the reader list at :2370. The processing gate (:402)
already exempts human and reader sessions, so a halt doesn't hide claims. No TESTER_VERBS or USER_VERBS
entry is added (see D6).
**D4. CLI `mosaic agents`** (cli.mjs:314) calls `agents` and then `thread.claims`. `formatAgents(list,
threads = [])` prints the role lines unchanged, then for each held thread claim:
```
thread <thread> <instance> <holder_run> <harness> since <at>
```
Each line starts with `thread `, so a reader can tell the two kinds apart (brief item 1). Role output
with no thread claims is byte-equal to today, including "agents: no role is claimed". When there are no
roles but there are thread claims, the empty-roles line is still printed, then the thread lines.
**D5. The governance pin.** message-filter.test.mjs, the "claims" item: `threadsState` becomes
`call(owner, 'thread.claims')`, which must include `{ thread: U, holder_run: 'assistant-run' }`. The
comment that names this row goes away (brief item 5).
**D6. What this row leaves out**, so as not to widen beyond owner and reader sessions (mos-1202):
- tester sessions;
- FED R3 user principals, who own threads under R3, so their own claims are an R6 question;
- a Console section;
- the TUI.
## Questions for Duckworth and Sage
- **Q1.** Do you accept D1 over the briefed mixed list? If you'd rather have the mixed list, the
manifest grows to webui bus.js, screens.mjs and app.mjs, plus their tests (bus-browser,
webui bus.test and the TUI tests).
- **Q2.** Should the Console show thread claims before a user thread goes live? PRD :1083-1084 promises
visibility to the owner, and a Console user won't run `mosaic agents`. My recommendation: a follow-up
row bound like this one (it lands before any user thread goes live), so this row stays small for
10-27. The alternative is a "Thread claims" section in the Console Agents view in this row.
## Tests (under D1)
All new tests go in packages/bus/tests/agents.test.mjs unless named otherwise. Each one runs on a store
with one held role claim and one thread run.
- **T1.** Human `thread.claims` lists the held thread claim (thread, instance, holder_run, harness,
at). Reader gets the same result.
- **T2.** After release, the thread is gone. After a re-claim followed by a revoke, it's gone again.
The thread has an earlier `claim` row, so a first-op reading would still list it.
- **T3.** A role session's `thread.claims` refuses `human-required`.
- **T4.** `agents` is deepEqual to a copy taken before the thread run claimed, for the role, human
and reader sessions on the same store.
- **T5.** `JSON.stringify` of the `thread.claims` result has neither the thread's name nor the body of
a message sent in that thread.
- **T6.** packages/cli/tests/format.test.mjs: a mixed list renders the role lines exactly as today,
then `thread …` lines. The empty-roles case with threads is covered too.
- **T7.** packages/bus/tests/message-filter.test.mjs: the moved pin (D5).
## Mutants (each killed by a named test)
| Mutant | Change | Killed by |
|---|---|---|
| M1 | `thread.claims` returns `[]` | T1, T7 |
| M2 | the held check uses `min(seq)` (first op) | T2 |
| M3 | the human/reader check is dropped, so a role session gets thread rows | T3 |
| M4 | `agents` for human appends the thread rows | T4 |
| M5 | the SELECT adds `t.name` | T5 |
| M6 | formatAgents drops the thread lines | T6 |
## Manifest (expected)
- packages/bus/src/broker.mjs
- packages/bus/README.md (a paragraph for `thread.claims`, next to `agents`)
- packages/bus/tests/agents.test.mjs
- packages/bus/tests/message-filter.test.mjs
- packages/cli/src/cli.mjs
- packages/cli/src/format.mjs
- packages/cli/tests/format.test.mjs
- this BUILD.md
Verb-list pins checked at c27207f6e:
- tester-writes.test.mjs:19 pins `TESTER_VERBS` exactly. D6 adds nothing to it, so the pin holds.
- No test pins the reader list at :2370.
- `thread.messages` appears in tester-threads, threads and webui chat as calls, not as a verb set.
Before the in-review move, I'll rerun the same grep for `thread.claims` and for any new verb-set pin.
Gate (brief): Duckworth approves the manifest. On Sage's gate, the touched test files run whole, the
bus and cli suites are green, every scripts/test-*.sh is green, and M1-M6 are killed.
## r2 (duckworth-0118: D1 approved with C1-C3)
Q1: Duckworth accepts D1. Q2: Duckworth is fine with a follow-up row under the same bind; Sage rules on
it. Duckworth's note is agents/duckworth/work/row1782/DESIGN-r1.md on duckworth/reviews 13fb9abc5.
**C1. `--tester` makes one call.** callFor binds the tester when `--tester` is given (cli.mjs:192).
D6 keeps `thread.claims` off TESTER_VERBS, so a second call there would refuse tester-refused and exit
3. tester.test.mjs:128 pins exactly one spawn, `agents`. So cli.mjs calls `thread.claims` only when
`--tester` is absent. A tester's `mosaic agents` is unchanged.
- T8 (packages/cli/tests/format.test.mjs or the CLI test that drives main() with a stand-in transport;
the exact file is picked at build): `agents --tester` makes one call, `agents`; human `agents` makes
two, `agents` then `thread.claims`.
- Gate adds tester.test.mjs and other-tree.test.mjs, each run whole.
- M7: thread.claims is called on the tester path too. T8 and tester.test.mjs:128 kill it.
**C2. `--json` shape.** `mosaic agents --json` prints the `agents` array unchanged, as `inbox --json`
prints decisions only (cli.mjs:308). Only the text output adds the `thread …` lines. The machine read of
thread claims is the bus verb, and the bus README paragraph documents `thread.claims` as such. A grep at
c27207f6e found no consumer of `mosaic agents --json` in packages/*/tests or scripts. T6 pins both: the
text output is mixed, and the `--json` output deepEquals the `agents` result alone.
**C3. Where the pin moves.** Under D1 the governance pin moves to the owner's `thread.claims` call, not
to `agents`. That is brief item 5 read under D1: the brief named `agents` because it put thread rows
there.
Notes taken:
- N1: T4's before/after equality covers the tester session too.
- N2: T3 adds a tester: `thread.claims` refuses tester-refused.
- N3: FED R3 user principals must not get other users' thread claims. This row gives them none
(no USER_VERBS entry). The rule carries with 205 N1/N3 to 205b/R3.
- N4: the :403 processing-gate list is in the verb-set grep. Human and reader sessions return before
that list (:402), so `thread.claims` needs no entry there, and no test pins the list (grep at
c27207f6e). The :2370 reader list is the one list that gains the verb.
Mutants are now M1-M7. Building starts once Sage creates the row.
## r3: build (sage-2486, on 23c3d6218)
Changes from the design, each a placement choice:
- **Bus tests file.** T1-T5 go in a new file, packages/bus/tests/thread-claims.test.mjs, not
agents.test.mjs. agents.test.mjs's businesses have no `assistant` instance, so a thread run would
need a new fixture there. The new file copies message-filter.test.mjs's setup (chief plus an
`assistant` thread instance, human, reader and tester sessions). Its threads are owned by the
business human (`jason`), so the human's `assistant.stop` reaches the run for T2's revoke.
- **T3** covers the chief's role session and the thread run itself (both `human-required`) and the
tester (`tester-refused`, N2).
- **T4** covers human, reader, tester and chief sessions (N1).
- **T5** writes the thread's message to the store, as message-filter.test.mjs's put() does. The run's
own message.send would also pass through the messaging graph, which T5 doesn't test.
- **T6/T8 split.** format.test.mjs holds T6's text cases: mixed, empty roles with threads, and no
threads argument. The `--json` pin needs main(), so it sits with T8 in tester.test.mjs, whose
setup has both stand-in transports. That test also pins the call order: tester `["agents"]`; human
`["agents", "thread.claims"]`; human `--json` adds only `agents`. Its JSON deepEquals the broker's
own `agents` result.
- **T7.** The message-filter.test.mjs pin now reads `holder_run` from the owner's `thread.claims`.
The old pin read `holder` from `threadsState`.
Manifest (actual):
- packages/bus/src/broker.mjs
- packages/bus/README.md (a "Thread claims" paragraph at the end of "Agent state")
- packages/bus/tests/thread-claims.test.mjs (new)
- packages/bus/tests/message-filter.test.mjs
- packages/cli/src/cli.mjs
- packages/cli/src/format.mjs
- packages/cli/tests/format.test.mjs
- packages/cli/tests/tester.test.mjs
- agents/morgana/work/queue-277/BUILD.md
Mutants: M1 (T1, T7), M2 (T2), M3 (T3), M4 (T1, T4), M5 (T5), M6 (T6), M7 (T8). Gate: the touched test
files run whole, plus other-tree.test.mjs. Then the bus and cli suites and every scripts/test-*.sh.
Nothing runs before Sage's CLEAR.
Early look (sage-2504, 05:27Z), on c050bb90b: thread-claims.test.mjs failed 5/5. The test's `call`
helper took any string as a session name, but a run's capability is a string too, so the run's calls
went out unauthenticated. T1 also compared null-prototype store rows strictly. Both are test bugs, and
the fix is r2 of the build. On r2, thread-claims 5/5, cli tester 9/9, other-tree 4/4 pass, 0 skipped.
## r3: Duckworth notes N1/N2 (duckworth-0119, ruled sage-2505), for the post-194 rebase round
- **N1.** A human `mosaic agents` no longer fails as a whole when `thread.claims` is refused.
cli.mjs prints the role lines, then `thread claims: refused <code>`, and exits 0. The choice of
exit 0 follows D1: `agents` stays as it was, and it exited 0 before this row. Only an error with a
bus code is caught (the same `^[a-z-]{1,64}$` shape `markRead` checks); any other error still
throws. Pinned by a new test in cli tester.test.mjs: a stand-in transport refuses thread.claims
with `processing-halted`, the role row prints, then the refusal line. This matches row 278 item 4
for the Console. M8 (the catch drops the refusal line, or rethrows) is killed by that test.
- **N2.** packages/cli/README.md: the usage line gains `[--tester]`, and a bullet describes the
`thread` lines, the refusal line, `--json` and `--tester`. host.test.mjs:855 reads this README
for a unit file name, which is still there. host.test.mjs joins the gate's whole-file runs.
Manifest adds packages/cli/README.md (10 paths). Nothing here has run yet. It runs whole after the
rebase onto 194, on Sage's CLEAR.
## r4: rebase onto 0165be266 and Duckworth's delta notes (sage-2587, duckworth-0123)
- **Rebase.** Commits 1-6 are cherry-picked onto refactor 0165be266, which has 194, 236 and 274
landed, on morgana/row277-0165. The range-diff is `=` for all six and the patch-ids are unchanged.
Four files auto-merged around upstream lines: bus README, broker.mjs, cli README and cli.mjs.
- **N1 (duckworth-0123).** The N1 catch's rethrow branch had no test, so a mutant that takes every
error as a refusal (it would print `refused undefined`) survived. A new cli tester.test.mjs test
throws from thread.claims with no code, then with `ECONNRESET`. Each rejects main with the same
error and prints nothing. M9 (the code check dropped) is killed by that test.
- **N2 (duckworth-0123), accepted.** Since 274, the owner's and a reader's thread.claims can include
user threads (id, instance, holder run, harness, time; no name, no messages). That is the design
join approved in duckworth-0118, with no owner filter, and owner-visible run governance under
sage-1368 B1.
- **N3 (duckworth-0123), for later.** When 264a and 277 meet, thread.claims's latest row per thread
goes per session, and thread-claims.test.mjs needs the default session. It is checked in whichever
round lands second.
Gate (sage-2590): the touched test files plus user-principal, other-tree and host whole; the bus,
cli, harness, business and seat suites; every scripts/test-*.sh; M1-M9. The kit prints HEAD and the
manifest check first. Nothing runs before Sage's CLEAR.
+127
View File
@@ -0,0 +1,127 @@
# Row 202 BUILD: FED R5, `mosaic access list|grant|revoke`
Quackerjack, 2026-10-20. Plan: `PLAN.md` (commit 2d0aa2937). Manifest: `MANIFEST.md` in this directory.
Brief: `docs/plans/2026-10-20_federation-r1-r2-r5.md`, "Brief: FED R5". Reviewer Duckworth. Gate owner Sage.
Review record: round 2 approved as duckworth-0041 (commit 2a1d17da7) on 48a3fb96e; viewer carry c204bef73 after it.
## What was built
- `packages/cli/src/access.mjs`: the three verbs, the human proof, the context (`accessContextFor`), the
atomic write, the lock and the receipt.
- `packages/cli/src/cli.mjs`: routes `access` to `accessCommand`.
- `scripts/mosaic`: sends `access` to the CLI.
- `packages/cli/README.md`: the verbs, the human proof, the lock, the `instances` limit, the removal order,
and the limits on peers and subjects.
- `packages/cli/tests/access.test.mjs`: 21 tests (round 2; 18 in round 1). Single file, green on this branch.
R1 (`packages/business/src/access.mjs`) is merged in unchanged at 80fe31071, the Duckworth-approved
candidate. 44e36fc4c, the next commit on that branch, only removes a stray pyc file.
## The lock file (not in PLAN.md; added at Sage's request)
- Path: `<configDir>/access.json.lock`, the access file's path plus `.lock`. Mode 0600.
- Created with `O_EXCL` (`wx`). The holder writes its pid and a newline into it, then holds it for the
read, the check, the temp write and the rename. It is removed in a `finally`.
- A second writer that finds the lock reads the pid. If the pid is alive (or the pid is reused by a live
process), it refuses with `refused: ... being written by another command; try again` (exit 3). Nothing
is written.
- A lock whose pid is gone (`kill(pid, 0)` returns ESRCH) also refuses, and nothing reclaims it. The
refusal names the path and the pid. A human checks that no write is running and removes the file. This
is the crash case: the holder died mid-write, and a human clears it.
- An empty lock, or one that is not a pid, refuses. Its holder may still be between create and write. A
holder that died in that gap leaves an empty lock; a human removes it.
Reclaim was removed at Sage's ruling (sage-1429, 10-20). It was the one design with a race, so the
race below is gone, not accepted.
Former limit, now removed: reclaim race. The old design let a writer take a stale lock by unlinking it
and retaking it. The case that lost: writers A and B both read the same dead pid N. A unlinks the lock
and takes it. B then unlinks A's live lock and takes its own. Both read the same revision, both write
it, and the second rename silently replaces the first. The revision check did not catch it, because both
had read the old revision before either wrote.
Known limits, stated plainly:
1. A dead holder's lock blocks every write until a human removes it. That is the fail-closed price of
no reclaim. The refusal says which pid and which path.
2. A reused pid reads as alive, so a stale lock refuses. The fix is to remove the file by hand.
3. The lock is per access file. It does not cover the bus's own reads.
4. Crash-safety of the write path: the temp file is removed on any failure before or at the rename. The
directory is not fsynced after the rename, so a power loss right after a rename can lose the rename.
The bytes are never mixed.
Tests for the lock (all in `access.test.mjs`):
- "a live holder's lock refuses and leaves the file and the lock alone": pid = this process.
- "an empty or unreadable lock refuses": empty file, and `not-a-pid`.
- "a lock left by a dead pid refuses, names the pid, and is not reclaimed": a child that has exited. The
file, the lock and the receipt are unchanged.
- "the lock is mode 0600 while held and removed after a refused write".
Tests for the write path and the refusals (added in this round):
- "a failed rename leaves the old bytes, removes the temp file, and writes no receipt": the fs seam
(`deps.fs`) makes `renameSync` throw. Checks the old bytes, no `.access-*.tmp`, no lock, no receipt.
- "a group-writable access file refuses with exit 3, names the file, and changes nothing": a 0664 file.
Exit 3, the file path in the message, nothing written.
- "an agent or tester caller is refused before the context or the access file is read": `contextFor`
throws if called, and the file is 0664. The refusal must come first.
## The human proof, and the bus behavior behind test 10
The proof is one `project.list` call through `humanTransport`. The bus gates it with `#human`
(`packages/bus/src/broker.mjs`, the `project.list` case at :2319, and `#human` at :377). The call is in
one function, `proveHuman` in `access.mjs`, for R4 to swap.
Test 10 runs the real `human-cli.mjs` on a scratch bus from agent ancestry (`CLAUDECODE=1` in the child's
env). The bus refuses. The client reports `outcome-unknown`, not `human-required`, because the bus closes
the socket on a failed proof and `packages/bus/src/client.mjs:32-33` maps a closed socket to
`outcome-unknown`. Exit 1 (`busExit`). The test pins that code and asserts the access file and the
receipt are unchanged.
This is a bus behavior outside R5. It is not changed here. Gizmoduck and Gander own the verifier (row 196).
Until it changes, a human who hits the agent refusal sees `outcome-unknown`.
## Context
`accessContextFor(system, env)`:
- `providers`: `system.console.sso.providers[].id`. Row 192's shape (packages/webui/src/sso-config.mjs on
row 192's candidate).
- `ssoSubjects`: each provider's `subjects[].sub`, paired with the provider id. Gizmoduck's design says
`console.sso.subjects`; Sage ruled that is shorthand for this (erratum).
- `peers`: `[]` until row 203 adds the federation key. A grant that names a peer home refuses.
- `businesses`: `loadBusiness` for each `<configDir>/businesses/*.json`; `human` and the role names as
`instances`. A business file that fails to load fails the command.
No `console` key gives empty providers and subjects, so every grant naming a subject refuses. The
`checkConsole` check from row 192 is not on this branch yet. R5 rebases onto row 192's landing, and then
reads `console` through `checkConsole`.
## Mutant list (one per refusal; run on Sage's go, before review)
| # | Mutant | Expected kill |
|---|---|---|
| M1 | remove the `refuseInsideAgent` call | "an agent caller is refused before the bus is asked" |
| M2 | remove the `--tester` check | "--tester is refused on every verb" |
| M3 | the proof's error is swallowed | "a bus that refuses the human proof blocks the write" |
| M4 | the revision compare is removed | "a stale revision refuses with revision-conflict" |
| M5 | `takeLock` unlinks a dead-pid lock and retakes it (the reclaim this round removed) | "a lock left by a dead pid refuses, names the pid, and is not reclaimed" |
| M6 | `pidAlive` always returns true (a dead pid reads as live) | "a lock left by a dead pid refuses, names the pid, and is not reclaimed" |
| M11 | `writeAtomic` writes straight to access.json, no temp file or rename | "a failed rename leaves the old bytes, removes the temp file, and writes no receipt" |
| M12 | the BusinessError catch in `accessCommand` is removed | "a group-writable access file refuses with exit 3, names the file, and changes nothing" |
| M13 | `contextFor()` is called before the agent and tester refusals | "an agent or tester caller is refused before the context or the access file is read" |
| M7 | `checkAccess` is skipped before the write | "a grant naming an unknown instance is refused before any write" |
| M8 | the receipt append is removed | "grant writes ... a receipt line" |
| M9 | a project name is written as given (no lookup) | "a project name resolves to its id" |
| M10 | the lock is not taken | "a live holder's lock refuses" |
Round 2 closed the direct-write gap: `writeAtomic` takes the fs calls through `deps.fs` (default: the real
`node:fs` functions), and a rename that throws is tested (M11 is the direct-write mutant). Not yet run:
the mutant batch, the cli suite and `scripts/test-*.sh`. All wait on Sage's slot.
Round 2 also changed the file's refusal path: a `BusinessError` from `loadAccess` or `loadBusiness` now
exits 3 with the file path and key path on stderr, no stack trace (Duckworth B1, Sage sage-1437).
## Not done in this row
- `--peers` and the federation key: row 203.
- `access.reload`: row R4.
- Gizmoduck's `console.sso.subjects` name: erratum, the flattened shape is used.
- The suite (`packages/cli` and every `scripts/test-*.sh`) and the mutant batch: on Sage's gate.
@@ -0,0 +1,814 @@
# Row 191 stopRun inspection round
Design: ../DESIGN-r2.md, approved by Dewey in 29473; scope sage-2244;
per-run contract ../STOP-RUN-CONTRACT.md, honker-0079/0081. Not review-ready:
full package/shell/Docker gate and acceptance/guard mutants remain pending.
Current commit 7aa18b0f6e1a09188fb602853ebb02815b642ac9, new branch
rocko/row191-stoprun-dcd8-20261022, parent dcd8d4dda. Worktree
/mnt/storage/scratch/rocko-r191-r3. Earlier inspection f7bf7dfec is preserved.
All 79 manifest paths have identical blobs at f7bf7dfec and 7aa18b0f6;
rebase-product-diff.stat is empty, rebase-product-check.txt names the check.
The manifest SHA-256 stays
9971ac5fe56d0d36eb914ecaf2c282e14ab3cd29860fd27b268ec0baadaf0e52.
Rocko author and committer identity were selected explicitly. No attribution
lines, force-push or branch deletion. Sage publishes through jarvis.
## Changes Dewey must review: two focused-run fixes
1. packages/bus/src/broker.mjs, requestHost: the new processingWork guard was
initially inserted into the inner refusal logger's try block. That let the
original request reach validation, then prevented its refusal being logged
while halted, returning storage-unavailable. Move it to the main admission
try, before keys/credentials/authority/effects. The refusal logger retains
its existing unconditional audit behavior. The new host-admission test now
expects processing-halted and proves neither executor method was called.
Initial evidence: ../logs/stoprun-focused-packages_bus_tests_processing.test.mjs.log.
2. packages/bus/tests/processing.test.mjs, real socket await test: row 196's
asynchronous kernel-peer subprocess now precedes dispatch. The old 5 ms
server deadline could fire during that subprocess, before the processing
await branch extends it. This test already stubs authenticateHuman; give it
a deterministic peerReader too, with read and close, and assert exactly two
proof reads for halt/resume. Keep the 5 ms ordinary timeout and 30 ms halt
delay. Production peer discovery, proof/transport and timeout behavior are
unchanged. The test still fails if the async branch timeout is removed.
The first fixture retry omitted close(); its teardown failed and left its
own server running. Only that verified test group 2831504 was killed. Both
unsuccessful logs remain. Corrected evidence:
../logs/stoprun-focused-bus-processing-fixed-2.log (14/14).
These are diagnosed code/fixture mistakes, not declared flakes. No failed log
was removed and no existing security assertion was weakened.
## Focused evidence, sage-2250
Ran singly on 41b0cf755 because none of row 254's seven paths overlaps ours.
Sage-2265 carries this evidence to dcd8d4dda after the empty 79-path diff.
Scripted busy check exited 0 before the first file. Every test runner used its
own process group, 180 s timeout, scratch TMPDIR
/mnt/storage/scratch/rocko-r191-tmp and DOCKER_HOST=unix:///nonexistent.sock.
There was no Docker, browser, package suite, shell suite or mutant run.
| File | Final passed | Failed | Skipped |
|---|---:|---:|---:|
| processing-control/tests/stop-run.test.mjs | 12 | 0 | 0 |
| processing-control/tests/runtimes.test.mjs | 10 | 0 | 0 |
| processing-control/tests/coordinator.test.mjs | 3 | 0 | 0 |
| processing-control/tests/containers.test.mjs | 6 | 0 | 0 |
| processing-control/tests/effects.test.mjs | 5 | 0 | 0 |
| processing-control/tests/task.test.mjs | 7 | 0 | 0 |
| bus/tests/processing.test.mjs | 14 | 0 | 0 |
| bus/tests/processing-runtime.test.mjs | 2 | 0 | 0 |
| bus/tests/project-runtime.test.mjs | 3 | 0 | 0 |
| Script sum | 62 | 0 | 0 |
All final runners exited 0. The batch stopped at the initial bus failure,
then that file was diagnosed/rerun before the final two files. Counts are from
focused-run-table-final.json and include only each final successful file log.
Earlier bus failures are reported above, not silently included as green.
Approximate run span 23:59Z–00:00:41Z; start second was not recorded.
Cwd-scoped node/chromium sweep was empty at 00:00:41Z; slot returned to Sage.
## stopRun implementation
The export resolves host-owned run identity even without a bound bus claim.
It checks boot/start/group identity immediately before each signal, preserves
verified member anchors, waits 5 s before KILL and observes for 2 s. Signals
are acknowledgements only, never invented. Root/business/run cache joins the
same Promise. Atomic hard-link publication plus durable exclusive claim
coordinates other host processes. First owner/reason/deadline/result persists;
completed retries never re-escalate or overwrite that result. Halt links its
generation to the first fire receipt without relabelling the runtime's stop
cause. A later verified exit is separate immutable observation evidence.
The seam writes no bus events. Honker maps killed/alive to lifecycle events.
No global unit, live config, binding, token, slot file or live store touched.
No schema migration. First activation still requires stopping old processing
before trusted initialization and aligning the copy/image/read-only mount in
Mos's window. Existing result.json evidence remains write-once.
## Integration and outstanding work
The 12 latest-base conflict resolutions are listed in STATUS.md. New landed
host execution, publish and library sync paths are now wrapped in processing
scopes; host credential cancellation composes with its existing revocation
signal. This changes only files already owned by 191. Those wider integrations
still need the full owning-file/package gate and mutant evidence.
Fifteen proposed per-run mutants preflight exact once in stoprun-mutants.json;
none has been applied or run. The broader kill-switch acceptance/guard table
and executable mutant runner are still being completed. No kill count or
review-ready claim is made. Ask Sage for the later CLEAR when the packet is
complete, as sage-2259/2262 requires.
### Gate preparation after the focused slot
The published product implementation is unchanged. Three uncommitted test-file
strengthenings are saved in post-inspection-tests.patch: effects.test.mjs adds a
4-second bounded abort expectation for the watcher mutant; Discord's early
start fence is also checked before a malformed journal can be read; WebUI's
body limit now uses oversized valid JSON, and a direct-handler test isolates
its own origin/site guards from serve's outer fence. These edits are unrun and
are not included in the published 9971ac5f manifest. They must pass before a
new candidate is cut. Thus 62/62 is prior focused evidence, not a claim for
these changed test bytes.
The complete proposed table has 70 exact-once sites in all-mutants.json,
including expected killing test names. Static preflight is clean; no mutant
has been applied. run-mutants.py validates preflight, uses new process groups,
tracks only its own verified descendant identities, kills those groups on
timeout, checks named TAP failures, and restores all source bytes in finally.
A timeout or wrong-test failure is not counted as a valid kill. The table and
runner are prepared evidence, not a kill result. The requested nine-package,
ten-shell, seventy-mutant gate is in gate-plan.json; Sage CLEAR is pending.
## Published test-strengthening commit and full gate (sage-2269/2271)
81889b58d57889b12a584aa225e1ac26e90e5700 is one fast-forward commit on
7aa18b0f6, same branch rocko/row191-stoprun-dcd8-20261022. It commits the
three test changes saved in post-inspection-tests.patch; that patch remains
historical evidence, not an uncommitted candidate. Product implementation is
unchanged. Earlier manifest 9971ac5f belongs to 7aa18b0f6; candidate.txt now
names the manifest for 81889b58d. Sage published this tip before granting CLEAR.
The full gate checks HEAD equality and clean product bytes before its first
file. Changed test files run singly, then nine packages, all ten shell suites,
then seventy mutants. Each step gates on its exit status and retains its full
log. run-gate.sh uses pipefail and an EXIT trap for the label-verified Compose
network rocko-r191-gate_default, project rocko-r191-gate. TMPDIR is
/mnt/storage/scratch/tmp/rocko-r191-gate. Docker is available only for test-task
and test-release; other steps use unix:///nonexistent.sock. Live provider calls
are disabled. No daemon, socket, volume or live configuration changes.
Gate start: 2026-10-22T00:18:00Z. Results remain pending until gate-results.json
and mutant-results.json provide actual exit statuses and counts.
### First full-gate stop and fixture correction (sage-2274)
The 81889b58d run stopped at its first failure, 00:18:00Z–00:19:21Z.
27 files completed: 268 passed, 1 failed, 0 skipped. Packages, shells and
mutants did not start. Full logs/results are in gate-attempt-81889/.
The tester refusal test's fixture requested tester:true over TCP, which row
196 now refuses before route handling. This is a stale fixture, not a flake.
The correction uses the existing startTesterSocket helper, a private fixture
root, socketPath requests, TESTER_ORIGIN and no human identity header. Every
assertion remains; the request now reaches the intended processing refusal
rather than failing listener setup or the outer identity fence. A grep of all
changed test files found no other tester:true TCP setup. The cwd node/chromium
sweep returned []; no Docker test workload ran. Commit c78f48946 is a single
fast-forward test-only fix on 81889b58d, authorized by sage-2274. The complete
gate restarts from its first single file only after Sage publishes this tip.
Sage-2275 published c78f48946 and granted full restart. Second attempt begins
2026-10-22T00:21:03Z; any further failure stops before additional edits.
Candidate manifest b49fd493dc75ef2484ffc255213dc8eeaaaa348dee416c8d26d1c2bab8e20067.
### Second attempt stopped; no further changes (sage-2275)
Run 00:21:03Z–00:23:50Z on published c78f48946: all 27 changed singles
269/269, processing-control 61/61, bus 646/646. The CLI package emitted 26
failed subtests before its verified own runner group 3119310 was stopped.
Its result is interrupted, not a complete package count. Six gate-lane image
checks refuse because scripts/testing/lane-image.mjs pins the old four COPY
lines and 191 adds processing-control to Containerfile. Twenty launch-account,
launch-limit-checks, launch-limits and launch-routes cases hit
processing-uninitialized on fixture roots with no explicit control init.
No edits, retries, later packages, shells, Docker test workloads or mutants
followed this second failure. Parent runner exited; cwd node/chromium sweep
returned [] at 00:23:50Z. Slot returned to Sage at 00:24:05Z with the exact
failures and a request for scope on image-manifest and fixture initialization.
This is an integration failure, not a declared flake or a passing gate.
## Image-input manifest and launcher fixture scope (sage-2278, sage-2282)
Commit eb3abba12 is one fast-forward commit on c78f48946. This is an image-input
manifest change: scripts/testing/lane-image.mjs now pins the exact Containerfile
COPY of packages/processing-control/src/state.mjs and entrypoint.mjs and hashes
both named source paths, without a wildcard. Its only two changed lines add
those inputs. Without them, a lane could reuse a stale image after a reader or
entrypoint change. The image test fixtures copy both files and each has its
own receipt-invalidating test. Mutant KS56 drops state.mjs from the hash list;
its named killer is the state.mjs receipt-invalidating test. Static preflight
is 71 sites / 0 errors. No new run result is claimed before CLEAR.
The launch-account, launch-limit-checks, launch-limits and launch-routes fixtures
now explicitly call initializeControl(f.dataRoot) before constructing their
launchers. Existing assertion lines are byte-identical. A new launch-limit-checks
test leaves initialization off and asserts processing-uninitialized, no
mayLaunch/authorizeLaunch, and no registered session. A live root gets control
initialized by the bus broker. The re-pin order is bus start before any seat
launch; a launcher started before that refuses. This supplements the existing
window precondition to stop old processing before the first trusted init.
Sage-2282 supersedes the test-only image ruling: lane-image.mjs is a product
blob, so mos-1200 carries no earlier results. The complete plan restarts from
the first single file, all nine packages, all ten shells and all 71 mutants
only after publication and Sage CLEAR. Earlier attempts remain in their
separate gate-attempt directories; neither is a passing full gate.
Sage-2283 published eb3abba12. Current 85-path candidate manifest is
01a2b33651414c9b0cb094d2108f9e39c48a41b79974dcc16bdec374f8371091.
The gate prints HEAD and this digest, verifies every manifest blob before its
first test, and carries no old results. Awaiting CLEAR after Gander 253.
## Full restart on eb3abba12 stopped at Discord (sage-2286)
Verified HEAD and all 85 manifest blobs before tests. Run span:
2026-10-22T00:34:50Z–00:47:33Z. No old result carried.
| Step | Passed | Failed | Skipped | Exit |
|---|---:|---:|---:|---:|
| 32 changed test files singly | 304 | 0 | 0 | 0 |
| processing-control | 61 | 0 | 0 | 0 |
| bus | 646 | 0 | 0 | 0 |
| cli | 761 | 0 | 0 | 0 |
| discord | 190 | 2 | 0 | 1 |
Singles sum independently confirmed by awk over all 32 TAP logs. Discord's
binding.test.mjs:201 STOP check and recover.test.mjs:145 ready recovery check
both get processing-uninitialized before the expected behavior. Those older
fixture roots do not explicitly initialize control. The gate stopped at this
step; no subsequent package, shell suite, Docker test workload or mutant ran.
Docker availability was checked read-only (29.7.2); no daemon/volume changes.
The EXIT trap completed. Cwd node/chromium sweep returned [] at 00:47:49Z,
source tree remains clean, and the slot was handed back at 00:47:58Z.
No test or product edits or retries followed this failure; Sage has the table
and the fixture-initialization recommendation. This is not a green full gate.
## Discord fixture init and all-package static sweep (sage-2294)
Commit e6800380dc0765ce3f1477044e2ce4f773ebb43c is test-only and fast-forwards eb3abba12.
Only binding.test.mjs and recover.test.mjs change: one initializeControl import
and one initializeControl(dep.dataRoot) call in each affected CLI fixture.
Every original assertion line is retained. Product blobs are unchanged.
FIXTURE-SWEEP.md and fixture-sweep.json enumerate 338 source call-site hits,
with the two fixes and the already-initialized chains, plus the screens for
rootless optional contexts and read-only/usage CLI calls. The generator reads
repository source only and invokes no fixture. Deliberate missing/deleted/
corrupt-state refusal tests remain negative; no shared helper masks them.
Current manifest be36af8f0855f57336e6a33283aa0a8e94297f338b1f8180936cbccf1119c60a, 87 paths. Sage-2294 carries only unchanged
processing-control 61/61, bus 646/646, CLI 761/761 and unchanged singles from
gate-attempt-eb3/. The resume must run both touched files wholly, whole Discord,
then the never-run WebUI/tasks/seat/project/library packages, every shell suite
and all 71 mutants. No result is claimed for the new test bytes before CLEAR.
The live initialization order remains bus start before any seat or Discord
start; no fixture change initializes or repairs a live root.
## Resume on e6800380d stopped at WebUI (sage-2311)
Start 2026-10-22T01:27:40Z; failed-step exit 01:28:45Z. Startup verified HEAD,
manifest be36af8f and all 87 blobs, and read-only Docker availability (29.7.2).
| New step | Passed | Failed | Skipped | Exit |
|---|---:|---:|---:|---:|
| binding.test.mjs whole | 15 | 0 | 0 | 0 |
| recover.test.mjs whole | 5 | 0 | 0 | 0 |
| Discord whole | 192 | 0 | 0 | 0 |
| WebUI whole | 314 | 4 | 1 | 1 |
Carried unchanged under sage-2294: 32 singles 304/304, processing-control 61/61,
bus 646/646 and CLI 761/761, from gate-attempt-eb3/. These are explicitly carried
results, not fresh runs on e6800380d.
Four WebUI failures are retained in gate-logs/package-webui.log:
- board-held.test.mjs:91: processing's config/state GETs omit redirect:manual.
- board-held.test.mjs:194 and send-browser.test.mjs:58: processing's independent
hash/focus/interval refresh continues to read after the shell signs out.
- shell.test.mjs:104: the added control bar makes the short desktop page 1095px
high at a 1000px viewport. Its new height is not accounted for by the existing
frame/sidebar/footer layout.
Static diagnosis: processing.js ignores the existing body.signed-out state and
mosaic-signed-out event that app.js and bus.js already share. Its GETs also lack
manual redirect handling. processing.css adds an external bar while shell.css's
frame minimum and sidebar heights still subtract only bar-h and foot-h. These
are product integration defects; no assertions were weakened and no fix was
made after the failed step. Sage received the table and recommendations.
One TAP skip: the editor rebuild requires exactly esbuild 0.28.2; installed
shared node_modules has 0.28.1. Full skip line is in tap-skips.json. No shell
suite ran, so there are no ^skip shell lines (skip-lines.txt remains empty).
No tasks/seat/project/library suite, shell Docker workload or mutant ran after
WebUI failed. Parent runner exited, EXIT cleanup completed, cwd node/chromium
sweep returned [] at 01:29:00Z, source tree is clean, and slot returned to Sage.
No retries or source edits were made. This is not a passing gate.
## Console shared sign-out and bounded desktop layout (sage-2317)
Commit 8e1f55c1db6a096e227b5114cd13a7142493b112, fast-forward on e6800380d, changes only the
processing assets and their owning two tests. Current 87-path manifest:
d3b014e6a0e9e547a29ae780eb27b1c0e57cc7b0e73dc49954abbff623929f41.
All three processing fetch sites use redirect:manual. responseBody dispatches
the existing mosaic-signed-out event for an opaque redirect, or its signIn
variant for exactly 401/sign-in required. No independent sign-out state is
added: refresh/action guards read body.signed-out. The existing event cancels
the interval, invalidates pending reads, clears stale state and disables
writes. In-flight answers cannot restore state after sign-out. Initial work
waits for DOMContentLoaded because this deferred asset precedes app.js in the
HTML and needs its shared sign-out listener registered first. app.js/bus.js
and their proof baselines remain untouched.
The CSS keeps the control bar in normal document flow. On desktop, the frame
flexes into the remaining viewport rather than imposing another full-height
minimum below the bar. Fixed navigation retains the shell's command/footer
bounds and existing responsive column widths. Mobile retains natural flow.
No short-page, sidebar or footer assertion is changed; rendering remains to
be verified on the next authorized gate, not claimed from static checks.
The VM fixture models the existing body class, document events and DOM ready;
its original assertion lines are unchanged. Six new browser cases cover config,
state and POST opaque redirects and sign-in-required replies, manual redirect
requests, interval cancellation and no subsequent fetch from forced refreshes
or clicks. KS57 removes manual redirect from the config GET; KS58 removes the
refresh signed-out guard. Both name the config-redirect browser case as their
killer. Static preflight: 73 exact-once sites / 0 errors. Syntax checks pass;
no owning test or mutant has run on this commit.
A product blob changes, so sage-2317/mos-1200 carries nothing. The next CLEAR
runs all changed files singly, all nine package suites, all ten shell suites
with Docker available and all 73 mutants. The editor rebuild's esbuild0.28.1
vs pinned0.28.2 skip remains host drift and is reported separately. Earlier
failed attempts remain immutable in gate-attempt-* directories.
## Rebase onto row 202 (sage-2329)
New branch rocko/row191-stoprun-ddfb-20261022, tip eafcbd3e7d6ef6e259fb59d87a7b0857b42337aa,
base ddfb29af7. The prior published branch is preserved; no force-push, delete
or old-name reuse. Six Rocko commits rebased, with one manual conflict in
scripts/mosaic's human CLI case: prepend halt|resume|processing and retain
row 202's appended access. packages/cli/src/cli.mjs auto-merges retain the
access import/branch and all three processing commands. CLI README retains
both the Access and Instance processing switch sections.
The full old/new tip diff has 15 paths (base plans/queue/logs, row 202's new
access files and its three shared CLI paths), 2327 insertions/32 deletions.
Within row 191's owned paths only CLI README, cli.mjs and scripts/mosaic differ,
with the row 202 additions. rebase-ddfb-log.txt, rebase-ddfb-tip-diff.stat,
rebase-ddfb-owned-diff.stat and rebase-ddfb-range-diff.txt retain exact evidence.
Manifest fd0f2740b1cccadaaad8c613640fdddb0497cdab33ec2c0fd11debf3cae6c4ee, 87 candidate paths. The package command lists were
regenerated from this base; CLI includes access.test.mjs. All 73 mutant sites
must preflight again. No tests ran, no result carries, and the complete gate
waits for publication and Sage CLEAR after Megavolt's debug run.
Sage-2333 published eafcbd3e7. Their per-file patch-id comparison matches
the prior six-commit range except the exactly ruled scripts/mosaic dispatch
line; binary/secrets/attribution checks pass. Gate remains unrun pending CLEAR.
## eafcbd3e7 full gate stopped on timer fixture attribution (sage-2339)
Run 2026-10-22T02:18:04Z–02:19:25Z. HEAD, 87 blobs and fd0f2740 manifest
verified. Short mktemp TMPDIR /mnt/storage/scratch/tmp/gxbC; named Compose
project rocko-r191-gate. Nothing carried. 32 changed files completed:
313 passed, 6 failed, 0 skipped. All six new processing-browser sign-out
cases stop at their common precondition, processingTimers length2 vs1.
The original two browser cases pass. Their recorder attributes every 5000ms
interval to processing, but app.js:307 already arms its own freshness timer.
The intended processing interval is at processing.js:87. These new cases have
not yet exercised sign-out or killed KS57/KS58, so no behavior proof is claimed.
The gate stopped at this first red step. No package, shell, Docker workload
or mutant ran; the esbuild rebuild was not reached. Runner exited, EXIT cleanup
completed and cwd node/chromium sweep returned [] at 02:19:34Z. No source edit
or retry followed the failure. Sage received the exact failure and a proposed
test-only caller-attribution filter with the assertions unchanged. This is a
fixture error, not a passing gate or a declared flake.
## Timer recorder attribution correction (sage-2345)
Commit 4db8b57785895bc62f5f015468a69515da7fc77f, fast-forward on eafcbd3e7, is a one-line
test-only correction in processing-browser.test.mjs. Cause: app.js:307's
freshness interval is also 5000ms, and the recorder incorrectly counted it as
a processing interval. The recorder now requires the processing.js caller
stack. Every assertion remains byte-identical, including exactly one timer
before sign-out and zero after it. A second 5000ms interval from processing.js
would still be counted and fail the exactly-one assertion. No product byte
changes, and no test or mutant ran for this correction.
Manifest ffb46fbdb875f97ff2eb7a7bed8bf6d5c4df53b8a63c738ffcde5fc5f6b7f6ad, 87 paths. Full restart after publication and CLEAR,
with nothing carried. Sage-2345 counts the timer-fixture red as the first in
this 191 window; any second red stops and is escalated to Mos. Previous logs
are retained in gate-attempt-eaf/.
## Full gate on 0f8235277: second red (sage-2351)
Candidate: 0f8235277e1fc21588edb6f2931d66c8e154a400 on ddfb29af7.
Manifest: ffb46fbdb875f97ff2eb7a7bed8bf6d5c4df53b8a63c738ffcde5fc5f6b7f6ad.
No prior result carried. Start 2026-10-22T02:33:41Z; failed-step exit
2026-10-22T02:46:17Z. Fresh TMPDIR /mnt/storage/scratch/tmp/giNF.
Compose project rocko-r191-gate, trapped ownership-checked network cleanup.
| Step | Passed | Failed | Skipped | Exit |
|---|---:|---:|---:|---:|
| 34 changed test files singly | 330 | 0 | 0 | 0 |
| processing-control | 61 | 0 | 0 | 0 |
| bus | 646 | 0 | 0 | 0 |
| cli (including access) | 783 | 0 | 0 | 0 |
| discord | 192 | 0 | 0 | 0 |
| webui | 323 | 1 | 1 | 1 |
| tasks, seat, project, library | not run | — | — | — |
| all ten scripts/test-*.sh | not run | — | — | — |
| all 73 mutants | not run | — | — | — |
Singles sum independently confirmed with awk over all 34 TAP logs.
The sole failure is shell.test.mjs:380 in the full shell-browser test:
scrollHeight 1062 vs innerHeight 1000. Full evidence is
gate-logs/package-webui.log (not ok 271, line 1626).
All prior manual-redirect/sign-out failures and eight processing-browser cases
pass, but the desktop short-page geometry is still wrong. No passing layout
or full-gate claim is made.
Known host-drift TAP skip: editor rebuild requires exactly esbuild 0.28.2,
while shared node_modules has 0.28.1. Full line is in tap-skips.json. This skip
is not counted as a red. No shell suite ran, so skip-lines.txt has no ^skip
shell line. No Docker test workload or mutant started.
Stopped at the first failed step without retry or product edits, per sage-2351.
This is the second red in the 191 window and is with Sage for Mos escalation
under mos-0823. Parent runner exited; EXIT cleanup completed. Cwd-scoped node
and chromium sweep returned [] at 02:47:14Z, product worktree is clean, and the
slot was returned. No in-review move was made.
### mos-1248 / sage-2363: desktop viewport allocation (unrun static candidate)
The second red on 0f8235277 was shell.test.mjs:380 (1062 versus 1000).
The shell gives .frame a viewport-based minimum at packages/webui/src/public/shell.css:135;
processing.css:12-14 replaced that minimum but used a column flex body with only
min-height:100vh, so the new bar was not allocated a bounded share of the viewport.
The desktop body now uses explicit grid rows for the command bar, controls, optional
banner, frame and footer. The frame row is minmax(min-content,1fr): it takes the
remaining short-page space while long content may grow the document. No clipping,
overflow suppression, fixed body height, test viewport change or assertion removal.
The existing shell.test.mjs:380 equality stays byte-identical. Added assertions
check actual processing-bar visibility, positive height, viewport placement and
no document scroll at 1440x1000; the existing 1440x420 long-page checks remain.
KS59 reverts processing.css to 0f8235277's bytes and must fail that same shell
browser test. Total: 74 planned mutants. No test or mutant has run on this fix.
Webby must statically review this delta before Sage opens the full no-carry gate.
### Correction after Webby B1 (webby-0104, 2026-10-22 03:00Z)
The cause stated in the mos-1248/sage-2363 note above is withdrawn.
processing.css loads after shell.css and already overrides the frame minimum.
With the tree fixed and inspector hidden, the remaining floor is the Board's
main content. The grid min-content row preserves that floor, so 5cd32f4c1
is not an accepted layout fix. Its static review is CHANGES at Webby's
REVIEW-layout.md, commit e2e7c60e5. No run established a new layout measurement.
Next step requires a granted fixture probe for main/control heights and an
owner layout ruling; no further product change or gate is authorized yet.
Existing shell assertions remain unchanged.
### Static nav placement: mos-1249/mos-1250, sage-2383/2385/2386, webby-0106/0108
Fast-forward on 5cd32f4c1; the rejected grid is removed. The unchanged labeled
region is now the first child of nav.tree, before its Console h2. The banner
stays at its original body location with every id, role, label and shipped
string unchanged. No processing.js change and no runtime reparenting.
At >=760 the tree is the same fixed box, with the same width breakpoints and
ordinary overflow:auto. The controls are non-shrinking and non-sticky; they
scroll with overflowing nav links (sage-2386 withdraws persistent visibility).
Below760 the region takes a full flex line before Console/Projects. Its mobile
location changes from before .frame to first in the static nav; the banner
therefore precedes it in a halted mobile view. Banner behavior itself is unchanged.
Specificity (0,3,0) resets .btn margin/order and .small margin/basis, leaving
the shell rules unchanged. No content is shortened and no overflow is hidden.
Correct height arithmetic (Webby B1): page=max(innerHeight,86+C), where C is
main content height. Controls consume space within the existing nav box, not
another body row. The old failing layout was max(innerHeight,86+barHeight+C).
The halted banner remains a body row; Sage tracks that separately.
Candidate-test design change at processing-ui.test.mjs:67 under mos-1250.
Old assertion:
```js
assert.ok(html.indexOf('id="processing-controls"') < html.indexOf('<div class="frame">'));
```
New assertion text:
```js
const controlsAt = html.indexOf('id="processing-controls"');
const mainAt = html.indexOf('<main id="main"'), inspectorAt = html.indexOf('<aside class="inspector"');
assert.ok(controlsAt >= 0 && controlsAt < mainAt, 'controls are outside and before #main');
assert.ok(!(inspectorAt < controlsAt && controlsAt < html.indexOf('</aside>', inspectorAt)),
'controls are outside aside.inspector');
```
Reason: the bar moves into the frame's first column under mos-1249 while
remaining outside route-specific #main and aside.inspector. This is a change
to our unlanded candidate assertion, not a landed assertion.
All existing shell assertions stay unchanged. Added bar-inside-tree assertion
and diagnostic height output. New processing browser coverage checks 400px
Stop order=0, visibility, focus and position above Projects; also on-load
visibility/focus at1440x1000,1440x420,800x420. Debug logs report controls/tree
heights at those desktop sizes and main height; Sage judges controls over half
the 334px nav at1440x420 before the window. No run yet.
Mutant delta: KS59 now reverts the complete layout (HTML and CSS together);
KS60 hides the controls; KS61 moves them into #main; KS62 drops the (0,3,0)
button reset. Total77. Every new site has its named killing test.
### sage-2394 debug on published da4334806 (2026-10-22)
03:14:37Z–03:14:53Z: shell.test.mjs whole 5/5, processing-browser.test.mjs
whole 9/9, 0 failures/skips. awk sum14/0/0. Original shell short-page and
long-page assertions pass unchanged. Logs/results and empty cwd sweep are in
debug-da4334806/. Slot clear sent Sage/Webby03:15:11Z after sweep.
Controls/tree/main heights: read-only shell1440x1000 212.5/914/914; writable
processing fixture1440x1000 127/914/914;1440x420 127/334/907.734375;800x420
127/334/1131.65625. Stop visible/order0/focusable/aboveProjects at400x1000.
Writable127 is below half the334 nav. The read-only212.5 height at1000 was
flagged for Sage's usability call; that state was not measured at420, so no
claim is made that all states use less than half the short nav.
No mutants or full gate run under this debug grant. Await separate CLEAR.
### R6 accepted: sage-2399 / webby-0111 (2026-10-22 03:16Z)
Sage accepts the short-nav usability tradeoff. Writable controls occupy38%
of the334px nav at1440x420. The read-only recovery note makes that about64%,
inferred from the measured212.5px height at the same width. The region is
non-sticky and scrolls away; Projects was already below the fold at that size
before191. Webby confirmed the debug TAP and has no window blocker. Sage
tracks the read-only note length with the halt-banner follow-up; no change
to this row.
Do not rebase or run now. Row273 lands first. On Sage's landing message,
rebase the candidate including the two latest layout commits onto the new
refactor under a new branch name (mos-1086), then provide range-diff,
patch-ids and the refreshed88-product-path manifest. The full no-carry
window requires a separate Sage CLEAR on that published tip.
### Rebase after row273: sage-2402 (2026-10-22 03:42Z)
New branch rocko/row191-stoprun-86f9-20261022, tip
0fa7bf421fc153a226b5f06ed484e089e48e23ba, on86f95bb27. All10 commits
rebase cleanly and range-diff pairs each with '='; no conflict resolution.
All88 per-file patch-ids match the old candidate. Whole product diff stable
patch-id before/after:0846823aefe165363601a6c68134ad46cc701c73.
87 product blobs are identical; only docs/TOOLS.md changes because the new
base carries row273's --send/--no-sso usage and three explanatory F39 lines.
191's TOOLS hunks stay unchanged. New88-path manifestSHA256:
0bcbf337433f6d46d809ee1dcaf8a5a8503160403d273cf73d6dac64adef3c02.
Range-diff, per-path patch-ids and TOOLS comparison are rebase-86f9-* files.
Static77-site exact-once preflight has0errors. No tests run, nothing carried
into the required full window; await Sage publication and separate CLEAR.
### Full no-carry window on0fa7bf421: stopped at SR08 (sage-2407/2418)
03:44:56Z–04:01:29Z. All35touched files passed336/0/0; nine packages
passed2403/0 with1known editor/esbuild skip. Every shell exited0:425selftests
plus156queue node tests passed; the canonical-root queue check and two
explicit live-provider exclusions are saved in skip-lines.txt. See
GATE-RESULTS.md for per-suite counts and mutant table.
SR01–SR07 killed. SR08 timed out at90seconds without named assertion failure
(TAP header only); runner restored all88candidate blobs and stopped. The69
later mutants did not run. SR08 is not counted as killed. Cwd sweep[] and
owned network cleanup completed; slot clear sent Sage04:02:12Z.
Saved mutation, timeout policy lines64–89 and shell/skip table sent Sage in
response to sage-2418. No source edit or retry; no in-review move. Await
Mos ruling and explicit Sage CLEAR before any control/replay/resume.
### SR08 timeout policy: mos-1259 / sage-2423 (2026-10-22 04:07Z)
Cause confirmed by Mos: runtimes.mjs:333 loops on now() <= deadline, while
stop-run.test.mjs:106-108 gives the second host a fixed fake clock with real
sleep. Removing the join branch enters the stop loop without advancing that
clock. Original SR08 log contains TAP header only; original timeout record
stays immutable. Mos permits separate classification as killed-by-timeout
only after the unmutated same whole test file passes the same90second
harness in this resumed run. **Control result: not run; awaiting CLEAR.**
No assertion kill is claimed for SR08.
Runner review requested from Dewey, Sage copied. SHA256 of run-mutants.py:
fe0d360f86d86ab2df967da5338479c593c27c88d06eb38a1389d99a08489369. Diff:runner-mos1259.diff; baseline:
run-mutants.before-mos1259.py. Shared execution harness uses90seconds, own
process groups, verified descendant cleanup and restored source bytes. A
control must pass every test with exit0, no not-ok/skip/cancel/fail. Its record
binds invocation id, candidate HEAD/manifest and test hash; all88candidate
blobs verified before and after control, and before each mutation. The
approved historical SR08 classification and69-site SR09+resume use separate
files so the original seven kills and timeout evidence remain intact.
Assertion kills and timeout kills have separate counters. No control for the
same file in this invocation means a timeout stops; survivors and invalid
failures stop too. Every timeout gets a cause/control record before review.
No product/test change, no control/mutant execution. Wait for independent
runner approval and Sage CLEAR. Green suites carry only on unchanged0fa7bf421
and manifest0bcbf337. Test clock fix is deferred to Vikunja502 as Mos ruled.
### Independent runner approval: dewey-0789 (2026-10-22 04:11:20Z)
Dewey APPROVES the frozen fe0d360f86d86ab2df967da5338479c593c27c88d06eb38a1389d99a08489369
runner, static only. Use only the proposed --control-file stop-run.test.mjs
--adjudicate-timeout SR08 --resume-from SR09 invocation. A positional-id or
full run would overwrite the original evidence and is not this resume.
Report the complete tally as7 prior assertion kills (old status killed),
plus SR08 separately adjudicated by timeout after control passes, plus69
resumed sites. The summary file counts only this invocation. No control or
resume result exists yet; Sage CLEAR remains required.
Classification change: the reviewed runner recognizes indented not-ok lines
with ^\s* rather than only top-level lines. Controls therefore reject nested
failures too, and mutant assertion kills still require the named pattern in
a failing test name. SR01–SR07 evidence was classified by the original runner.
Historical SR08 has no HEAD field. Its binding to0fa7bf421 relies on the saved
commit.txt/log timestamps and the new candidate HEAD+88blob check, not a
retroactively invented HEAD field in the old result. Preserve this provenance
in the adjudication report. Any newly detected timeout needs a concrete cause
before review; the generic placeholder is not accepted review evidence.
### Control and resume: sage-2427, stopped on KS43
Control/resume began04:13:44Z; last saved mutant result2026-10-22T04:14:08Z; process
absence confirmed04:14:56Z and slot returned to Sage. Whole unmutated
stop-run.test.mjs control passed12/12, exit0, no not-ok, no skips/cancels,
in0.459seconds. The reviewed runner adjudicated original SR08 separately
as killed-by-timeout under mos-1259. The immutable original SR08 row/log
stay unchanged; adjudication binds the control, exact cause, current HEAD
and manifest. Historical HEAD provenance remains as Dewey N4 described.
Resume stopped at KS43, not retried. It removes the inner processingHTTP
origin check and its selected outer-route test passed1/1(exit0). Saved
evidence:resume-mutant-logs/KS43.tap and resume-mutant-results.json.
Tally:7prior assertion kills +49resumed assertion kills =56assertion kills;
1historical timeout kill;1survivor(KS43);19unrun. No new timeouts or placeholder
causes. All88candidate blobs restored and resume cwd sweep[]; no product or
test change. No in-review move. Sage and Mos notified directly.
Static diagnosis only: KS43 selects processing.test.mjs:52's outer-server
fence test. The unchanged direct-handler test at:95 already isolates this
origin guard from serve's outer refusal. Its pattern was not selected. Sent
recommendation to Sage/Mos to correct only the mutant's named killer after
their ruling, then run KS43+19remaining. No metadata edit or rerun authorized
yet; no claim the direct-handler test kills it until the run proves it.
### KS43 metadata retarget: mos-1261 (2026-10-22 04:16:41Z)
Original pattern `processing write fences refuse` selected processing.test:52.
KS43 survived with1passing test and no writer call, preserved in
resume-mutant-results.json and resume-mutant-logs/KS43.tap. The serve-level
test cannot isolate the handler guard: outer fences and later refusal can
mask its removal. No product defect or new kill is claimed from static read.
New pattern: `processing handler independently refuses wrong origin and cross-site before its writer`,
the existing direct processingHTTP test at:95-107 with valid user/site/content
headers, asserted403 and no writer call. Only KS43's pattern changed in the
77-site table; product/test/mutation bytes unchanged. Generator mirrors it.
TableSHA256:5dbc8308c995e1974d6da63b3daa967896d822154eab38a8bb96154be667edf5. Diff:mutant-table-mos1261.diff,
baseline:all-mutants.before-mos1261.json. Dewey must independently review
this metadata diff before the next run. Static audit of all19unrun sites:
mutant-pattern-audit-mos1261.md; no other serve-level/direct-handler mismatch
with an existing direct test warrants retargeting. KS44 body-bound case has
no direct-handler counterpart; its oversized whitespace+{} case is valid
JSON without the bound, so it retains the existing pattern.
Carry56assertion kills+1timeout kill and green suites only on unchanged
0fa7bf421. Next approved scope is KS43+19sites after review and Sage CLEAR.
No run authorized by this metadata change.
### Retarget review APPROVE and C1 archive: dewey-0791 (2026-10-22 04:19:55Z)
Dewey approves table5dbc8308 and the19-site audit, conditional on preserving
the first resume evidence before any next run. Archive:mos-1261-archive/,
full per-file inventory:mos-1261-archive/sha256-manifest.txt.
- `resume-mutant-results.json` SHA256 `f5725ef26cdb488441285081fadaa9ce4a3e7d390ab2a1c6d04acfd27f105fc6`
- `resume-mutant-logs/KS43.tap` SHA256 `c778ee33edb667f795ab560a328fd5c1c04ef6acf99d7b79c364f6302cf29864`
- `unmutated-control.json` SHA256 `ee9ddcd69d24276165abe1185f159a2737157fcae8fd37f731768e9324b765e5`
- `SR08-timeout-adjudication.json` SHA256 `214a04696d3329ef67bf10578daf4d251599816fd246bc31abecae1a6230e1e0`
The50-row result, all first-resume logs and control/adjudication records
were copied byte-for-byte. Original paths remain untouched too. A separate
retarget-mos1261/ directory holds byte-identical approved runnerfe0d360f,
reviewed table5dbc8308 and identical candidate/commit files. The proposed
--resume-from KS43 invocation there selects20sites and writes its own logs
and results without overwriting either original or archive. No control flag
is needed for these tests; any timeout without a same-file control stops.
Final tally will combine7old assertionkills +49first-resume assertionkills +
SR08separate timeout adjudication +20retarget results. Still no run; awaiting
Sage CLEAR. Product/test bytes remain unchanged.
### KS43 retarget run: sage-2436, stopped on KS44 invalid mutation
Run began04:21:06Z; KS43 is killed by the named direct-handler assertion.
KS44 then fails import with SyntaxError Unexpected token catch at
processing-http.mjs:40. The failing TAP name is the test file, not the
selected test, so runner records invalid-failure and stops without a kill.
Evidence remains in retarget-mos1261/console.log,resume-mutant-results.json
and resume-mutant-logs/KS43.tap,KS44.tap. No original evidence overwritten.
Cause: our KS44 mutation uses `// no body bound` to replace an inline
`if (size > 1024) throw Error();` in a one-line for-await body. The comment
swallows the remaining chunks.push and closing brace. This is malformed
mutation metadata, not an observed product failure. No metadata correction
or retry yet. Proposed to Sage/Mos: replace only the new text with `void 0;`,
then independent review and a new CLEAR for KS44+18 on unchanged candidate.
Current tally57assertionkills (7+49+1),1SR08timeoutkill,1invalid(KS44),18unrun.
Cwd sweep[] and all88candidate hashes restored04:21:30Z; slot returned and
Sage/Mos notified. No in-review move. Wait for ruling before any edit or run.
### KS44 syntax metadata correction: mos-1264 (2026-10-22 04:22:16Z)
Only KS44.new changes from `// no body bound` to `void 0;`. Old site,
test and pattern stay unchanged. The invalid import result remains in
retarget-mos1261/resume-mutant-results.json and resume-mutant-logs/KS44.tap.
TableSHA256:85d9a7b13ad510e840f258d892afbfce393cefbc4006e1f808605b83adc84570. Diff:mutant-table-mos1264.diff;
baseline:all-mutants.before-mos1264.json. Generator mirrors the one field.
Authorized syntax-class preflight applied KS44–KS62 individually in scratch,
ran node --check on each JavaScript file, and restored before advancing.
All15JS checks passed; no other replacement fix was needed. KS59–KS62
change only HTML/CSS and are explicitly asset-only/not-applicable for the
Node parser; Mos/Sage informed, no false JS syntax pass claimed. All19
mutations restored and88candidate hashes match. Per-site logs/results and
restoration record:syntax-mos1264/. No behavioral test or assertion-mutant
run occurred. Dewey must review table diff and syntax results before next
Sage CLEAR for KS44+18. Carry57assertionkills+1timeoutkill only on unchanged
0fa7bf421. No product or test change.
### dewey-0795 approval: syntaxfixed-mos1264 staged (unrun)
Dewey APPROVES table85d9a7b1 and syntax preflight, static only. Conditions
met:syntaxfixed-mos1264/ holds byte copies of runnerfe0d360f, table85d9a7b1,
commit.txt,candidate.txt,candidate-manifest.sha256. Full SHA256 and byte-cmp
list is syntaxfixed-mos1264/byte-copy-check.txt. Invocation after Sage CLEAR:
python3 syntaxfixed-mos1264/run-mutants.py --resume-from KS44 (19sites).
All previous invalid/survivor evidence stays untouched in its original dirs.
No behavioral run occurred during preparation.
### Final19 resume stopped at KS54: sage-2444 (2026-10-22)
Run began04:27:45Z; KS44–KS53 are10named assertion kills. KS54 then timed out
at90.073seconds. Its log contains the expected dependency-list assertion:
"the runtime refuses an invalid adapter and closes a valid one" at
bus/tests/tasks.test.mjs:263. The mutation drops processing from runtime.mjs
tasks factory deps. The test opens runtime then asserts before close at:265;
a failing assertion skips the close and leaves Node alive. No same-file
control ran in this invocation, so this is timeout-without-control, not a
kill. No assertion or timeout kill is claimed for KS54.
Raw resume result's generic cause sentence says a control passed, which is
incorrect: its control:null and timeout-without-control status establish the
opposite. The raw evidence remains unchanged; the concrete cause and
correction are in syntaxfixed-mos1264/KS54-timeout-report.json. This explicit
report replaces the placeholder as explanation without altering the log.
Current tally67assertionkills(7+49+1+10),1adjudicatedSR08timeoutkill,
1unqualifiedKS54timeout,8unrun. All88candidate blobs restored; cwd sweep[]
and slot returned04:30:06Z to Sage, Mos directly copied as instructed. No
retry, product/test edit or in-review move. Sage's disclosed syntaxcheck
caused no busy refusal or foreign-node report. Await owner ruling/CLEAR.
### Final controls/mutants GREEN: mos-1267, sage-2448, dewey-0798
On unchanged0fa7bf421, whole-file tasks control9/9 passed in0.815s; KS54
rerun killed-by-timeout with the exact dependency not-ok at tasks.test:263.
KS55–KS62 are8assertionkills. Final run04:35:32Z–04:37:17Z, cwd sweep[] and
88hashrestore04:37:48Z; slot returned to Sage and direct handoff to Dewey/Mos.
Final77-site tally:75assertionkills(7+49+1+10+8),2separate timeoutkills
(SR08adjudication,KS54control+rerun). final-mutant-results.json consolidates
latest accepted evidence by id; no prior record or log overwritten.
GATE-RESULTS.md is the final per-suite/mutant table; prior narrative saved
as GATE-RESULTS-before-final.md.
KS54 raw placeholder result saved as resume-mutant-results.raw.json before
filling concrete cause in its review result. Same-invocation control id
1792643732624962173; prior syntaxfixed eleven-row result file remains exact
0ede036954ff77c21f29e1a744b21d1d9b18eb75736f78ecd1cf4ae559358911.
Dewey independently read logs/control/restoration and confirmed tally in
dewey-0798, explicitly not yet a manifest verdict. N6test cleanup stays
deferred under mos-1267/Vikunja502; no candidate byte changes permitted.
### Claimant review handoff
Queue move rocko-191-review-r1 entered in-review at revision3114, round1
on#1689, manifest0bcbf337. Revision3115 records the expected missing-token
request failure; command exit1. Receipt:in-review-receipt.txt. Sage posts
the request as jarvis; Dewey owns the manifest verdict. Candidate source
remains published0fa7bf421 with88product paths, base86f95bb27. No live
action, push, source change or further gate authorized by this handoff.
+56
View File
@@ -0,0 +1,56 @@
# Row279: stopRun deadline final observation
Scope: sage-2512, mos-1269; brief docs/plans/2026-10-22_stoprun-deadline.md.
Base f40f2e770. Reviewer Dewey; gate owner Sage. V502 stays separate.
## Cause and smallest change
The loop's precondition now()<=deadline can skip the existing final status
read when a sleep wakes beyond deadline. Use for(;;) so the existing
now()>=deadline branch is the only time exit. Change that loop header only;
all record/identity/ended exits and result mapping remain byte-identical.
The old trailing fallback remains unreachable in the fixed loop. M1 restores
only the old header and thereby restores the complete old fall-through path.
## Regression and evidence plan
Add one test to stop-run.test.mjs, preserving existing tests. Its fake sleep
advances requested duration plus1ms, so the last sleep crosses the deadline.
Its signal stub records TERM/KILL and returns success without killing the
always-live fixture process. Expect both signals, time>7000ms and alive.
The old loop should fail only the alive assertion with unknown.
Ask Sage CLEAR before any run, including the expected regression red. Then
apply the one-line fix, run the touched whole file and processing-control
package as granted, and kill M1 with that same named regression. Busy check,
short scratch TMPDIR,deadDocker,owned process groups,timeouts,restore,sweep.
No live state,units,store,provider,browser,Docker or V502 changes.
Packet includes base,patch,candidate hashes,regression/gate/mutant logs and
exact source-diff check. Commit candidate and packet on the new branch only
after authorized checks. No token is available; Sage publishes as jarvis.
The business/CLI suites remain Sage's landing gate unless separately granted.
## Authorized run and result
Claim rev 3138, op rocko-279-start-r1. Sage granted this exact sequence in
sage-2516 (2026-10-22T05:55:21Z). Runs: 05:56:03Z to 05:58:03Z.
| Step | Pass | Fail | Skip | Result |
| --- | ---: | ---: | ---: | --- |
| Regression on unfixed base | 0 | 1 | 0 | Expected assertion: unknown versus alive |
| stop-run.test.mjs whole | 13 | 0 | 0 | Green |
| processing-control package whole | 62 | 0 | 0 | Green |
| M1 old loop | 0 | 1 | 0 | Killed by the named regression, unknown versus alive |
M1's killer is `a sleep past the stop deadline still observes an alive run
after both signals`. It exits 1 with that assertion, never a syntax error or
timeout. Logs and results.json retain the intentional failures separately.
Green runs total 75 passes (the whole-file tests are also part of the package).
No skips. All commands used a short scratch TMPDIR, a dead Docker socket and
fixture-only configuration. Each node command ran in its own process group
with a 120-second timeout. No timeout occurred. Final cwd sweep: empty.
The source restoration and exact one-line product scope are checked in
source-scope-check.json. The trailing unknown return stays unchanged and is
unreachable after the fix. Business and CLI suites remain Sage's owner gate.
Slot returned to Sage at 05:58:03Z. No live action or V502 change.
+1
View File
@@ -0,0 +1 @@
f40f2e770e0d8be394f1ef53b404045fd6a49c0e
+35
View File
@@ -0,0 +1,35 @@
diff --git a/packages/processing-control/src/runtimes.mjs b/packages/processing-control/src/runtimes.mjs
index 05e439c1a..a11cb8e52 100644
--- a/packages/processing-control/src/runtimes.mjs
+++ b/packages/processing-control/src/runtimes.mjs
@@ -330,7 +330,7 @@ async function ownStop(args, key, options) {
};
const termEnd = started + grace, deadline = termEnd + observe;
let termTried = false, killTried = false;
- while (now() <= deadline) {
+ for (;;) {
try { record = runtime(root, record.id); }
catch {
const present = readdirSync(directory(root, 'runtimes')).includes(record.id + '.json');
diff --git a/packages/processing-control/tests/stop-run.test.mjs b/packages/processing-control/tests/stop-run.test.mjs
index ed69bc111..5277ea7fe 100644
--- a/packages/processing-control/tests/stop-run.test.mjs
+++ b/packages/processing-control/tests/stop-run.test.mjs
@@ -61,6 +61,17 @@ test('failed signal delivery observes alive, records no invented signals and nev
assert.equal(f.sleeps.reduce((a,b)=>a+b,0), 7000);
assert.deepEqual(await stopRun(f.args, f.options), result);
});
+test('a sleep past the stop deadline still observes an alive run after both signals', async t => {
+ const f = setup(t); let time = 0;
+ f.options.now = () => time;
+ f.options.sleep = async ms => { time += ms + 1; };
+ f.options.signal = (_r, sig) => { f.signals.push(sig); return true; };
+ const result = await stopRun(f.args, f.options);
+ assert.ok(time > 7000, 'the last sleep wakes past the complete stop deadline');
+ assert.deepEqual(f.signals, ['SIGTERM', 'SIGKILL']);
+ assert.equal(result.outcome, 'alive');
+ assert.deepEqual(result.signals, ['SIGTERM', 'SIGKILL']);
+});
test('missing run and another business never borrow a pid or signal', async t => {
const f = setup(t);
for (const args of [{ ...f.args, run: 'missing' }, { ...f.args, business: 'other' }]) {
@@ -0,0 +1,2 @@
7145420aa354180689a5c84423928fc60c8123476dcfed3364c9cf4542f8c7ea packages/processing-control/src/runtimes.mjs
7ce7a99363b987f8525f8992fd57940f4305597e870c6162fc158962f2101ff6 packages/processing-control/tests/stop-run.test.mjs
@@ -0,0 +1 @@
1016b85846c60dc159d49373f99ca36794a51fd9
@@ -0,0 +1,2 @@
packages/processing-control/src/runtimes.mjs
packages/processing-control/tests/stop-run.test.mjs
+1
View File
@@ -0,0 +1 @@
1016b85846c60dc159d49373f99ca36794a51fd9
+32
View File
@@ -0,0 +1,32 @@
TAP version 13
# Subtest: a sleep past the stop deadline still observes an alive run after both signals
not ok 1 - a sleep past the stop deadline still observes an alive run after both signals
---
duration_ms: 40.278473
type: 'test'
location: '/mnt/storage/scratch/rocko-r279/packages/processing-control/tests/stop-run.test.mjs:64:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly equal:
'unknown' !== 'alive'
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected: 'alive'
actual: 'unknown'
operator: 'strictEqual'
stack: |-
TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r279/packages/processing-control/tests/stop-run.test.mjs:72:10)
async Test.run (node:internal/test_runner/test:1409:7)
async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3)
...
1..1
# tests 1
# suites 0
# pass 0
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 89.021437
@@ -0,0 +1,382 @@
TAP version 13
# Subtest: container admission publishes its generated name and root/runtime labels before dispatch
ok 1 - container admission publishes its generated name and root/runtime labels before dispatch
---
duration_ms: 9.883733
type: 'test'
...
# Subtest: container sweep sends TERM then KILL only after verifying exact id and both labels
ok 2 - container sweep sends TERM then KILL only after verifying exact id and both labels
---
duration_ms: 66.167157
type: 'test'
...
# Subtest: foreign labels and a replaced container id stay unknown and are never signalled
ok 3 - foreign labels and a replaced container id stay unknown and are never signalled
---
duration_ms: 5.105671
type: 'test'
...
# Subtest: an unverified missing container remains unknown rather than claiming a pending start ended
ok 4 - an unverified missing container remains unknown rather than claiming a pending start ended
---
duration_ms: 15.538468
type: 'test'
...
# Subtest: a removed --rm container is settled only by its trusted owner completion receipt
ok 5 - a removed --rm container is settled only by its trusted owner completion receipt
---
duration_ms: 28.897791
type: 'test'
...
# Subtest: after signalling a verified container, successful empty Docker listing proves --rm removed that id
ok 6 - after signalling a verified container, successful empty Docker listing proves --rm removed that id
---
duration_ms: 24.215826
type: 'test'
...
# Subtest: halt publishes before awaiting stops, resume refuses pending, and only clear permits new work
ok 7 - halt publishes before awaiting stops, resume refuses pending, and only clear permits new work
---
duration_ms: 161.569582
type: 'test'
...
# Subtest: a new coordinator replays a persisted halt and an unresolved start keeps clear refused
ok 8 - a new coordinator replays a persisted halt and an unresolved start keeps clear refused
---
duration_ms: 1102.680874
type: 'test'
...
# Subtest: a new halt generation never inherits a previous quiescent status before its sweep
ok 9 - a new halt generation never inherits a previous quiescent status before its sweep
---
duration_ms: 4.450121
type: 'test'
...
# Subtest: halt aborts an active local request and prevents a subsequent effect after halt/resume
ok 10 - halt aborts an active local request and prevents a subsequent effect after halt/resume
---
duration_ms: 35.831508
type: 'test'
...
# Subtest: processing scopes isolate roots and cancel only the registered child group
ok 11 - processing scopes isolate roots and cancel only the registered child group
---
duration_ms: 59.946603
type: 'test'
...
# Subtest: halted scopes never invoke their body and unscoped human work has no processing context
ok 12 - halted scopes never invoke their body and unscoped human work has no processing context
---
duration_ms: 3.067699
type: 'test'
...
# Subtest: host git is stopped promptly by the active scope rather than waiting for its long deadline
ok 13 - host git is stopped promptly by the active scope rather than waiting for its long deadline
---
duration_ms: 151.711241
type: 'test'
...
# Subtest: a child leader exiting does not erase an unverified surviving group receipt
ok 14 - a child leader exiting does not erase an unverified surviving group receipt
---
duration_ms: 117.914283
type: 'test'
...
# Subtest: the actual nested read-only control mount refuses unlink, replacement and parent removal
ok 15 - the actual nested read-only control mount refuses unlink, replacement and parent removal
---
duration_ms: 14.941666
type: 'test'
...
# Subtest: the image entrypoint refuses halted and stale generations using the shared reader
ok 16 - the image entrypoint refuses halted and stale generations using the shared reader
---
duration_ms: 89.020485
type: 'test'
...
# Subtest: halt terminates a tracked stand-in and leaves honest write-once stop evidence
ok 17 - halt terminates a tracked stand-in and leaves honest write-once stop evidence
---
duration_ms: 95.55672
type: 'test'
...
# Subtest: a TERM-resistant workload is killed after the bounded wait
ok 18 - a TERM-resistant workload is killed after the bounded wait
---
duration_ms: 146.00258
type: 'test'
...
# Subtest: wrong start ticks or boot identity cannot signal a live replacement
ok 19 - wrong start ticks or boot identity cannot signal a live replacement
---
duration_ms: 65.844444
type: 'test'
...
# Subtest: a pending start cannot be reported quiescent and a second root is never signalled
ok 20 - a pending start cannot be reported quiescent and a second root is never signalled
---
duration_ms: 93.749051
type: 'test'
...
# Subtest: escalation still kills the tracked group after its leader exits
ok 21 - escalation still kills the tracked group after its leader exits
---
duration_ms: 192.463864
type: 'test'
...
# Subtest: session TERM targets its verified runner before forcing the outer namespace
ok 22 - session TERM targets its verified runner before forcing the outer namespace
---
duration_ms: 100.923976
type: 'test'
...
# Subtest: new runtime and stop directories sync their containing directories
ok 23 - new runtime and stop directories sync their containing directories
---
duration_ms: 42.560646
type: 'test'
...
# Subtest: a vanished group leader with unverified surviving members stays unknown, never quiescent
ok 24 - a vanished group leader with unverified surviving members stays unknown, never quiescent
---
duration_ms: 130.848284
type: 'test'
...
# Subtest: persisted member identity anchors a group after the leader exited before the sweep
ok 25 - persisted member identity anchors a group after the leader exited before the sweep
---
duration_ms: 124.574935
type: 'test'
...
# Subtest: an unconfirmed secondary brake does not leave a verified workload running or claim success
ok 26 - an unconfirmed secondary brake does not leave a verified workload running or claim success
---
duration_ms: 75.057063
type: 'test'
...
# Subtest: halt persists across readers and resume needs the current generation
ok 27 - halt persists across readers and resume needs the current generation
---
duration_ms: 9.437871
type: 'test'
...
# Subtest: first initialization is explicit; missing state after control exists fails closed and halt repairs it
ok 28 - first initialization is explicit; missing state after control exists fails closed and halt repairs it
---
duration_ms: 5.039685
type: 'test'
...
# Subtest: refuses invalid JSON instead of enabling work
ok 29 - refuses invalid JSON instead of enabling work
---
duration_ms: 1.867281
type: 'test'
...
# Subtest: refuses unknown keys instead of enabling work
ok 30 - refuses unknown keys instead of enabling work
---
duration_ms: 2.65956
type: 'test'
...
# Subtest: refuses bad timestamp instead of enabling work
ok 31 - refuses bad timestamp instead of enabling work
---
duration_ms: 2.144084
type: 'test'
...
# Subtest: refuses nonboolean state instead of enabling work
ok 32 - refuses nonboolean state instead of enabling work
---
duration_ms: 2.039112
type: 'test'
...
# Subtest: refuses bad generation instead of enabling work
ok 33 - refuses bad generation instead of enabling work
---
duration_ms: 2.085806
type: 'test'
...
# Subtest: refuses loose mode instead of enabling work
ok 34 - refuses loose mode instead of enabling work
---
duration_ms: 2.34307
type: 'test'
...
# Subtest: refuses oversized file instead of enabling work
ok 35 - refuses oversized file instead of enabling work
---
duration_ms: 2.916273
type: 'test'
...
# Subtest: a symlink leaf never follows its target; human halt replaces only the leaf
ok 36 - a symlink leaf never follows its target; human halt replaces only the leaf
---
duration_ms: 4.530858
type: 'test'
...
# Subtest: unsafe parent and interrupted initialization never get repaired as off
ok 37 - unsafe parent and interrupted initialization never get repaired as off
---
duration_ms: 0.385291
type: 'test'
...
# Subtest: repeated halt is a no-op and a different root keeps running
ok 38 - repeated halt is a no-op and a different root keeps running
---
duration_ms: 7.00566
type: 'test'
...
# Subtest: transition rejects nonstring identity before publishing state
ok 39 - transition rejects nonstring identity before publishing state
---
duration_ms: 3.121939
type: 'test'
...
# Subtest: bootstrap syncs the containing root as well as the state and control directory
ok 40 - bootstrap syncs the containing root as well as the state and control directory
---
duration_ms: 36.144072
type: 'test'
...
# Subtest: state ownership and bounded read guards refuse before trusting a document
ok 41 - state ownership and bounded read guards refuse before trusting a document
---
duration_ms: 101.892703
type: 'test'
...
# Subtest: canonical root aliases address one switch and never create independent control state
ok 42 - canonical root aliases address one switch and never create independent control state
---
duration_ms: 4.179236
type: 'test'
...
# Subtest: unbound run stops by verified registry identity, fixed 5s grace and 2s observe policy
ok 43 - unbound run stops by verified registry identity, fixed 5s grace and 2s observe policy
---
duration_ms: 38.851257
type: 'test'
...
# Subtest: same run joins the identical promise and completed repeats preserve owner and send no signals
ok 44 - same run joins the identical promise and completed repeats preserve owner and send no signals
---
duration_ms: 27.976805
type: 'test'
...
# Subtest: identity changed before TERM or between TERM and KILL refuses the changed signal
ok 45 - identity changed before TERM or between TERM and KILL refuses the changed signal
---
duration_ms: 15.642865
type: 'test'
...
# Subtest: failed signal delivery observes alive, records no invented signals and never restarts escalation
ok 46 - failed signal delivery observes alive, records no invented signals and never restarts escalation
---
duration_ms: 32.602087
type: 'test'
...
# Subtest: a sleep past the stop deadline still observes an alive run after both signals
ok 47 - a sleep past the stop deadline still observes an alive run after both signals
---
duration_ms: 31.045314
type: 'test'
...
# Subtest: missing run and another business never borrow a pid or signal
ok 48 - missing run and another business never borrow a pid or signal
---
duration_ms: 10.113655
type: 'test'
...
# Subtest: halt joins an in-progress fire without another TERM or deadline and links its first receipt
ok 49 - halt joins an in-progress fire without another TERM or deadline and links its first receipt
---
duration_ms: 30.224351
type: 'test'
...
# Subtest: a separate host joins durable first ownership without duplicate signals
ok 50 - a separate host joins durable first ownership without duplicate signals
---
duration_ms: 99.128156
type: 'test'
...
# Subtest: corrupt and symlinked final receipts refuse as unknown rather than signalling again
ok 51 - corrupt and symlinked final receipts refuse as unknown rather than signalling again
---
duration_ms: 22.649789
type: 'test'
...
# Subtest: a later exit gets separate halt evidence while the original alive result stays immutable
ok 52 - a later exit gets separate halt evidence while the original alive result stays immutable
---
duration_ms: 55.85559
type: 'test'
...
# Subtest: root aliases join but a different root never shares the run stop
ok 53 - root aliases join but a different root never shares the run stop
---
duration_ms: 45.6493
type: 'test'
...
# Subtest: a changed runtime record cannot replace the original stop identity after TERM
ok 54 - a changed runtime record cannot replace the original stop identity after TERM
---
duration_ms: 8.507771
type: 'test'
...
# Subtest: identity is rechecked at actual signal delivery, not just at the loop observation
ok 55 - identity is rechecked at actual signal delivery, not just at the loop observation
---
duration_ms: 6.500324
type: 'test'
...
# Subtest: task halt keeps the true Docker exit status, writes kill-switch once and leaves snapshots unchanged
ok 56 - task halt keeps the true Docker exit status, writes kill-switch once and leaves snapshots unchanged
---
duration_ms: 201.141886
type: 'test'
...
# Subtest: halt refuses direct task launch before Docker or a run record; clear allows a fresh normal result
ok 57 - halt refuses direct task launch before Docker or a run record; clear allows a fresh normal result
---
duration_ms: 167.652567
type: 'test'
...
# Subtest: halt refuses hello.sh before Docker, credentials or release alignment
ok 58 - halt refuses hello.sh before Docker, credentials or release alignment
---
duration_ms: 122.654442
type: 'test'
...
# Subtest: halt refuses verify.sh before Docker, credentials or release alignment
ok 59 - halt refuses verify.sh before Docker, credentials or release alignment
---
duration_ms: 122.064969
type: 'test'
...
# Subtest: halt refuses agent.sh before Docker, credentials or release alignment
ok 60 - halt refuses agent.sh before Docker, credentials or release alignment
---
duration_ms: 120.632718
type: 'test'
...
# Subtest: a nonzero Docker client exit retains its unresolved runtime receipt (task)
ok 61 - a nonzero Docker client exit retains its unresolved runtime receipt (task)
---
duration_ms: 93.3117
type: 'test'
...
# Subtest: a nonzero Docker client exit retains its unresolved runtime receipt (container-cli)
ok 62 - a nonzero Docker client exit retains its unresolved runtime receipt (container-cli)
---
duration_ms: 97.234461
type: 'test'
...
1..62
# tests 62
# suites 0
# pass 62
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 1323.026518
@@ -0,0 +1,32 @@
TAP version 13
# Subtest: a sleep past the stop deadline still observes an alive run after both signals
not ok 1 - a sleep past the stop deadline still observes an alive run after both signals
---
duration_ms: 53.588908
type: 'test'
location: '/mnt/storage/scratch/rocko-r279/packages/processing-control/tests/stop-run.test.mjs:64:1'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly equal:
'unknown' !== 'alive'
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected: 'alive'
actual: 'unknown'
operator: 'strictEqual'
stack: |-
TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r279/packages/processing-control/tests/stop-run.test.mjs:72:10)
async Test.run (node:internal/test_runner/test:1409:7)
async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3)
...
1..1
# tests 1
# suites 0
# pass 0
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 100.649138
@@ -0,0 +1,88 @@
TAP version 13
# Subtest: unbound run stops by verified registry identity, fixed 5s grace and 2s observe policy
ok 1 - unbound run stops by verified registry identity, fixed 5s grace and 2s observe policy
---
duration_ms: 36.122553
type: 'test'
...
# Subtest: same run joins the identical promise and completed repeats preserve owner and send no signals
ok 2 - same run joins the identical promise and completed repeats preserve owner and send no signals
---
duration_ms: 24.270208
type: 'test'
...
# Subtest: identity changed before TERM or between TERM and KILL refuses the changed signal
ok 3 - identity changed before TERM or between TERM and KILL refuses the changed signal
---
duration_ms: 13.289288
type: 'test'
...
# Subtest: failed signal delivery observes alive, records no invented signals and never restarts escalation
ok 4 - failed signal delivery observes alive, records no invented signals and never restarts escalation
---
duration_ms: 27.743499
type: 'test'
...
# Subtest: a sleep past the stop deadline still observes an alive run after both signals
ok 5 - a sleep past the stop deadline still observes an alive run after both signals
---
duration_ms: 26.252224
type: 'test'
...
# Subtest: missing run and another business never borrow a pid or signal
ok 6 - missing run and another business never borrow a pid or signal
---
duration_ms: 9.103416
type: 'test'
...
# Subtest: halt joins an in-progress fire without another TERM or deadline and links its first receipt
ok 7 - halt joins an in-progress fire without another TERM or deadline and links its first receipt
---
duration_ms: 25.918671
type: 'test'
...
# Subtest: a separate host joins durable first ownership without duplicate signals
ok 8 - a separate host joins durable first ownership without duplicate signals
---
duration_ms: 99.095272
type: 'test'
...
# Subtest: corrupt and symlinked final receipts refuse as unknown rather than signalling again
ok 9 - corrupt and symlinked final receipts refuse as unknown rather than signalling again
---
duration_ms: 23.700708
type: 'test'
...
# Subtest: a later exit gets separate halt evidence while the original alive result stays immutable
ok 10 - a later exit gets separate halt evidence while the original alive result stays immutable
---
duration_ms: 55.938599
type: 'test'
...
# Subtest: root aliases join but a different root never shares the run stop
ok 11 - root aliases join but a different root never shares the run stop
---
duration_ms: 42.386018
type: 'test'
...
# Subtest: a changed runtime record cannot replace the original stop identity after TERM
ok 12 - a changed runtime record cannot replace the original stop identity after TERM
---
duration_ms: 7.168469
type: 'test'
...
# Subtest: identity is rechecked at actual signal delivery, not just at the loop observation
ok 13 - identity is rechecked at actual signal delivery, not just at the loop observation
---
duration_ms: 6.715559
type: 'test'
...
1..13
# tests 13
# suites 0
# pass 13
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 445.396108
@@ -0,0 +1 @@
{"id":"M1","file":"packages/processing-control/src/runtimes.mjs","old":" for (;;) {\n try { record = runtime(root, record.id); }","new":" while (now() <= deadline) {\n try { record = runtime(root, record.id); }","test":"packages/processing-control/tests/stop-run.test.mjs","pattern":"a sleep past the stop deadline still observes an alive run after both signals","required":"named assertion kill, never syntax or timeout"}
@@ -0,0 +1,20 @@
6d33d35302499d66036145976f8f777a4c438c6b3f5c7b613fcfa5651c49faab BUILD.md
8521af77deed1d3b2c722f30b776159eb373df407a2a698ee116ef4174125f01 base.txt
74efdcea480e6c089f586b7330591b66164a3aa338fb1e1a60b74f6b7b9f3b0a build.patch
312ed511d8f1cff826ee2fd547de8368923130c7bfe06236d8e29d8655b80aaa candidate-manifest.sha256
960c366a732e4dc20133bf490d842cc42a345fefad1432ca6eba6dbd0058f337 candidate.txt
3099049a5acd051207fb5631b7bb6362dd84c246191009a44eca1ba696d27d73 changed-paths.txt
960c366a732e4dc20133bf490d842cc42a345fefad1432ca6eba6dbd0058f337 commit.txt
98a4da4e1c62f7e4ed5f50adc16cf99462a5e012e9736e658441e8eda3db894c logs/M1.log
7f878c13be43640f501b72d36e8d9e0c8d844bc5451f969154cd75d8fab0ee42 logs/processing-control-whole.log
62f83e511ad860d0f7da42d87e063eb691a95f29a0806b88784912374d2e7c47 logs/regression-red.log
4de8028674472cc75f61dc78cac624c336c8db785580a189e0b705584f80d636 logs/stop-run-whole.log
7669ce9f04189f659dbc23dae6b635d5ffac354e03d00fbae10e6fadc7e98791 mutant-plan.json
84f1080a9689374f7000197119074e53b459641cd709745f95eb0ebe37b5ef2f reader-search.txt
dd6b2ea95782154e1c9839bae7a6d76a60566762a2374901ae64b22f9a7f54f7 results.json
b9408890f30a72451d9171d8ceec40eb4f4f671811b30ba74f50f178e0eb8350 run-step.py
b8140e3919a5d342eb92c1cd34b13bbb62d6419c6a5a01a9cb4870e9a94c64ed scan-result.json
9e5c2e8cd2b59ac4559b0d67348a2a98e59b44a71eec7359018ea7a84d7a4082 skips.txt
43d77fe5bffa916f444a77bacda5b4ddbe78035b6ca7631378056c3158aa3253 source-scope-check.json
37517e5f3dc66819f61f5a7bb8ace1921282415f10551d2defa5c3eb0985b570 sweep.json
74b3eae937fdcc92cf2b0dd15fb67f4f6addd4a046f74e934febc565e4b5d2ed tmpdir.txt
@@ -0,0 +1,12 @@
packages/processing-control/tests/runtimes.test.mjs:9:const api = await import('../src/runtimes.mjs').catch(e => { if (e.code === 'ERR_MODULE_NOT_FOUND') return {}; throw e; });
packages/processing-control/tests/runtimes.test.mjs:139: new URL('../src/runtimes.mjs', import.meta.url).href, new URL('../src/state.mjs', import.meta.url).href, root], { encoding: 'utf8' });
packages/processing-control/tests/effects.test.mjs:60: const { listRuntimes } = await import('../src/runtimes.mjs');
packages/processing-control/tests/coordinator.test.mjs:9:import { registerRuntime, updateRuntime, processIdentity, unregisterRuntime } from '../src/runtimes.mjs';
packages/processing-control/tests/containers.test.mjs:62: const { updateRuntime } = await import('../src/runtimes.mjs');
packages/processing-control/tests/task.test.mjs:12: const runtimes = new URL('../src/runtimes.mjs', import.meta.url).href;
packages/processing-control/tests/stop-run.test.mjs:7:import { registerRuntime, updateRuntime, runtime, stopRun, sweepRuntimes, readStops } from '../src/runtimes.mjs';
packages/processing-control/tests/stop-run.test.mjs:104: const f = setup(t), source = new URL('../src/runtimes.mjs', import.meta.url).href;
packages/processing-control/tests/stop-run.test.mjs:148: const { processIdentity } = await import('../src/runtimes.mjs');
packages/bus/tests/processing-runtime.test.mjs:10:import { registerRuntime, updateRuntime, processIdentity } from '../../processing-control/src/runtimes.mjs';
packages/discord/tests/processing-stop.test.mjs:35: const { registerRuntime, updateRuntime } = await import('../../processing-control/src/runtimes.mjs');
packages/discord/tests/processing.test.mjs:68: const { listRuntimes, sweepRuntimes, processIdentity } = await import('../../processing-control/src/runtimes.mjs');
+62
View File
@@ -0,0 +1,62 @@
[
{
"step": "regression-red",
"start": "2026-10-22T05:56:03Z",
"end": "2026-10-22T05:56:03Z",
"exit": 1,
"counts": {
"tests": 1,
"pass": 0,
"fail": 1,
"skipped": 0
},
"failing": [
"a sleep past the stop deadline still observes an alive run after both signals"
],
"log": "/mnt/storage/scratch/rocko-r279/agents/rocko/work/queue-279/logs/regression-red.log"
},
{
"step": "stop-run-whole",
"start": "2026-10-22T05:57:47Z",
"end": "2026-10-22T05:57:47Z",
"exit": 0,
"counts": {
"tests": 13,
"pass": 13,
"fail": 0,
"skipped": 0
},
"failing": [],
"log": "/mnt/storage/scratch/rocko-r279/agents/rocko/work/queue-279/logs/stop-run-whole.log"
},
{
"step": "processing-control-whole",
"start": "2026-10-22T05:57:47Z",
"end": "2026-10-22T05:57:49Z",
"exit": 0,
"counts": {
"tests": 62,
"pass": 62,
"fail": 0,
"skipped": 0
},
"failing": [],
"log": "/mnt/storage/scratch/rocko-r279/agents/rocko/work/queue-279/logs/processing-control-whole.log"
},
{
"step": "M1",
"start": "2026-10-22T05:58:03Z",
"end": "2026-10-22T05:58:03Z",
"exit": 1,
"counts": {
"tests": 1,
"pass": 0,
"fail": 1,
"skipped": 0
},
"failing": [
"a sleep past the stop deadline still observes an alive run after both signals"
],
"log": "/mnt/storage/scratch/rocko-r279/agents/rocko/work/queue-279/logs/M1.log"
}
]
+15
View File
@@ -0,0 +1,15 @@
from pathlib import Path
import subprocess,signal,os,json,time,sys,re
root=Path('/mnt/storage/scratch/rocko-r279');p=root/'agents/rocko/work/queue-279';logs=p/'logs';logs.mkdir(exist_ok=True);name=sys.argv[1];command=sys.argv[2:]
start=time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime());log=logs/(name+'.log')
with log.open('w') as out:
child=subprocess.Popen(command,cwd=root,env=os.environ,stdout=out,stderr=subprocess.STDOUT,start_new_session=True)
try:rc=child.wait(timeout=120)
except subprocess.TimeoutExpired:
os.killpg(child.pid,signal.SIGKILL);child.wait();rc=124
text=log.read_text();counts={k:int(v) for k,v in re.findall(r'^# (tests|pass|fail|skipped) (\d+)\s*$',text,re.M)};failing=re.findall(r'^not ok \d+ - (.*)$',text,re.M)
row=dict(step=name,start=start,end=time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime()),exit=rc,counts=counts,failing=failing,log=str(log));result=p/'results.json';rows=json.loads(result.read_text()) if result.exists() else [];rows.append(row);result.write_text(json.dumps(rows,indent=2)+'\n');print(json.dumps(row));print(text[-3200:])
if name in ['regression-red','M1']:
ok=rc==1 and failing==['a sleep past the stop deadline still observes an alive run after both signals'] and "actual: 'unknown'" in text and "expected: 'alive'" in text
print('EXPECTED_UNKNOWN_VS_ALIVE',ok);raise SystemExit(0 if ok else 1)
raise SystemExit(rc)
@@ -0,0 +1,4 @@
{
"secret_matches": [],
"binary_files": []
}
+1
View File
@@ -0,0 +1 @@
None. All four TAP records report skipped 0.
@@ -0,0 +1,6 @@
{
"base": "f40f2e770e0d8be394f1ef53b404045fd6a49c0e",
"only_product_change": "while (now() <= deadline) -> for (;;)",
"other_product_bytes_unchanged": true,
"trailing_unknown_fallback": "unchanged; unreachable"
}
+1
View File
@@ -0,0 +1 @@
[]
+1
View File
@@ -0,0 +1 @@
/mnt/storage/scratch/tmp/gwxq
+222
View File
@@ -0,0 +1,222 @@
# Slim packet and full-evidence location
This packet keeps the build note, candidate manifest and patch, summaries,
per-run counts/durations/exit statuses, mutant definitions and cleanup
records. Raw TAP logs, per-attempt config/log trees, diagnostic byte dumps,
runner scripts and transient TMPDIR notes remain only in the full packet.
References below to those files resolve at the full evidence commit.
Full evidence is retained permanently at commit
`d6c924764d387ea320365c4bb160b4b57ed8d635` on
`origin/rocko/row280-da5d-20261022`, under
`agents/rocko/work/queue-280/`. Its 3,531-entry inventory SHA256 is
`7d155092cb6edb547dbb7c8ba2fbabb1dd74f966c5c20965ef4fe6af2e7121b8`.
The full branch is retained; this slim packet does not replace its evidence.
The inventory in this directory describes only this slim packet.
Sage-2541 requested this doc-only follow-up on b765f5cb8 after the green
owner gate: bus 656/0/0, grant-executor 41/0/0, host-run 26/0/0 (#1785 c30431).
No source or test byte changes and no additional test run.
## Portability limit and carry notes
Duckworth N2: M2's demonstrated kill holds only while the host's ephemeral
range has lo <= 32768 <= hi. On another range, the picker test still checks
the outside-range property, but the old-picker mutant may not be killed by
a fixed sample. The evidence here applies to this host's 32768-60999 range.
For the next row touching scratch-sshd.mjs, carry these non-blocking notes:
- N3: add one helper comment line naming MOSAIC_SSHD_EVIDENCE.
- N4: remove the double blank lines in both owning test files.
These are follow-ups, not changes to the tested candidate in this packet.
---
# Row 280: scratch sshd owns its port
Design before code; Sage assignment sage-2530, mos-1270. Base
`da5d3651d75bff113b27d91abefc824c077dabbe`. Claim rev 3148,
`rocko-280-start-r1`. Reviewer Duckworth; gate owner Sage.
## Proposed change
Both helpers currently return on `listening(port)` alone
(grant-executor.test.mjs:86 and host-run.test.mjs:91). A foreign listener
can satisfy that probe while our daemon fails its bind. Move only their
common launch/retry/readiness loop and port selection into a shared test
helper, `packages/bus/tests/scratch-sshd.mjs`; retain their distinct key,
authorized-key and command-stub setup in their existing files.
For each of the existing five attempts, write a fresh, attempt-specific
sshd log, start the child, and accept only after all three checks hold:
a successful loopback connect, the exact `Server listening on 127.0.0.1
port N.` line from this attempt's log, and the child's exitCode/signalCode
still null after the asynchronous connect. No previous attempt's log can
prove a later attempt. Early exit or failed bind advances to a fresh pick;
kill and await an unsuccessful child before retry. Exhaustion keeps the
existing `scratch sshd did not start` error, never a skip. Register cleanup
for the successful child as well.
Read and validate `/proc/sys/net/ipv4/ip_local_port_range`. Select uniformly
from eligible ports 1024 through 65535 outside the inclusive range, refusing
invalid input or an empty eligible set. No fixed 32768 assumption. Expose a
first-attempt port override only to the regression, then use the normal picker.
In each owning file, a regression opens a plain TCP listener, supplies that
occupied port for attempt one, and asserts the helper rejects it and returns
a different port. The helper's log/liveness proof still governs that returned
port. Register TCP listener cleanup before awaiting startup.
Add a deterministic picker assertion using injected random samples spanning
[0,1); every selected port must be unprivileged and outside the actual proc
range. M2 restores the old picker. If the actual host range makes M2 escape
this assertion, record it and ask Duckworth to adjudicate as the brief allows.
## X5 closure
DEFERRED.md:311-321 binds the `sleep 30` after the grant in G22/X5.
Use the fixture's existing fake-command PATH: a `sleep` stub delegates every
other argument to /usr/bin/sleep, but for 30 waits in short intervals only
while its scratch directory exists. The existing `sleep 30` argv, grant,
sudo-log assertion and revoke assertions stay unchanged. Remote cleanup no
longer leaves a 30-second child after the fixture directory is removed.
Append a closing line to the DEFERRED entry; do not rewrite its history.
## Evidence and slot request plan
No run before Sage CLEAR. First run the new occupied-port regressions red
against the old acceptance, then green after the helper change. Run both
whole files, M1 (old connect-only acceptance) and M2 (old random picker),
with each killing test named. Do not classify a timeout or syntax error as
an assertion kill. Save process-group timeout output and restore hashes.
After the whole-file durations are known, refine the repeat estimate.
Provisional estimate: allow 60-90 minutes for both 30-run whole-file repeats
under continuous CLI-suite load, with per-run counts and elapsed time saved.
The load and repeats are intentional concurrency within one granted slot;
all other commands stay sequential. Both files must complete all 30 without
timeout, and the CLI load must remain green. Any unexpected red stops for
Sage/Mos. Use a fresh short scratch TMPDIR, dead Docker socket, no live
configuration, no Docker or browser, and kill owned process groups on timeout.
Sweep owned node and sshd/stub processes and restore candidate hashes before
returning the slot. Sage runs the whole bus suite in the owner gate.
Owned paths: the two existing test files, the shared test helper,
`docs/plans/DEFERRED.md`, and this packet. No product change, no live action.
## Phase 1 runner setup correction
The initial red-grant invocation at 07:22:02Z ran no test: packet runner
parents[5] selected the scratch parent instead of the worktree. The invalid
record and log stay unchanged. Sage-2534 approved parents[4], a git-root
and test-file existence preflight, and a fresh CLEAR. No source or test
byte changed for this correction; it is not a test red.
## Accepted design conditions and first test stop
Sage-2533 and duckworth-0120 approved the design. C1 samples are
[0, 0.25, 0.5, 0.6384, 0.75, 0.999999]; M2 uses rand(), never Math.random().
C2 checks attempt-zero's config names the occupied port and its log has no
listening proof for that port. C3's 30/60-second stub delegates other
arguments to absolute /usr/bin/sleep, so hold-add/remove's 0.05 sleeps do
not recurse. The same stub covers host-run's remote long sleeps (N1).
Attempt logs are sshd-N.log; exhaustion names the final log (N2).
After sage-2534's runner correction, both old-acceptance regression runs
failed exactly on returned port equaling the occupied listener port.
The first green regression then failed: no accepted sshd after five
attempts (07:22:56Z–07:23:09Z, 12.193 s), while the picker assertion passed.
The exact log comparison may be rejecting line framing, but this is only a
hypothesis: fixture cleanup removed the raw attempt logs. Stopped without
retry and requested diagnostic CLEAR to capture the logs before cleanup.
No later whole-file run or mutant was executed. Slot returned 07:23:34Z;
own cwd node/sshd sweep is empty. This evidence is not a passing gate.
## Confirmed log framing and correction
Sage-2535 granted a diagnostic rerun and the conditional CR-only correction.
The saved diagnostic TAP and diagnostic-bytes.json prove the occupied-port
bind refusal, then four listening lines with trailing hex 0d before newline.
Only the comparison changed: the exact expected line, optionally followed
by one CR, is accepted. No trim or substring match is used. The temporary
dump is removed. Both focused files pass 2/2 after this correction.
The earlier diagnostic red is retained as build evidence, not hidden.
## Phase 1 complete
Whole grant-executor: 41/41 in 20.673 s; whole host-run: 26/26 in
14.367 s. Focused regressions: 2/2 in each file. No skips or timeouts.
M1 and M2 are each assertion-killed by the named regression in both files
(4/4 executions, two distinct mutant sites). M2 deterministically hits
32768 at injected sample 0.6384. Source restored. Post-run pgrep finds no
process naming the fixture TMPDIR; cwd node/sshd sweep is empty, proving
the long-sleep stub cleanup requested in C3. Logs retain the prior invalid
runner invocation and the diagnosed CRLF build failure separately.
## Repeat preparation (sage-2536)
Before the repeat commit, the shared helper adds optional fixture evidence
output via MOSAIC_SSHD_EVIDENCE. It saves each attempted config/log before
accepting or abandoning that attempt, so test cleanup cannot erase the
startup evidence. The repeat runner assigns a packet directory per run.
No acceptance, picker, stub or existing assertion changes for this capture.
The repeated full files verify this final committed helper.
Repeat source commit: b765f5cb812e60789e4129ed2ab8009f5439a5be. Four-path manifest SHA256: 27806c0579c76d0999d2e6be4180c3867570df0268af9d7e0d7b971af799e6f5.
All four blobs are rechecked before and after each run. No candidate
bytes may change after this point.
## Final committed-byte evidence
Sage-2536 granted the repeat on committed bytes, with no changes carried
through the run. Source commit b765f5cb812e60789e4129ed2ab8009f5439a5be;
manifest 27806c0579c76d0999d2e6be4180c3867570df0268af9d7e0d7b971af799e6f5.
Repeat window: 2026-10-22T07:29:03Z to 07:48:38Z. Both whole files ran
sequentially under continuous CLI-suite load, with full output, exit status,
counts and durations retained per run in repeat-results.json/repeat-logs/.
Each run checks HEAD and all four hashes before and after. The load is the
actual complete CLI glob, not a substitute stress loop. Sage received the
required 15-minute report at 07:44:01Z.
| Repeat file | Whole runs | Pass | Fail | Skip | Timeout |
| --- | ---: | ---: | ---: | ---: | ---: |
| grant-executor.test.mjs | 30 | 1230 | 0 | 0 | 0 |
| host-run.test.mjs | 30 | 780 | 0 | 0 | 0 |
| CLI load, two whole cycles | 2 | 1588 | 0 | 0 | 0 |
Repeat assertions total 2010/2010; load assertions 1588/1588. The in-progress
second CLI load was allowed to finish whole before stopping. No skips.
Each repeat has a 180 s process-group timeout; each load a 900 s timeout.
Neither bound fired. Attempt config/log evidence was saved before cleanup
under attempt-logs/<run>/<fixture>/; these are generated fixture paths only.
grant-executor: seconds min 20.611, median 20.811, max 26.419.
host-run: seconds min 14.207, median 14.408, max 14.609.
cli-load: seconds min 587.109, median 587.409, max 587.709.
Sage-2537 granted final M1/M2 replays after the complete CLI load exited,
because the optional attempt-log capture was added before the repeat commit.
07:48:56Z–07:48:57Z: four focused executions, all assertion kills:
| Mutant | Named killer, in both owning files | Result |
| --- | --- | --- |
| M1: connect-only acceptance | row 280: an occupied TCP port is attempted, refused, and retried with our sshd | Both fail returned-port inequality |
| M2: 20000 + Math.floor(rand() * 20000) | row 280: every injected picker sample is unprivileged and outside the ephemeral range | Both fail at sample 0.6384, port 32768 |
No timeout or syntax failure is counted as a kill. Phase-1 runner, table and
logs remain separately recorded; final replays use the exact b765 helper.
Restored-hashes.json records 4/4 with no drift. Final-sweep.json records no
node, chromium or sshd under owned cwd paths and no process naming either
owned TMPDIR. This also supplies C3's post-test long-sleep cleanup evidence.
Slot returned at 07:49:17Z. No source byte changed during the repeat; the
final mutations were restored under sage-2537. No live file, unit or config
was touched. The whole bus suite remains Sage's owner gate.
Existing assertions/refusals remain unchanged (289 grant lines, 125 host
lines); assertion-preservation.json records the static check. The reader
search found only the intended shared-helper imports and no outside reader
of either owning file. Duckworth also confirmed no owning-file hash pin.
The two extra blank-line pairs are acknowledged style nits, left unchanged
after the committed repeat (sage-2537).
@@ -0,0 +1,10 @@
{
"packages/bus/tests/grant-executor.test.mjs": {
"existing_assertion_refusal_lines": 289,
"all_preserved": true
},
"packages/bus/tests/host-run.test.mjs": {
"existing_assertion_refusal_lines": 125,
"all_preserved": true
}
}
+1
View File
@@ -0,0 +1 @@
da5d3651d75bff113b27d91abefc824c077dabbe
+333
View File
@@ -0,0 +1,333 @@
diff --git a/docs/plans/DEFERRED.md b/docs/plans/DEFERRED.md
index 197a29ecb..7945a6829 100644
--- a/docs/plans/DEFERRED.md
+++ b/docs/plans/DEFERRED.md
@@ -319,6 +319,10 @@ at every gate. Started 2026-09-12 during the control board MVP.
waits 30 s before it calls a lone `sleep 30` a leak. The row 197
lanes scan only at lane end, about 15 minutes after pkg-bus, so they
can't see it (mos-1169). (2026-10-21)
+ Closing note, row 280 (2026-10-22): both scratch sshd fixtures install a
+ bounded 30/60-second sleep stub that exits when its test directory is gone;
+ other sleep arguments exec /usr/bin/sleep unchanged. Evidence is recorded
+ in agents/rocko/work/queue-280/BUILD.md.
- **Row 66 live checks: the unslop notice on a live managed session, both harnesses.**
(mos-1186, Vikunja #454.) Row 66's gate asked for one landing session per
diff --git a/packages/bus/tests/grant-executor.test.mjs b/packages/bus/tests/grant-executor.test.mjs
index 1992f1525..b27d91dc4 100644
--- a/packages/bus/tests/grant-executor.test.mjs
+++ b/packages/bus/tests/grant-executor.test.mjs
@@ -6,9 +6,9 @@
// reaches another host.
import test from 'node:test';
import assert from 'node:assert/strict';
-import { execFileSync, spawn } from 'node:child_process';
+import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
-import { connect } from 'node:net';
+import { createServer } from 'node:net';
import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir, userInfo } from 'node:os';
import { join } from 'node:path';
@@ -19,6 +19,8 @@ import { hostExecutor, runHost } from '../src/host-run.mjs';
import { Store } from '../src/store.mjs';
import { grantDecision, hostRecord } from './host-fixtures.mjs';
+import { ephemeralRange, installSleepStub, pickPort, startScratchSshd } from './scratch-sshd.mjs';
+
const SSHD = '/usr/bin/sshd';
const USER = userInfo().username;
const AT = '2026-10-21T00:00:00.000Z';
@@ -35,12 +37,7 @@ const DEFINITIONS = {
pm: { roleVersion: 2, credentials: [] },
ops: { roleVersion: 2, credentials: [{ service: 'host' }] },
};
-const listening = (port) =>
- new Promise((resolve) => {
- const s = connect(port, '127.0.0.1');
- s.on('connect', () => (s.destroy(), resolve(true)));
- s.on('error', () => resolve(false));
- });
+
async function until(fn, ms = 10000) {
const end = Date.now() + ms;
while (!fn()) {
@@ -54,12 +51,13 @@ async function until(fn, ms = 10000) {
// Both also stop when the test dir is gone, since a held stub on the sshd side outlives its test (#1750).
// wrap.sh logs every request. The sudo stub logs its arguments to sudo.log and runs the command as the
// test's user.
-async function sshd(t, dir) {
+async function sshd(t, dir, options) {
if (!existsSync(SSHD)) throw new Error(`${SSHD} is missing: the executor tests need it and fail, never skip`);
keygen(join(dir, 'hostkey'));
keygen(join(dir, 'installer'));
writeFileSync(join(dir, 'authorized_keys'), readFileSync(join(dir, 'installer.pub')), { mode: 0o600 });
mkdirSync(join(dir, 'fake'));
+ installSleepStub(dir);
const stub = (name, body) => writeFileSync(join(dir, 'fake', name), `#!/bin/sh\nD='${dir}'\n${body}\n`, { mode: 0o755 });
stub('list', '[ -e "$D/fail-list" ] && exit 1\n[ -e "$D/granted" ] && grep " mosaic-[0-9a-f]*$" "$D/granted"\nexit 0');
stub(
@@ -76,26 +74,42 @@ async function sshd(t, dir) {
`#!/bin/sh\nprintf '%s\\n' "$SSH_ORIGINAL_COMMAND" >> '${dir}/requests.log'\nPATH=${dir}/fake:/usr/bin:/bin; export PATH\nexec /bin/sh -c "$SSH_ORIGINAL_COMMAND"\n`,
{ mode: 0o755 },
);
- for (let attempt = 0; attempt < 5; attempt++) {
- const port = 20000 + Math.floor(Math.random() * 20000);
- writeFileSync(
- join(dir, 'sshd_config'),
- [`Port ${port}`, 'ListenAddress 127.0.0.1', `HostKey ${dir}/hostkey`, `AuthorizedKeysFile ${dir}/authorized_keys ${dir}/granted`,
- `PidFile ${dir}/sshd.pid`, 'UsePAM no', 'StrictModes no', 'PasswordAuthentication no', 'KbdInteractiveAuthentication no',
- `ForceCommand ${dir}/wrap.sh`, ''].join('\n'),
- );
- const daemon = spawn(SSHD, ['-D', '-f', join(dir, 'sshd_config'), '-E', join(dir, 'sshd.log')], { stdio: 'ignore' });
- t.after(() => daemon.kill('SIGKILL'));
- const deadline = Date.now() + 3000;
- while (Date.now() < deadline && daemon.exitCode === null) {
- if (await listening(port)) return port;
- await sleep(30);
- }
- daemon.kill('SIGKILL');
- }
- throw new Error('scratch sshd did not start');
+ return startScratchSshd(t, dir, [
+ 'ListenAddress 127.0.0.1', `HostKey ${dir}/hostkey`,
+ `AuthorizedKeysFile ${dir}/authorized_keys ${dir}/granted`, `PidFile ${dir}/sshd.pid`,
+ 'UsePAM no', 'StrictModes no', 'PasswordAuthentication no', 'KbdInteractiveAuthentication no',
+ `ForceCommand ${dir}/wrap.sh`,
+ ], options);
}
+
+test('row 280: an occupied TCP port is attempted, refused, and retried with our sshd', async t => {
+ const dir = mkdtempSync(join(tmpdir(), 'sshd280-'));
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
+ const server = createServer(socket => socket.end());
+ await new Promise((resolve, reject) => {
+ server.once('error', reject);
+ server.listen(0, '127.0.0.1', resolve);
+ });
+ t.after(() => new Promise(resolve => server.close(resolve)));
+ const occupied = server.address().port;
+ const port = await sshd(t, dir, { firstPort: occupied });
+ assert.match(readFileSync(join(dir, 'sshd-0.conf'), 'utf8'), new RegExp(`^Port ${occupied}\\n`));
+ assert.equal(readFileSync(join(dir, 'sshd-0.log'), 'utf8').includes(`Server listening on 127.0.0.1 port ${occupied}.`), false);
+ assert.notEqual(port, occupied);
+ assert.ok([1, 2, 3, 4].some(n => existsSync(join(dir, `sshd-${n}.log`)) &&
+ readFileSync(join(dir, `sshd-${n}.log`), 'utf8').includes(`Server listening on 127.0.0.1 port ${port}.`)));
+});
+
+test('row 280: every injected picker sample is unprivileged and outside the ephemeral range', () => {
+ const [lo, hi] = ephemeralRange();
+ for (const sample of [0, 0.25, 0.5, 0.6384, 0.75, 0.999999]) {
+ const port = pickPort(() => sample);
+ assert.ok(port >= 1024 && port <= 65535);
+ assert.ok(port < lo || port > hi, `sample ${sample}: ${port} inside ${lo}-${hi}`);
+ }
+});
+
async function setup(t, opts = {}) {
const dir = mkdtempSync(join(tmpdir(), 'g235x-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
diff --git a/packages/bus/tests/host-run.test.mjs b/packages/bus/tests/host-run.test.mjs
index 6f5949b5c..f7eebd45e 100644
--- a/packages/bus/tests/host-run.test.mjs
+++ b/packages/bus/tests/host-run.test.mjs
@@ -4,7 +4,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync, spawn } from 'node:child_process';
-import { connect, createServer } from 'node:net';
+import { createServer } from 'node:net';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir, userInfo } from 'node:os';
import { join } from 'node:path';
@@ -19,6 +19,8 @@ import { hostGrant } from '../src/host-grant.mjs';
import { startBroker } from '../src/runtime.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
+import { ephemeralRange, installSleepStub, pickPort, startScratchSshd } from './scratch-sshd.mjs';
+
const SSHD = '/usr/bin/sshd';
const REPO = fileURLToPath(new URL('../../../', import.meta.url));
const USER = userInfo().username;
@@ -50,22 +52,18 @@ async function until(fn, ms = 5000) {
}
return true;
}
-const listening = (port) =>
- new Promise((resolve) => {
- const s = connect(port, '127.0.0.1');
- s.on('connect', () => (s.destroy(), resolve(true)));
- s.on('error', () => resolve(false));
- });
+
// A scratch sshd. ForceCommand puts the fake sudo first on PATH and hands the command to /bin/sh, so
// the remote side parses the command line the way a real host's POSIX shell does.
-async function sshd(t, dir) {
+async function sshd(t, dir, options) {
if (!existsSync(SSHD)) throw new Error(`${SSHD} is missing: the executor tests need it and fail, never skip`);
keygen(join(dir, 'hostkey'));
keygen(join(dir, 'otherhost'));
keygen(join(dir, 'user'));
writeFileSync(join(dir, 'authorized_keys'), readFileSync(join(dir, 'user.pub')), { mode: 0o600 });
mkdirSync(join(dir, 'fake'));
+ installSleepStub(dir);
writeFileSync(
join(dir, 'fake', 'sudo'),
`#!/bin/sh\n'${process.execPath}' -e 'require("fs").appendFileSync(process.argv[1], JSON.stringify(process.argv.slice(2)) + "\\n")' '${dir}/argv.json' "$@"\nshift 2\nexec "$@"\n`,
@@ -75,26 +73,42 @@ async function sshd(t, dir) {
writeFileSync(join(dir, 'fake', 'fail255'), '#!/bin/sh\necho "Host key verification failed." >&2\nexit 255\n', { mode: 0o755 });
writeFileSync(join(dir, 'wrap.sh'), `#!/bin/sh\nPATH=${dir}/fake:/usr/bin:/bin; export PATH\nexec /bin/sh -c "$SSH_ORIGINAL_COMMAND"\n`);
chmodSync(join(dir, 'wrap.sh'), 0o755);
- for (let attempt = 0; attempt < 5; attempt++) {
- const port = 20000 + Math.floor(Math.random() * 20000);
- writeFileSync(
- join(dir, 'sshd_config'),
- [`Port ${port}`, 'ListenAddress 127.0.0.1', `HostKey ${dir}/hostkey`, `AuthorizedKeysFile ${dir}/authorized_keys`,
- `PidFile ${dir}/sshd.pid`, 'UsePAM no', 'StrictModes no', 'PasswordAuthentication no', 'KbdInteractiveAuthentication no',
- `ForceCommand ${dir}/wrap.sh`, ''].join('\n'),
- );
- const daemon = spawn(SSHD, ['-D', '-f', join(dir, 'sshd_config'), '-E', join(dir, 'sshd.log')], { stdio: 'ignore' });
- t.after(() => daemon.kill('SIGKILL'));
- const deadline = Date.now() + 3000;
- while (Date.now() < deadline && daemon.exitCode === null) {
- if (await listening(port)) return port;
- await sleep(30);
- }
- daemon.kill('SIGKILL');
- }
- throw new Error('scratch sshd did not start');
+ return startScratchSshd(t, dir, [
+ 'ListenAddress 127.0.0.1', `HostKey ${dir}/hostkey`,
+ `AuthorizedKeysFile ${dir}/authorized_keys`, `PidFile ${dir}/sshd.pid`,
+ 'UsePAM no', 'StrictModes no', 'PasswordAuthentication no', 'KbdInteractiveAuthentication no',
+ `ForceCommand ${dir}/wrap.sh`,
+ ], options);
}
+
+test('row 280: an occupied TCP port is attempted, refused, and retried with our sshd', async t => {
+ const dir = mkdtempSync(join(tmpdir(), 'sshd280-'));
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
+ const server = createServer(socket => socket.end());
+ await new Promise((resolve, reject) => {
+ server.once('error', reject);
+ server.listen(0, '127.0.0.1', resolve);
+ });
+ t.after(() => new Promise(resolve => server.close(resolve)));
+ const occupied = server.address().port;
+ const port = await sshd(t, dir, { firstPort: occupied });
+ assert.match(readFileSync(join(dir, 'sshd-0.conf'), 'utf8'), new RegExp(`^Port ${occupied}\\n`));
+ assert.equal(readFileSync(join(dir, 'sshd-0.log'), 'utf8').includes(`Server listening on 127.0.0.1 port ${occupied}.`), false);
+ assert.notEqual(port, occupied);
+ assert.ok([1, 2, 3, 4].some(n => existsSync(join(dir, `sshd-${n}.log`)) &&
+ readFileSync(join(dir, `sshd-${n}.log`), 'utf8').includes(`Server listening on 127.0.0.1 port ${port}.`)));
+});
+
+test('row 280: every injected picker sample is unprivileged and outside the ephemeral range', () => {
+ const [lo, hi] = ephemeralRange();
+ for (const sample of [0, 0.25, 0.5, 0.6384, 0.75, 0.999999]) {
+ const port = pickPort(() => sample);
+ assert.ok(port >= 1024 && port <= 65535);
+ assert.ok(port < lo || port > hi, `sample ${sample}: ${port} inside ${lo}-${hi}`);
+ }
+});
+
const GRANT = [['true'], ['false'], ['echo'], ['sleep'], ['head'], ['cat'], ['systemctl', 'status']];
async function hostFixture(t, { commands = GRANT, reviewerHosts, wrongHostKey = false, coderWeb2 = false } = {}) {
const dir = mkdtempSync(join(tmpdir(), 'h210-'));
diff --git a/packages/bus/tests/scratch-sshd.mjs b/packages/bus/tests/scratch-sshd.mjs
new file mode 100644
index 000000000..2dde26de3
--- /dev/null
+++ b/packages/bus/tests/scratch-sshd.mjs
@@ -0,0 +1,88 @@
+// Row 280: fixture-only sshd startup. A TCP connect alone does not prove ownership.
+import { spawn } from 'node:child_process';
+import { once } from 'node:events';
+import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { connect } from 'node:net';
+import { basename, join } from 'node:path';
+
+export const ephemeralRange = () => {
+ const text = readFileSync('/proc/sys/net/ipv4/ip_local_port_range', 'utf8').trim();
+ if (!/^\d+\s+\d+$/.test(text)) throw Error('invalid ephemeral port range');
+ const range = text.split(/\s+/).map(Number);
+ if (range[0] < 1 || range[1] > 65535 || range[0] > range[1]) throw Error('invalid ephemeral port range');
+ return range;
+};
+
+export function pickPort(rand = Math.random) {
+ const [lo, hi] = ephemeralRange();
+ const below = Math.max(0, Math.min(lo, 65536) - 1024);
+ const above = Math.max(0, 65535 - Math.max(hi, 1023));
+ const sample = rand();
+ if (!below && !above) throw Error('no scratch sshd port available');
+ if (!(sample >= 0 && sample < 1)) throw Error('invalid random sample');
+ const offset = Math.floor(sample * (below + above));
+ return offset < below ? 1024 + offset : Math.max(hi + 1, 1024) + offset - below;
+}
+
+const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
+const listening = port => new Promise(resolve => {
+ const socket = connect(port, '127.0.0.1');
+ const done = result => { socket.destroy(); resolve(result); };
+ socket.setTimeout(200, () => done(false));
+ socket.on('connect', () => done(true));
+ socket.on('error', () => done(false));
+});
+const stop = async daemon => {
+ if (daemon.pid === undefined || daemon.exitCode !== null || daemon.signalCode !== null) return;
+ const exited = once(daemon, 'exit');
+ daemon.kill('SIGKILL');
+ await exited;
+};
+
+export async function startScratchSshd(t, dir, config, { firstPort } = {}) {
+ let lastLog;
+ for (let attempt = 0; attempt < 5; attempt++) {
+ const port = attempt === 0 && firstPort !== undefined ? firstPort : pickPort();
+ const configFile = join(dir, `sshd-${attempt}.conf`);
+ lastLog = join(dir, `sshd-${attempt}.log`);
+ writeFileSync(configFile, [`Port ${port}`, ...config, ''].join('\n'));
+ writeFileSync(lastLog, '');
+ const daemon = spawn('/usr/bin/sshd', ['-D', '-f', configFile, '-E', lastLog], { stdio: 'ignore' });
+ t.after(() => stop(daemon));
+ const saveLog = () => {
+ if (!process.env.MOSAIC_SSHD_EVIDENCE) return;
+ const evidence = join(process.env.MOSAIC_SSHD_EVIDENCE, basename(dir));
+ mkdirSync(evidence, { recursive: true });
+ writeFileSync(join(evidence, basename(lastLog)), readFileSync(lastLog));
+ writeFileSync(join(evidence, basename(configFile)), readFileSync(configFile));
+ };
+ let spawnError;
+ daemon.on('error', error => { spawnError = error; });
+ const deadline = Date.now() + 3000;
+ while (Date.now() < deadline && !spawnError && daemon.exitCode === null && daemon.signalCode === null) {
+ if (await listening(port) && daemon.exitCode === null && daemon.signalCode === null &&
+ readFileSync(lastLog, 'utf8').split('\n').some(line =>
+ (line === `Server listening on 127.0.0.1 port ${port}.` ||
+ line === `Server listening on 127.0.0.1 port ${port}.\r`))) { saveLog(); return port; }
+ await sleep(30);
+ }
+ await stop(daemon);
+ saveLog();
+ }
+ throw Error(`scratch sshd did not start: ${lastLog}`);
+}
+
+// Long remote sleeps remain in flight for their original duration, but cannot outlive the fixture.
+export function installSleepStub(dir) {
+ const script = `const fs = require("node:fs");
+const end = Date.now() + Number(process.argv[2]) * 1000;
+const timer = setInterval(() => {
+ if (!fs.existsSync(process.argv[1]) || Date.now() >= end) { clearInterval(timer); }
+}, 50);`;
+ writeFileSync(join(dir, 'fake', 'sleep'), `#!/bin/sh
+case "$#:$1" in
+ 1:30|1:60) exec '${process.execPath}' -e '${script}' '${dir}' "$1" ;;
+ *) exec /usr/bin/sleep "$@" ;;
+esac
+`, { mode: 0o755 });
+}
@@ -0,0 +1,4 @@
1cb0971e745745e3db329c7c75f36197664d7bf0687def33c0819134f121975f packages/bus/tests/grant-executor.test.mjs
140ec358fff212b747412691e3cb5952844c13c7fe43d3ff1013d24f0df886d2 packages/bus/tests/host-run.test.mjs
3b647e59f09e7fc43b41d072e46c3cea5ed7641d5d4a95c5b39c240f73cdb75e packages/bus/tests/scratch-sshd.mjs
94c21723cd42f551bbd6249d5133cbfaa12ac25efef5a72bf525998ac3c52100 docs/plans/DEFERRED.md
@@ -0,0 +1 @@
b765f5cb812e60789e4129ed2ab8009f5439a5be
@@ -0,0 +1,4 @@
packages/bus/tests/grant-executor.test.mjs
packages/bus/tests/host-run.test.mjs
packages/bus/tests/scratch-sshd.mjs
docs/plans/DEFERRED.md
+1
View File
@@ -0,0 +1 @@
b765f5cb812e60789e4129ed2ab8009f5439a5be
@@ -0,0 +1,15 @@
{
"cwd_processes": [],
"pgrep_checks": [
{
"tmpdir": "/mnt/storage/scratch/tmp/gInD",
"exit": 1,
"matches": []
},
{
"tmpdir": "/mnt/storage/scratch/tmp/gVtU",
"exit": 1,
"matches": []
}
]
}
@@ -0,0 +1,4 @@
{
"helper_sha256": "3b647e59f09e7fc43b41d072e46c3cea5ed7641d5d4a95c5b39c240f73cdb75e",
"restored": true
}
+14
View File
@@ -0,0 +1,14 @@
[
{
"id": "M1",
"old": " if (await listening(port) && daemon.exitCode === null && daemon.signalCode === null &&\n readFileSync(lastLog, 'utf8').split('\\n').some(line =>\n (line === `Server listening on 127.0.0.1 port ${port}.` ||\n line === `Server listening on 127.0.0.1 port ${port}.\\r`))) { saveLog(); return port; }",
"new": " if (await listening(port)) return port;",
"pattern": "row 280: an occupied TCP port"
},
{
"id": "M2",
"old": "export function pickPort(rand = Math.random) {\n const [lo, hi] = ephemeralRange();\n const below = Math.max(0, Math.min(lo, 65536) - 1024);\n const above = Math.max(0, 65535 - Math.max(hi, 1023));\n const sample = rand();\n if (!below && !above) throw Error('no scratch sshd port available');\n if (!(sample >= 0 && sample < 1)) throw Error('invalid random sample');\n const offset = Math.floor(sample * (below + above));\n return offset < below ? 1024 + offset : Math.max(hi + 1, 1024) + offset - below;\n}\n",
"new": "export function pickPort(rand = Math.random) {\n return 20000 + Math.floor(rand() * 20000);\n}\n",
"pattern": "row 280: every injected picker sample"
}
]
@@ -0,0 +1,21 @@
d5d82d7cb679332e7d90ed26220cd7d3170931d2e8aa2ccdfc35ceea12147c11 BUILD.md
0e8f1e76f8c13d7de5cbea1ae8b8c2f178ca5aa0283ac0dbd00bffcd4c39bea5 assertion-preservation.json
052e9e79aa3c9ba1b6f8dedc7af00440e104d308ed9451a076e872c903704c7d base.txt
5898d3c45b4274a9cbf117018811f2b6206987e3849e97ce171c3aaa42d314da build.patch
27806c0579c76d0999d2e6be4180c3867570df0268af9d7e0d7b971af799e6f5 candidate-manifest.sha256
634e46d73bbcdbdea70660954fcc8290dc3d6c8d15554218d59c78feafbd0b08 candidate.txt
84f2091c0eb1b55c34b4462775160b5f808a6843e68b3e4cb54e1f7a2421eaf3 changed-paths.txt
634e46d73bbcdbdea70660954fcc8290dc3d6c8d15554218d59c78feafbd0b08 commit.txt
eb9f55fd5de0ec370742702f15d737fb91e35542dbda727d43502adce92903a9 final-sweep.json
f9a578b95bd6f9e07dc231002a28ea9c3ae14fe2e269f3fbca50c4fc97ba61e0 mutant-restore.json
72cfbf942b4437720d6b92ddef0bc138d27bd6ecef1f1da0b32a2b0250b3cd61 mutants.json
b435b731ea4ccff12f90e1b53023cbc54bf65c08ad0f7feaf8a810088156a0b6 post-phase1-pgrep.txt
9f8458487a1c5146230e70d40428b6fb218c93f378fd1a8e8607fab80c446b2c reader-search.txt
634e46d73bbcdbdea70660954fcc8290dc3d6c8d15554218d59c78feafbd0b08 repeat-commit.txt
42e240961767b817fb5f1d183bc949074b3b90c72335d65c3e0278a3db4294ea repeat-results.json
e26b6ecefd1a6db69fbaaa38c4f08abe0ad69035ecaa8e2e29f5da2961e5da15 repeat-summary.json
3e1903ee103b0a9ef8febeb4d2073848aca0e42fd4ecd1c586381f2ab7404be2 restored-hashes.json
f38eaa23861b8acb1738ed082f25a0e6661d976b87da140993373de41bc4053d results.json
76c5c03767ca34bc38a7f69c14fbf1c06e8ebcc93120bc577a44e2271f6fdb16 scan-result.json
e7f560ce5fc957432ddcad231897a5decd33be06f1201926adabb62d3c218c83 skips.txt
37517e5f3dc66819f61f5a7bb8ace1921282415f10551d2defa5c3eb0985b570 sweep-phase1.json
@@ -0,0 +1 @@
No process names the owned fixture TMPDIR.
@@ -0,0 +1,28 @@
Search: packages/bus/tests/grant-executor.test.mjs
(none)
Search: grant-executor.test.mjs
(none)
Search: packages/bus/tests/host-run.test.mjs
(none)
Search: host-run.test.mjs
(none)
Search: packages/bus/tests/scratch-sshd.mjs
(none)
Search: scratch-sshd.mjs
packages/bus/tests/grant-executor.test.mjs:22:import { ephemeralRange, installSleepStub, pickPort, startScratchSshd } from './scratch-sshd.mjs';
packages/bus/tests/host-run.test.mjs:22:import { ephemeralRange, installSleepStub, pickPort, startScratchSshd } from './scratch-sshd.mjs';
Search: docs/plans/DEFERRED.md
packages/queue/tests/fixtures/genesis-render.md:35:| 30 | Open gaps from the MVP work | unassigned | — | parked | — | Jason reopens (jason) | `docs/plans/DEFERRED.md` § Open | Fixed only when a gate needs them |
packages/queue/tests/fixtures/queue-marked.md:25:Gaps found while working go to `docs/plans/DEFERRED.md`, not here.
Search: DEFERRED.md
packages/queue/tests/fixtures/genesis-render.md:35:| 30 | Open gaps from the MVP work | unassigned | — | parked | — | Jason reopens (jason) | `docs/plans/DEFERRED.md` § Open | Fixed only when a gate needs them |
packages/queue/tests/fixtures/queue-marked.md:25:Gaps found while working go to `docs/plans/DEFERRED.md`, not here.
packages/queue/tests/fixtures/queue-marked.md:83:| Open gaps from the MVP work | Fixed only when a gate needs them | DEFERRED.md, "Open" |
packages/queue/tests/fixtures/queue-marked.md:92:- 2026-09-13 — created; rows 1 to 8 taken from CURRENT.md, DEFERRED.md and the plan page.
@@ -0,0 +1 @@
b765f5cb812e60789e4129ed2ab8009f5439a5be
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,25 @@
{
"head": "b765f5cb812e60789e4129ed2ab8009f5439a5be",
"manifest_sha256": "27806c0579c76d0999d2e6be4180c3867570df0268af9d7e0d7b971af799e6f5",
"start": "2026-10-22T07:29:03Z",
"end": "2026-10-22T07:48:38Z",
"failed": false,
"repeat_runs": 60,
"load_runs": 2,
"counts": {
"repeat": {
"tests": 2010,
"pass": 2010,
"fail": 0,
"skipped": 0,
"cancelled": 0
},
"cli-load": {
"tests": 1588,
"pass": 1588,
"fail": 0,
"skipped": 0,
"cancelled": 0
}
}
}
@@ -0,0 +1,4 @@
{
"paths": 4,
"drift": []
}
+368
View File
@@ -0,0 +1,368 @@
[
{
"step": "red-grant",
"start": "2026-10-22T07:22:02Z",
"end": "2026-10-22T07:22:02Z",
"seconds": 0.029,
"exit": 1,
"timeout": false,
"counts": {},
"command": [
"node",
"--test",
"--test-reporter=tap",
"--test-name-pattern=row 280: an occupied TCP port",
"packages/bus/tests/grant-executor.test.mjs"
]
},
{
"step": "red-grant-actual",
"start": "2026-10-22T07:22:42Z",
"end": "2026-10-22T07:22:43Z",
"seconds": 0.111,
"exit": 1,
"timeout": false,
"counts": {
"tests": 1,
"pass": 0,
"fail": 1,
"cancelled": 0,
"skipped": 0
},
"command": [
"node",
"--test",
"--test-reporter=tap",
"--test-name-pattern=row 280: an occupied TCP port",
"packages/bus/tests/grant-executor.test.mjs"
]
},
{
"step": "red-host-actual",
"start": "2026-10-22T07:22:43Z",
"end": "2026-10-22T07:22:43Z",
"seconds": 0.143,
"exit": 1,
"timeout": false,
"counts": {
"tests": 1,
"pass": 0,
"fail": 1,
"cancelled": 0,
"skipped": 0
},
"command": [
"node",
"--test",
"--test-reporter=tap",
"--test-name-pattern=row 280: an occupied TCP port",
"packages/bus/tests/host-run.test.mjs"
]
},
{
"step": "green-grant-regressions",
"start": "2026-10-22T07:22:56Z",
"end": "2026-10-22T07:23:09Z",
"seconds": 12.193,
"exit": 1,
"timeout": false,
"counts": {
"tests": 2,
"pass": 1,
"fail": 1,
"cancelled": 0,
"skipped": 0
},
"command": [
"node",
"--test",
"--test-reporter=tap",
"--test-name-pattern=row 280:",
"packages/bus/tests/grant-executor.test.mjs"
]
},
{
"step": "diagnostic-log-bytes",
"start": "2026-10-22T07:24:22Z",
"end": "2026-10-22T07:24:34Z",
"seconds": 12.196,
"exit": 1,
"timeout": false,
"counts": {
"tests": 1,
"pass": 0,
"fail": 1,
"cancelled": 0,
"skipped": 0
},
"command": [
"node",
"--test",
"--test-reporter=tap",
"--test-name-pattern=row 280: an occupied TCP port",
"packages/bus/tests/grant-executor.test.mjs"
]
},
{
"step": "green-grant-regressions-crlf",
"start": "2026-10-22T07:25:03Z",
"end": "2026-10-22T07:25:03Z",
"seconds": 0.178,
"exit": 0,
"timeout": false,
"counts": {
"tests": 2,
"pass": 2,
"fail": 0,
"cancelled": 0,
"skipped": 0
},
"command": [
"node",
"--test",
"--test-reporter=tap",
"--test-name-pattern=row 280:",
"packages/bus/tests/grant-executor.test.mjs"
]
},
{
"step": "green-host-regressions-crlf",
"start": "2026-10-22T07:25:03Z",
"end": "2026-10-22T07:25:03Z",
"seconds": 0.212,
"exit": 0,
"timeout": false,
"counts": {
"tests": 2,
"pass": 2,
"fail": 0,
"cancelled": 0,
"skipped": 0
},
"command": [
"node",
"--test",
"--test-reporter=tap",
"--test-name-pattern=row 280:",
"packages/bus/tests/host-run.test.mjs"
]
},
{
"step": "grant-whole",
"start": "2026-10-22T07:25:03Z",
"end": "2026-10-22T07:25:24Z",
"seconds": 20.673,
"exit": 0,
"timeout": false,
"counts": {
"tests": 41,
"pass": 41,
"fail": 0,
"cancelled": 0,
"skipped": 0
},
"command": [
"node",
"--test",
"--test-reporter=tap",
"packages/bus/tests/grant-executor.test.mjs"
]
},
{
"step": "host-whole",
"start": "2026-10-22T07:25:24Z",
"end": "2026-10-22T07:25:38Z",
"seconds": 14.367,
"exit": 0,
"timeout": false,
"counts": {
"tests": 26,
"pass": 26,
"fail": 0,
"cancelled": 0,
"skipped": 0
},
"command": [
"node",
"--test",
"--test-reporter=tap",
"packages/bus/tests/host-run.test.mjs"
]
},
{
"step": "M1-grant-executor",
"start": "2026-10-22T07:26:25Z",
"end": "2026-10-22T07:26:25Z",
"seconds": 0.113,
"exit": 1,
"timeout": false,
"counts": {
"tests": 1,
"pass": 0,
"fail": 1,
"cancelled": 0,
"skipped": 0
},
"command": [
"node",
"--test",
"--test-reporter=tap",
"--test-name-pattern=row 280: an occupied TCP port",
"packages/bus/tests/grant-executor.test.mjs"
]
},
{
"step": "M1-host-run",
"start": "2026-10-22T07:26:25Z",
"end": "2026-10-22T07:26:26Z",
"seconds": 0.146,
"exit": 1,
"timeout": false,
"counts": {
"tests": 1,
"pass": 0,
"fail": 1,
"cancelled": 0,
"skipped": 0
},
"command": [
"node",
"--test",
"--test-reporter=tap",
"--test-name-pattern=row 280: an occupied TCP port",
"packages/bus/tests/host-run.test.mjs"
]
},
{
"step": "M2-grant-executor",
"start": "2026-10-22T07:26:26Z",
"end": "2026-10-22T07:26:26Z",
"seconds": 0.095,
"exit": 1,
"timeout": false,
"counts": {
"tests": 1,
"pass": 0,
"fail": 1,
"cancelled": 0,
"skipped": 0
},
"command": [
"node",
"--test",
"--test-reporter=tap",
"--test-name-pattern=row 280: every injected picker sample",
"packages/bus/tests/grant-executor.test.mjs"
]
},
{
"step": "M2-host-run",
"start": "2026-10-22T07:26:26Z",
"end": "2026-10-22T07:26:26Z",
"seconds": 0.128,
"exit": 1,
"timeout": false,
"counts": {
"tests": 1,
"pass": 0,
"fail": 1,
"cancelled": 0,
"skipped": 0
},
"command": [
"node",
"--test",
"--test-reporter=tap",
"--test-name-pattern=row 280: every injected picker sample",
"packages/bus/tests/host-run.test.mjs"
]
},
{
"step": "M1-final-grant-executor",
"start": "2026-10-22T07:48:56Z",
"end": "2026-10-22T07:48:57Z",
"seconds": 0.12,
"exit": 1,
"timeout": false,
"counts": {
"tests": 1,
"pass": 0,
"fail": 1,
"cancelled": 0,
"skipped": 0
},
"command": [
"node",
"--test",
"--test-reporter=tap",
"--test-name-pattern=row 280: an occupied TCP port",
"packages/bus/tests/grant-executor.test.mjs"
]
},
{
"step": "M1-final-host-run",
"start": "2026-10-22T07:48:57Z",
"end": "2026-10-22T07:48:57Z",
"seconds": 0.164,
"exit": 1,
"timeout": false,
"counts": {
"tests": 1,
"pass": 0,
"fail": 1,
"cancelled": 0,
"skipped": 0
},
"command": [
"node",
"--test",
"--test-reporter=tap",
"--test-name-pattern=row 280: an occupied TCP port",
"packages/bus/tests/host-run.test.mjs"
]
},
{
"step": "M2-final-grant-executor",
"start": "2026-10-22T07:48:57Z",
"end": "2026-10-22T07:48:57Z",
"seconds": 0.108,
"exit": 1,
"timeout": false,
"counts": {
"tests": 1,
"pass": 0,
"fail": 1,
"cancelled": 0,
"skipped": 0
},
"command": [
"node",
"--test",
"--test-reporter=tap",
"--test-name-pattern=row 280: every injected picker sample",
"packages/bus/tests/grant-executor.test.mjs"
]
},
{
"step": "M2-final-host-run",
"start": "2026-10-22T07:48:57Z",
"end": "2026-10-22T07:48:57Z",
"seconds": 0.138,
"exit": 1,
"timeout": false,
"counts": {
"tests": 1,
"pass": 0,
"fail": 1,
"cancelled": 0,
"skipped": 0
},
"command": [
"node",
"--test",
"--test-reporter=tap",
"--test-name-pattern=row 280: every injected picker sample",
"packages/bus/tests/host-run.test.mjs"
]
}
]
@@ -0,0 +1,4 @@
{
"binary_files": [],
"secret_matches": []
}
+1
View File
@@ -0,0 +1 @@
None. All 60 repeat files and both complete CLI load cycles report skipped 0.
@@ -0,0 +1 @@
[]
@@ -0,0 +1,50 @@
// Sage, 2026-10-22: row 191 (kill switch) landed, comment on P19 414 (Q174 landing comment). 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-r191-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: 414, title: /^MVP platform: the Given list for Cases 1 and 2/, body: [
"Row 191 (kill switch, stopRun) landed on refactor as 0fa7bf421 on 10-22 (recorded 04:49Z), a fast-forward on 86f95bb27. An owner can halt an instance: agent verbs that start or continue work refuse with a fixed code, reads and the human's own verbs (including the clear) still work, and running runs are stopped. Gitea #1689 is closed (comment 30360). Queue row 191 is done (rev 3120).",
"Rocko built it, and Dewey approved the manifest (88 paths, digest 0bcbf337, comment 30359, rev 3118). Gate on 0fa7bf421: 35 touched files whole, every package suite, the ten scripts/test-*.sh, and 77/77 mutants checked. Sage ran the runs suite 43/0/0 for Dewey's N7. Follow-ups are on Vikunja 502 (real clock, exact HTTP refusals, runtime close in finally, runner cause text).",
"It isn't live yet. Live is still 86f95bb27. The switch reaches dragon-lin at the next re-pin, through the window plan and Mos.",
] },
];
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,61 @@
// Sage, 2026-10-22: files the 10-27 owner thread-claim row (mos-1262) as a P19 task. No relation, labels, assignees,
// due dates or bucket moves. Sage's own key (bot user 13); the sync key reads back. Refuses if the title exists.
// Prints no secret.
// Usage: node file-thread-claims.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON ROW ISSUE
import { readFileSync, writeFileSync, existsSync } from "node:fs";
const [ORIGIN, KEY, SYNC, OUT, ROW, ISSUE] = process.argv.slice(2);
if (!/^[0-9]+$/.test(ROW ?? "") || !/^[0-9]+$/.test(ISSUE ?? "")) throw new Error("ROW and ISSUE must be numbers");
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: `The owner sees a thread agent's claims: agents lists held thread claims (row ${ROW}, #${ISSUE}, 10-27)`,
description: ps(SIG, [
"Filed by Mos's ruling mos-1262 as a 10-27 row, not post-v1. Row 205's message filter found it (morgana-0072, Sage ruling sage-2430).",
"A thread run's role.claim writes thread_claims only (broker.mjs:2682), and the owner's agents verb reads role_claims only (:3309). So the owner can't see a thread agent's claim, which PRD :1083-1084 and the FED R6 user sentence promise. No user thread goes live before this row lands, the same bind as the 193b1a filter. Q176 Case 1 needs it.",
`Queue row ${ROW}, after row 205, owner Morgana, reviewer Duckworth, gate Sage. Brief: docs/plans/2026-10-22_owner-thread-claims.md. If it can't make 10-27, that's a scope question for Jason, and it goes to Mos first.`,
]),
},
];
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,69 @@
// Sage, 2026-10-22: re-pin window 7247d80c7 to 86f95bb27 done (mos-1253, mos-1263). Comments on P19 386 and 435 (Mos posted 412 himself, mos-1266),
// text from Darkwing (darkwing-0160). No task is closed. Sage's own key (bot user 13); the sync key reads back.
// Checks each task's title before anything is posted. Prints no secret.
// Usage: node post-w12-86f95bb27.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). Text from Darkwing, the window operator.";
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 WIN = "agents/darkwing/work/window-repin-86f95bb27/";
const POSTS = [
{ id: 386, title: /^Q173 test system/, close: false, body: [
"Re-pin 7247d80c7 to 86f95bb27 done on dragon-lin, 2026-10-22, with Darkwing operating. Activation 20261022T040256Z-86f95bb27f6c, pointer at 04:03:00Z UTC, schema 3j with no migration. P1 to P6, B0, W0 to W5 and W9 to W11 passed. Nothing was rolled back, and there's no counted failure (mos-1260). W5 backup SHA256SUMS 45b18429.",
"S1 to S2 (03:55:33Z to 04:24:42Z UTC): schema 3j at both, events 1798 to 1817 with no grant.* kind, and hosts and host_grants at 0 rows.",
"W12 (w12c.sh, mos-1263) W12_PASS at 04:26:43Z. Backup w12-20261022T042555Z first (SHA256SUMS 210ce775). The chief relayed the pm read and launched pm r2cf8536da2c3, which answered PRD 1.14 with 85 ids. Row 66 on chief rd0b8fdb4d62d: turn 1 drew 4 banned words and the [unslop: note, turn 2 got the notice, and counts went from 1/0/0 to checked 6, flagged 1, notices 1, broken null.",
`STATE: ${WIN}STATE.md`,
] },
{ id: 435, title: /^Console inbox: Approve\/Deny/, close: false, body: [
"Re-pin to 86f95bb27 (activation 20261022T040256Z-86f95bb27f6c). Row 202's access verbs are live, and nobody created a grant. The bus journal since W5 has no grant.* event, and hosts and host_grants hold 0 rows.",
`STATE: ${WIN}STATE.md`,
] },
];
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: [], closes: [], 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 before = {};
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`);
if (P.close && t.json.done) throw new Error(`task ${P.id} is already done; nothing posted`);
before[P.id] = { title: t.json.title, description: t.json.description };
}
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(); continue; }
if (P.close) {
const full = await api("GET", `/api/v1/tasks/${P.id}`, undefined, key);
if (full.status !== 200 || full.json?.id !== P.id) { rec.problems.push(`v1 get ${P.id}: ${full.status}`); save(); continue; }
const u = await api("POST", `/api/v1/tasks/${P.id}`, { ...full.json, done: true }, key);
const b2 = await api("GET", `/api/v2/tasks/${P.id}`, undefined, sync);
const x = { task: P.id, status: u.status, readBack: b2.status, done: b2.json?.done, titleSame: b2.json?.title === before[P.id].title, descriptionSame: b2.json?.description === before[P.id].description, inverse: `mark undone: POST /api/v1/tasks/${P.id} done false (sage key)` };
rec.closes.push(x);
if (u.status >= 300 || b2.status !== 200 || b2.json?.done !== true || !x.titleSame || !x.descriptionSame) rec.problems.push(`close ${P.id}: update or read-back differs`);
}
save();
}
save();
process.stdout.write(JSON.stringify(rec, null, 1) + "\n");
if (rec.problems.length) process.exit(2);
@@ -0,0 +1,118 @@
# Re-pin 7247d80c7 to 86f95bb27 (rows 251, 244, 254, 202, 273, PRD 1.11-1.14), no migration
Sage, 2026-10-22, for mos-1253 under DECISIONS Q173 and mos-1246. The step layout is
`repin-1024-draft1.md`, with the copy changed and `--migrate` dropped. Where a step is unchanged I name it
and say only what differs. Mos checks this file's sha256 before P0, and any change after that means a new
check (mos-0849/0850). This file stays uncommitted until row 191's window ends, because refactor doesn't
move while a rebased candidate's gate runs.
## Who, and what nobody does
- Darkwing operates if free. Otherwise I operate. Mos verifies each step's output before the next one,
runs P3, and gives the W5 go. Row 191's full window keeps first claim on the slot (mos-1225). Steps that
start no test and no stub run alongside it: P1, W0, P4, P5, P6, B0, W1-W4. P2 waits for a gap or CLEAR,
because it starts node stubs that a busy check counts.
- Jason has no step. Nothing is pruned. Nobody acts as Jason: no `--by jason`, no human shell or tmux
pane, every bus read and write carries `--tester`, and `bus.tester` stays as it is. If it isn't `true`,
stop at P1.
- No pi (#383). No Save in the human Console, no `mosaic name`.
- Outside reach doesn't change. No `--send`, no `--sso`, no proof pair. Both go on together in the phase 2
window, which still waits for Jason's start (Vikunja 412). The tunnel forwards 7332 only.
- dragon-lin only. .53 and .54 take nothing (mos-1198, mos-1167).
## Names
- OLD, live: `7247d80c7af7ffa70fc61557406bf491cdf3bfc7`, schema 3j, pointer `pm: true`, activation
`20261021T215926Z-7247d80c7af7`. Console flags at W5 last time: `--port 7332 --public-host
mosaic.woltje.com --auth-header X-authentik-username --allow-user jason.woltje --tester-socket`. P1
re-reads them from `status`. I don't copy them from this file.
- NEW: `86f95bb27f6cb237bf9fa9ba53320bace6c4de0b`, 59 commits past OLD (`git rev-list --count`). The last
code landing in it is 0bbf0f6d1 (row 273). The two commits after that are docs and queue files.
- `WIN=agents/darkwing/work/window-repin-86f95bb27/` if Darkwing operates, else
`agents/sage/work/window-repin-86f95bb27/`. The scripts adapt last window's, in
`agents/sage/work/window-repin-7247d80c7/`, with NEW and OLD changed and every `--migrate` removed.
## What NEW carries over OLD
Non-test paths changed from OLD to NEW (`git diff --name-only`, tests and READMEs left out):
| Row | Commit | Paths | Live effect to check |
|---|---|---|---|
| 251, 244 | d37210288, e69260a3c | scripts/test-release.sh, test-task.sh, scripts/testing/* | Gate tooling. Not in the live path. |
| PRD 1.11-1.14 | ea0b2a16d..db1e7732a | packages/tasks/requirements.json | W9: the pm's requirements read gives 1.14 with 85 ids. |
| 254 | 331597709 | packages/bus/src/human-cli.mjs (library.sync client timeout 130 s), packages/webui/src/bus.mjs (six `library-*` STATUS codes) | No library entry is live, so neither path runs. |
| 202 | ddfb29af7 | packages/cli/src/access.mjs, cli.mjs, scripts/mosaic | `mosaic access list/grant/revoke`. Nobody runs them in this window (Q188, mos-1141). |
| 273 | 7991662dc..0bbf0f6d1 | scripts/stack-release.sh | `--send` and `--no-sso`. With neither given, the plan keeps the pointer's Console flags. W3 checks that. |
Nothing else in packages/bus/src or packages/webui/src changed (`git diff --quiet` with those two files left
out). The four service scripts and scripts/stack-copy.sh are unchanged from OLD (`git diff --quiet 7247d80c7
86f95bb27 --` on them). package-lock.json is unchanged.
## Schema and backups
- `SCHEMA_VERSION` is 3j at both commits (`packages/bus/src/store.mjs:17`). No migration step and no
schema.sql change in the range. W3's plan must show no migration line. If it asks for `--migrate`, stop.
- B0 is the backup taken first. It's an online copy of the live store through sqlite's backup API, read only
on the source, as in last window's W12. It is laid out the way `rehearse --backup PATH` reads it
(`stack-release.sh:2232-2235`): `$DATA/backups/b0-<STAMP>/bus/bus.sqlite`, `SHA256SUMS` written from
inside `bus/`, `SCHEMA` reading 3j, and `CHECK` holding the integrity_check result, the event count and
maxSeq. The operator sends Mos the path and the SHA256SUMS sha256 before P3.
- W5 takes the stopped-store backup every activate takes, plus the rollback set. That one is the rollback
point.
- The grant look-alike count from draft 1 is dropped. It guarded the 3i-3j migration, and there is none.
- Rollback is a pointer flip. OLD and NEW share 3j, so `rollback --plan` then the real run, with no
`--restore`. Any restore goes to Mos and ends the window (mos-1226, mos-1253 item 5).
## Before the window (nothing stopped)
| # | Who | Step |
|---|-----|------|
| P0 | Mos | The sha check of NEW and of this file. |
| P1 | operator | `status`: pointer, activation, 3j, Console flags, unit flags match. Business file sha256 and `business-check.mjs` LOAD_OK for OLD now and for NEW after W0. `ls -ld $DATA/console` is 0700 and `tester.sock` is 0600 (row 196 made them at the last W5). `bus.tester` is `true`. |
| W0 | operator | `scripts/stack-copy.sh copy $NEW`. The copy has `tools/unslop-hook` and `packages/library/bin/askpass` with NEW's mode. |
| P2 | operator, in a gap | The two stub runs from last window's p2.sh against NEW's copy, then render both units from REPO's service scripts and diff them against the live units. The only difference allowed is the copy path. |
| B0 | operator | The backup above. Path and SHA256SUMS sha256 to Mos. |
| P3 | Mos | `stack-release.sh rehearse $NEW mosaic-stack --backup <B0 path> --port <scratch>`, no `--migrate`. It must show no migration, the interpreter preflight passing, one receipt line, no `library:` line, and one pm reply. Mos also reads healthz, `/api/config` and the two tester-http reads on NEW's Console with the pointer's flags, as in mos-1107. |
| P4 | operator | The tunnel read. |
| P5 | operator | The four service scripts held at NEW, and empty porcelain on them. |
| P6 | operator | The 7332 matrix (a)-(g) on OLD as `$WIN/matrix-old.txt`, plus the two tester-http reads, for W10. |
## The window
| # | Who | Step |
|---|-----|------|
| W0b | operator | The business file copy. |
| W1 | operator | Idle check, start stamp from `date -u`, S1 with `$SNAP --held`. Open pm tasks: the pm finishes or parks them, or the operator records which piece crosses the window. |
| W2 | operator | `status`: pointer on OLD, `pm: true`, 3j, unit flags match. |
| W3 | operator | `stack-release.sh activate $NEW mosaic-stack --pm --tester-socket --plan`. It must show OLD to NEW, no migration, the backup line, and a Console line with OLD's flags exactly: `--tester-socket`, no `--send`, no `--sso`, no proof pair, no drop line. Any other difference stops here. |
| W4 | operator | The unit reads, as last window. W3 and W4 go to Mos for the W5 go. |
| W5 | operator, on Mos's go | The same command without `--plan`. Exit 0, `result ok`, every gate check passing, `pm_launched` included. Record the activation id, backup path, STAMP and the backup SHA256SUMS sha256. Then `status`. |
| W9 | operator, after Mos verifies W5 | Last window's W9 list: no `Sign-in:` line in the webui journal since the W5 stamp (with `UTC`), `/proc/<Console pid>/cwd` names NEW's copy, `tester.sock` 0600 in a 0700 `console/`, one new `peercred-starts.jsonl` line, no `library:` line in the bus journal. The pm's requirements read gives 1.14 with 85 ids. If the pm isn't relaunched by the restart (mos-1230), the read moves to W12, as last time. |
| W10 | operator | The matrix again as `$WIN/matrix-new.txt`, identical to `matrix-old.txt` apart from named lines. The two tester-http reads match P6's. No listener on 7333 (`ss -ltn`). P4 again. A Console POST on the main port with no signed-in session is refused (the mos-1195 baseline). |
| W11 | operator | STATE report in `$WIN/STATE.md`, posted on P19 386, 412 and 435, and on the Gitea issues for 202 (#1700), 254 (#1759) and 273 (#1778). |
| W12 | operator, after W11 | Row 66's live check as last time: backup first, one Claude Code managed session with a known tell, the RESULT note, the next-turn notice and the `unslop.jsonl` counts. Pi isn't run (#383). |
After W12 I write draft 3 of the phase 2 plan naming 86f95bb27. That window still waits for Jason.
## Steps skipped, and why
Mos allowed skipping a step that only repeats a test row 273's or row 202's gate already ran on the same
code. The blob ids at the gated commit and at NEW are equal for every one of those files (`git rev-parse`):
scripts/stack-release.sh `7c7676c71` at 0bbf0f6d1 and NEW, and access.mjs `a0a7c9364`, cli.mjs `b14b8fd79`,
scripts/mosaic `91c79ab4b` and human-cli.mjs `27b67fbf7` at ddfb29af7 and NEW.
- No separate run of the `--send`/`--no-sso` walk or the rollback flag carry. Darkwing's gate on 0bbf0f6d1
ran stack-release.test.mjs 218/218 (T53, T57, T58 c0/c1) and test-release.sh rc 0. W3's live plan line is
the only check left.
- No live `mosaic access` run. Row 202's gate ran access.test.mjs on ddfb29af7. A live grant would also
break Q188.
- The grant look-alike count, for the reason under Schema. It's a scope drop, not a gate repeat.
Nothing else is skipped. P2 and P3 aren't gate repeats: they check NEW's copy as built on dragon-lin.
## Stop conditions and rollback
As in draft 1. The first failure stops the step and goes to Mos. A second failure, or any restore, ends
the window (mos-0823, mos-1253 item 5). Before W5 there's nothing to undo. After W5, rollback is
`rollback --plan` then the real run, which flips the pointer to OLD with the same Console flags. Keep every
copy. Prune nothing.
+2
View File
@@ -38,6 +38,8 @@ services:
volumes:
# Configured runtime state root (from config.json dataRoot).
- ${MOSAIC_DATA_ROOT:?MOSAIC_DATA_ROOT must be set by scripts/load_config (run via scripts/*.sh)}:/var/lib/mosaic
# The whole control directory is protected against unlink and rename-over replacement.
- ${MOSAIC_DATA_ROOT:?MOSAIC_DATA_ROOT must be set by scripts/load_config (run via scripts/*.sh)}/control:/var/lib/mosaic/control:ro
# Runtime credential only: pi auth file mounted READ-ONLY.
# Never copied into the image.
- ${PI_AUTH_FILE:-/home/jwoltje/.pi/agent/auth.json}:/home/node/.pi/agent/auth.json:ro
+61
View File
@@ -1455,4 +1455,65 @@ are never rewritten or removed; corrections are new entries.
2026-10-21T22:30:20Z | Huey (gpt-6.1-sol) | T1 item 7 r3, scrooge-0007 R1, Vikunja 463 | isolated session-workspaces root and nesting mutant recorded; R1-only confirmation requested, other r2 findings closed; doc-only checks
2026-10-21 | Ludwig | PRD 1.11-1.14 drafts (Vikunja 459, 462, 463, 464) | approved by Della, handed to Sage
2026-10-21T22:51:07Z | Sage (Opus 5.5) | PRD 1.13 and 1.14 landing with Gosalyn's C2-01c brief and Case 2 design (51fdfb7e2), rows 264-268 filed (#1769-#1773), Case 2 re-pin to a6ac627c8, Vikunja 463 closed | pushed 6ba8a582e..db1e7732a; requirements tests 3/3 and 3/3 and verbs 31/31 on 5f73b388c and db1e7732a
2026-10-21 23:39:21 UTC | Rocko (d99326cc) | row 191 inspection handoff, sage-2242 | authorized local snapshot d52b4db1c on new rocko/row191-inspection-20261021; 78 blobs match a4f780c2; no push credential, no push or tests; Sage/Honker messaged directly; per-run API seam and mutants/gate pending.
2026-10-22 00:04:05 UTC | Rocko (d99326cc) | row 191 stopRun focused stage and S6 handoff | 9 files final 62/62; two bus failures diagnosed/fixed and logged, own failed fixture group cleaned; no runners remain, slot returned. Export in f7bf7dfec, rebased on new branch to 7aa18b0f6 (dcd8d4dda), 79 product blobs identical. Honker/Sage handed the sha directly; broader gate/mutants pending, no live action.
2026-10-22T00:18:12Z | Sage (Opus 5.5) | row 254 landing, rows 269-272 (264b-264e, #1774-#1777), 268 r2 request, row 191 publish and gate slot | 331597709 and eb9672c91 landed, queue e3c0446f5 pushed; rocko/row191-stoprun-dcd8-20261022 7aa18b0f6 and 81889b58d published as jarvis; 191 full gate on Rocko's slot from 00:17Z
2026-10-22 00:24:34 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 full gate on c78f48946 | stopped on second integration failure per sage-2275; singles 269/269, processing-control 61/61, bus 646/646; CLI interrupted with 26 observed failures; no further edits; cwd sweep empty and slot returned to Sage.
2026-10-22 00:48:20 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 eb3abba12 full gate | stopped at Discord 190/192; 32 singles 304/304, processing-control 61/61, bus 646/646, cli 761/761; two uninitialized Discord fixtures reported to Sage; no later run; cwd sweep empty, slot clear.
2026-10-22 00:58:27 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 sage-2294 fixture fix | e6800380d on eb3abba12, two test files/four additions, assertions preserved; 338-entry static all-package sweep recorded; no tests; sent Sage for publication and Honker for dependent gate.
2026-10-22T01:04:44Z | Dewey (Opus 5.5, 84fb346c) | FED R3 early look, e7d2dc3e6 (morgana-0065) | static only, no queue record; mutant rulings: M3x stays plus reply-chain under M3x, #threadFor user clause equivalent, 193b1a filter mutant n/a; N1 role key user to fix in R3 (:359 and instance-settings:90), Sage rules on widening; dewey-0777 to Morgana, dewey-0778 to Sage
2026-10-22T01:08:21Z | Dewey (Opus 5.5, 84fb346c) | FED R3 r2 62de33760 delta (morgana-0066) | static, no findings; N1 fixed at broker :361 and instance-settings :91 with tests; Sage rules instance-settings scope; dewey-0779 Morgana, dewey-0780 Sage
2026-10-22 01:30:04 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 e6800380d resume | binding15/15, recover5/5, Discord192/192; stopped WebUI314 passed/4 failed/1 esbuild skip; processing polling violates sign-out/manual redirect and bar changes layout; no fixes/retries, cwd sweep empty, slot returned.
2026-10-22 01:38:08 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 sage-2317 Console integration fix | 8e1f55c1d on e6800380d; shared sign-out/manual redirect/interval and desktop frame fixes, assertions retained, six browser cases, 73 mutant sites preflight; syntax only, no tests; sent Sage/Honker.
2026-10-22 02:06:38 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 rebase sage-2329 | eafcbd3e7 on ddfb29af7, new branch, dispatcher retains processing and access; CLI/readme auto-merges checked; 87-path fd0f2740 manifest and 73-site preflight; Sage2333 published, awaiting CLEAR; no tests.
2026-10-22 02:20:51 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 eafcbd3e7 full gate | stopped singles313/6: six new browser cases count app freshness timer as processing timer; diagnosis sent Sage, no fix/retry; no suites/shells/mutants; cwd sweep empty, slot clear.
2026-10-22 02:23:13 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 sage-2345 timer fixture | 4db8b5778 on eafcbd3e7, one line attributes timer to processing.js; all assertions retained, no product change or tests; sent Sage and Honker; awaiting publication/CLEAR.
2026-10-22 02:25:29 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 sage-2349 record commit | 0f8235277 on 4db8b5778 adds packet BUILD.md cause record; product manifest remains ffb46fbd/87 paths; no amend, product change or tests; Sage/Honker notified.
2026-10-22 02:48:36 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 0f8235277 full gate second red | singles330/330, processing61/61, bus646/646, cli783/783, discord192/192; WebUI323/1/1 host esbuild skip; shell geometry1062vs1000; stopped/no retry, no later suites/shells/mutants; cwd sweep empty, slot returned, Sage escalates Mos.
2026-10-22 02:57:36 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 mos-1248 desktop layout restart | FF 5cd32f4c1 on 0f8235277: desktop grid allocates processing bar, existing shell assertions kept, visibility/no-scroll assertions added; 74 static mutant sites; candidate 2f93ef19; sent Webby/Sage for static review; no runs, awaiting review and CLEAR.
2026-10-22T02:58:24Z | Dewey (Opus 5.5, 84fb346c) | row 236 (#1742) early static look at 8bf442be9 (megavolt-0112) | B1 step 7 revoke undone by step 8 write, B2 sweep fire retry skips D1 id check, N1-N4; dewey-0781 Megavolt, dewey-0782 Sage; no queue verdict
2026-10-22 03:01:02 UTC | Rocko (d99326cc) | row 191 Webby static review | B1 accepted: 5cd32f4c1 preserves the content floor; earlier root-cause claim withdrawn in BUILD.md; no runs; requesting fixture measurement slot and owner layout ruling.
2026-10-22T03:08:58Z | Dewey (Opus 5.5, 84fb346c) | row 236 (#1742) r2 77dd43c9e delta look (megavolt-0115) | B1 and B2 closed by reading; N5 (settle unknown/401 branches unguarded) not blocking; N1-N4 DEFERRED agreed with README lines; dewey-0783 Megavolt, dewey-0784 Sage; no queue verdict
2026-10-22T03:10:55Z | Dewey (Opus 5.5, 84fb346c) | row 236 (#1742) r3 7965efd0f delta look (megavolt-0119) | N5 closed by reading; r3 is the run candidate; dewey-0785 Megavolt, dewey-0786 Sage; no queue verdict
2026-10-22 03:11:09 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 static nav layout, mos-1249/1250 | FF da4334806 on 5cd32f4c1; controls first in nav, non-sticky per sage-2386/webby-0108; candidate-only placement assertion changed, landed shell assertions preserved; 77 mutants statically preflighted; manifest 245dceb1; Webby/Sage handoff sent, no runs, awaiting static look/publish/debug CLEAR.
2026-10-22T03:11:36Z | Dewey (Opus 5.5, 84fb346c) | row 236 (#1742) r4 1c0048911 static look (sage-2390) | README N3/N4 sentences match the rulings; N5 test split per branch, same asserts; r4 is the run candidate; dewey-0787 Sage, dewey-0788 Megavolt
2026-10-22 03:15:11 UTC | Rocko (d99326cc) | row 191 sage-2394 debug | da4334806 shell5/5 and processing-browser9/9, awk14/0/0; geometry heights reported with read-only distinction; cwd sweep[] and slot returned; full window awaits separate CLEAR.
2026-10-22T03:36:37Z | Filbert (Opus 5.5, sage-2398) | row 273 (#1778) static review, darkwing/row273 978143bf4 to cac7d0f67, carried to 0bbf0f6d1 | APPROVE c30308, queue rev 3111 (filbert-273-record-r1), digest e1031023 rebuilt and matched; B1/N1 fixed, T57 item 3 change approved, N1 TOOLS.md by Sage at landing. Posted before sage-2396 hold; my drift check missed row 202 scripts/mosaic (test-copied). Merge-scoped carry c30319 on 0bbf0f6d1: range-diff 6/6 =, patch-ids equal; rerun green per darkwing-0147 (not mine). Line left uncommitted: HEAD 378a76b85 behind origin 0bbf0f6d1.
2026-10-22T03:42:29Z | Darkwing (claude-opus-5-5) | row 273 (#1778) activate --send/--no-sso, sage-2301/mos-1246 F39 | LANDED 0bbf0f6d1 (docs 5365adaef, done rev 3113, #1778 closed c30324); 978143bf4 run red (stale T53 usage pin, fixed cac7d0f67), cac7d0f67 green on old base, rebased darkwing/row273-b2 (range-diff all =, digest e1031023) green 218/218, 25/25, 14/0, M1-M7 killed; in-review rev 3109/3110 c30307; TOOLS.md F39 lines match darkwing-0137
2026-10-22 04:02:12 UTC | Rocko (d99326cc) | row191 full window0fa7bf421 | touched35files336pass, packages2403pass/1esbuildskip, shells425selftests+156queue node pass; SR01–07killed, SR08timeout,69unrun; stopped/no retry;88blobs restored,cwd sweep[],owned network removed,slot returned Sage; await Mos ruling.
2026-10-22T04:11:48Z | Dewey (Opus 5.5, 84fb346c) | row 191 runner review (sage-2424, mos-1259 item 3): run-mutants.py fe0d360f static | APPROVE to Rocko dewey-0789 and Sage dewey-0790; N1-N5 non-blocking (placeholder timeout cause, 7+1+69 tally, indented not-ok match, SR08 time binding, resume-only invocation)
2026-10-22 04:14:56 UTC | Rocko (d99326cc) | row191 control/resume sage-2427 | control12/12;SR08separate timeoutkill;49resumed assertionkills thenKS43survived;total56assertionkills+1timeout,1survivor,19unrun;88blobs restored,cwd sweep[],slot returned,Sage/Mos informed;no retry/edit.
2026-10-22T04:20:27Z | Dewey (Opus 5.5, 84fb346c) | row 191 mos-1261 mutant table review: all-mutants.json 5dbc8308 KS43 pattern retarget + 19-site audit, static | APPROVE to Rocko dewey-0791 and Sage dewey-0792; C1 archive resume-mutant-results.json (f5725ef2) and KS43.tap (c778ee33) before a --resume-from KS43 run
2026-10-22 04:21:30 UTC | Rocko (d99326cc) | row191 KS43+19 retarget run | KS43assertionkill thenKS44invalid mutation syntax;57assertionkills+1timeout,1invalid,18unrun;stopped,no edit/retry;88hashes restored,cwd sweep[],slot returned,Sage/Mos notified.
2026-10-22T04:25:19Z | Dewey (Opus 5.5, 84fb346c) | row 191 mos-1264 KS44 table fix (85d9a7b1, // -> void 0) + 19-site syntax preflight, static; also C1 archive check (dewey-0793/0794) | APPROVE to Rocko dewey-0795 and Sage dewey-0796; run condition: new subdir with byte copies, --resume-from KS44 (19 sites); tally 57 kills + SR08
2026-10-22 04:30:06 UTC | Rocko (d99326cc) | row191 KS44+18 resume | 10assertionkills thenKS54timeout-without-control despite dependency assertion;total67assertionkills+1timeoutkill,1unqualifiedtimeout,8unrun;88hashes restored,cwd sweep[],slot returned,Sage/Mos notified,no retry/edit.
2026-10-22T04:38:59Z | Dewey (Opus 5.5, 84fb346c) | row 191 final mutant evidence check (control-ks54-mos1267, syntaxfixed-mos1264), static | tally 75 assertion + 2 timeout kills = 77 confirmed (dewey-0798 Rocko, dewey-0799 Sage); N6 tasks.test.mjs:262-266 runtime not closed on failure, Sage rules
2026-10-22 04:41:46 UTC | Rocko (Codex gpt-6.1-sol,d99326cc) | row191 #1689 builder complete/in-review | published0fa7bf421,manifest0bcbf337;35singles336pass,9packages2403pass/1knownskip,10shells425selftests+156queue nodepass;75assertionkills+2controlledtimeoutkills=77;88hashrestore,cwd sweep[],slotreturned;in-review rev3114,expected no-token failure3115(exit1);Sage/Dewey handoff,no live action.
2026-10-22T04:46:51Z | Dewey (Opus 5.5, 84fb346c) | row 191 manifest review round 1 (#1689, candidate 0bcbf337, source 0fa7bf421 on 86f95bb27) | APPROVE comment 30359, queue rev 3118; manifest, gate logs (2739/0/1 named), 77 mutants and 712-file packet checked; N6 carried to Vikunja 502; N7/N8 non-blocking; dewey-0800 Sage, dewey-0801 Rocko
2026-10-22T04:52:40Z | Dewey (Opus 5.5, 84fb346c) | row 236 static look at rebase 08840f3c1 on 0fa7bf421 (#1742) | clean, not a verdict: commit 1 only the three 191 union lines, 2-6 =, commit 7 grants.status read with a test; manifest 32 paths digest 1e40433b matches; dewey-0802 Megavolt, dewey-0803 Sage
2026-10-22 05:03:18 UTC | Rocko (Codex gpt-6.1-sol,d99326cc) | Vikunja502 processing test/runner hardening | brief+isolatedrocko-v502;expected wordingred→green,Node27/27,Python4/4,5/5assertionkills(includingSR08andKS54nohang),0skips/timeouts;sweep[],candidate5hashrestored,slotreturned05:01:29Z;candidate968d6ee3,runnerc5bdc764;packetready,no commit/push/liveaction.
2026-10-22 05:07:34 UTC | Rocko (d99326cc) | Vikunja502 committed/review handoff sage-2482 | newbranchrocko/vikunja502-ce6a-20261022,source8e3c6a79b+packet9bf4683c6 on ce6a96ad6;5paths no base drift,range-diff=,patch-id37135ebe unchanged;manifest968d6ee3,packet93889519 verifies39/39;focused runs carry;Sage publish/Dewey review handoff sent,no push/liveaction.
2026-10-22 05:10:00 UTC | Sage (Opus 5.5, 1ef1e4f8) | landings 191, 205, 268 (MVP 10-27) | 191 LANDED 0fa7bf421 (P19 414 c595); 205 LANDED ce6a96ad6 (gate GATE_PASSED 04:56:43Z, Duckworth APPROVE rev 3125, done rev 3127, #1703 closed c30379); 268 r4 LANDED c27207f6e as a carry on 205 (rerun 14/83/209, Webby 0112/0113, done rev 3129, #1773 closed c30384); doc commit adds the #1782 brief, re-pin plan, phase 2 draft 3
2026-10-22T05:11:59Z | Dewey (Opus 5.5, 84fb346c) | Vikunja 502 (191 follow-up, Rocko) static review: source 8e3c6a79b, packet 9bf4683c6, manifest 968d6ee3, runner c5bdc764 vs fe0d360f | APPROVE; N6 finally-close, exact refusal statuses, truthful no-control cause checked; 5/5 assertion kills; dewey-0804 Sage, dewey-0805 Rocko
2026-10-22T05:11:59Z | Dewey (Opus 5.5, 84fb346c) | row 274 (FED R3, #1779) rebase look at 226e1f63d on ce6a96ad6 | not a verdict: range-diff ! only at 191 meets, manifest 7d9c9d9b rebuilt and matches; drift to b3665f5ab (268 library) and halt-flag test to Sage; dewey-0806 Morgana, dewey-0807 Sage
2026-10-22 05:58:59 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 279 #1784 stopRun deadline | candidate 1016b8584, packet cad927137 on f40f2e770; expected unknown/alive red, stop-run 13/13, package 62/62, M1 killed, 0 skips, sweep clear; Sage publishes, Dewey reviews.
2026-10-22T06:04:20Z | Dewey (Opus 5.5, 84fb346c) | row 279 (#1784, Rocko) stopRun deadline fix, manifest review r1 on cad927137 (312ed511, 2 paths) | APPROVE, Gitea c30409, queue rev 3143 op dewey-279-r1-verdict; N1 run-before-commit (Sage gate binds), N2 discord processing tests (Sage added to gate); dewey-0809 Sage, dewey-0810 Rocko
2026-10-22T06:13:54Z | Dewey (Opus 5.5, 84fb346c) | row 279 (#1784) carry to ceadad993 (sage-2520) | APPROVE carried: range-diff 1:= 2:=, patch-ids match, product blobs equal, manifest 312ed511 ok; posted c30410
2026-10-22 06:15:54 UTC | Sage (Opus 5.5, 1ef1e4f8) | row 279 stopRun deadline (mos-1269) | LANDED ceadad993 06:13:49Z, carry on 343b6dd76, Dewey c30409 + carry c30410, done rev 3145; 194 b16 9cdc47d04 (Webby carry webby-0118) has the slot for runb8 + gate.sh, new window
2026-10-22 07:17:54 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 280 #1785 scratch sshd port ownership | claimed rev 3148 on da5d3651d; proposed test-only shared readiness/picker and X5 cleanup in queue-280/BUILD.md, sent Sage and Duckworth before code; no tests run, CLEAR requested.
2026-10-22 07:51:04 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 280 #1785 scratch sshd owns port | source b765f5cb8, packet d6c924764; grant 30/30 runs1230/1230, host30/30 runs780/780, CLI load1588/1588, no skips/timeouts, final M1/M2 four assertion kills; source restored and slot clear; no live action.
2026-10-22 07:56:59 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 280 slim packet under sage-2541 | b0fd28b6f on rocko/row280-packet-b765, parent b765f5cb8; 22 text files, 21-entry slim inventory914f888d, full evidence d6c924764 retained, portability/follow-ups recorded; no tests or source changes.
2026-10-22 08:00:02 UTC | Sage (1ef1e4f8) | row 280 review, gate and landing | design approve sage-2533; CLEARs for phase 1, diagnostic and repeat; published d6c924764 as jarvis; review request rev 3151/3152; gate bus 656/0/0; landed b0fd28b6f (ff), note 3154, done 3155; #1785 closed; P19 511 comment 623; Honker told for 194 b17.
2026-10-22 08:38:11 UTC | Sage (1ef1e4f8) | row 194 b17/b18 window and landing | b17 CLEAR (sage-2544), then my refactor push forced b18 (sage-2545, Webby sage-2546); review request rev 3158/3159; landed fd6dd327c (ff), note 3161, done 3162; #1692 closed; P19 414 comment 625
2026-10-22 08:42:04 UTC | Honker (Claude Opus 5.5, ecd17786) | row 194 #1692 build to landing | b8-b18 on honker/row194-b*; G13 diagnosis 60 runs (slot returned 07:11Z); b17 then b18 rebases (no force-push); runb8 + gate whole green on fd6dd327c, slot returned 08:31Z swept clean; in-review move revs 3156/3157 (request failed, no token); landed by Sage fd6dd327c; no live action.
2026-10-22T08:45:16Z | Dewey (Opus 5.5, 84fb346c) | row 236 (#1742) rebase onto be2b008cc, ce74477e1 (sage-2553/2555, megavolt-0128) | static look clean: c1 unions match 194 code, 2-7 =, manifest d7dd27b8 ok; fire-keeps-grant note to Sage; verdict waits on the green gate
2026-10-22T09:37:08Z | Dewey (Opus 5.5, 84fb346c) | row 236 (#1742) manifest round 1 on 0b00269b (bd5f11ded) | APPROVE, comment 30450, queue rev 3168; no blockers, no notes
2026-10-22T09:43:34Z | Sage (1ef1e4f8) | row 236 landing, row 281 filing | landed bd5f11ded (ff), note 3169, done 3170, #1742 closed c30451, P19 436 comment 639; row 281 #1786 brief and DEFERRED entry for mos-1271
2026-10-22T09:46:13Z | Dewey (Opus 5.5, 84fb346c) | row 274 (#1779) r4 rebase look on 1920b926f (bd5f11ded) | clean static look, not a verdict: range-diff/patch-ids reproduced, manifest 04a0750c ok, S1/S2/halt tests present; r5 covers move supported (dewey-0819/0820)
2026-10-22T09:50:02Z | Dewey (Opus 5.5, 84fb346c) | row 274 (#1779) r5 static look on f58cfc361 (47a35bf43) | clean, not a verdict: range-diff 1-4 = plus r5, manifest 13 paths 6f5f453a ok, covers moved to vocabulary.mjs as a re-export, RUNNER_FILES untouched (dewey-0821/0822)
2026-10-22T10:00:55Z | Dewey (Opus 5.5, 84fb346c) | row 281 (#1786) design d3 review on 36c2d98b8 (47a35bf43) | CHANGES dewey-0823/0824: B1 pending decisions and raise-after-fire, B2 awaited step G vs host.op 10 s and runner 30 s, B3 per-agent concurrency; N1 agent-scoped sweep, N2 name hosts revoke, N3 instance.fire tests in manifest
2026-10-22T10:08:33Z | Dewey (Opus 5.5, 84fb346c) | row 281 (#1786) design d4/d5 review on 59142e9fb (47a35bf43) | CHANGES dewey-0825/0826: B1-B3 closed; C1 step C in the job needs the chief's claim (#agent not-holder) and B/C failures mislabeled internal; N4 restart mid-job, N5 section 7 refusals, N6 leftover-line wording
2026-10-22T10:08:33Z | Dewey (Opus 5.5, 84fb346c) | row 274 (#1779) r6 static look on 4c485c63b (47a35bf43) | clean, not a verdict (dewey-0827/0828): range-diff 1-5 = plus r6, patch-ids reproduce, manifest 13 paths 00047b52 ok, deepEqual pins :132 before the delete loop; packet blob change doc-only
2026-10-22T10:11:28Z | Dewey (Opus 5.5, 84fb346c) | row 281 (#1786) design d6 review on 97535587b (47a35bf43) | APPROVE dewey-0829/0830: C1 and N4-N6 closed; build-time N7 (T12 step B refusal must survive step G), N8 (T11 decision-consumed can't hold for a within-role fire)
2026-10-22T10:28Z | Dewey (Opus 5.5, 84fb346c) | row 274 FED R3 r6 manifest round, 4c485c63b manifest 00047b52 | APPROVE, #1779 comment 30487, recorded rev 3178 (dewey-274-verdict-r6); N1 tests present, 22/22 mutants, 23 file lines (not 24)
2026-10-22 10:30:51 UTC | Sage (Claude Opus 5.5, 1ef1e4f8) | row 274 landing, row 281 d6 and 264a r5 plans | 274 landed 4c485c63b (ff, #1779 closed c30491, P19 414 c646); review request posted for Morgana (no token) c30488; 281 d6 approved by Dewey, code started; 264a r5 c4c75b6b4/5cb234285 run plan accepted; N7 DEFERRED (runs suite gates mosaic-task.mjs) committed
+18 -1
View File
@@ -276,11 +276,25 @@ scripts/mosaic follow --thread <thread> [--tester]
scripts/mosaic stop <run>
scripts/mosaic launches off | on [--business <id>]
scripts/mosaic launches list [--json]
scripts/mosaic halt | resume [--business <id>]
scripts/mosaic processing status [--business <id>] [--json]
scripts/bus-service.sh [--instance NAME] render [--copy ID [--copies DIR]] [--pm] [--claude PATH]
scripts/bus-service.sh [--instance NAME] install [--copy ID [--copies DIR]] [--pm] [--dir DIR] [--no-reload] [--claude PATH]
scripts/bus-service.sh [--instance NAME] uninstall | status <business>
```
The instance processing switch is separate from per-business launches off/on.
`mosaic halt` durably stops managed processing across every business in the
selected configuration's data root. The bus stays up for reads and human
recovery. `mosaic processing status --json` reports settled, pending or unknown
stops; `mosaic resume` checks the current generation and refuses pending stops.
Only the existing human proof can write it, independently of channel settings.
A missing/invalid state is never off: use halt, then resume; an unsafe control
parent needs operator repair. Unknown task/remote outcomes are not retried.
Native diagnostic seats remain available. First activation must stop old
processing before trusted initialization and align the image/read-only control
mount. This is a separate approved window, never a build step.
The human commands read and resolve through the broker's human transport
(`packages/bus/src/human-cli.mjs`), never the SQLite file. They refuse inside
an agent run, except the forms that take `--tester` (rows 110, 175 and 184), which
@@ -466,7 +480,7 @@ reports checkout or copy identity and refactor drift, and writes nothing.
```
scripts/stack-release.sh adopt BUSINESS
scripts/stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-socket | --tester-port N (legacy copies)]
[--proof-header NAME --proof-file PATH | --no-proof | --sso] [--no-send] [--timeout S] [--plan]
[--proof-header NAME --proof-file PATH | --no-proof | --sso | --no-sso] [--send | --no-send] [--timeout S] [--plan]
scripts/stack-release.sh pm-on BUSINESS [--timeout S]
scripts/stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
scripts/stack-release.sh status BUSINESS
@@ -476,6 +490,9 @@ scripts/stack-release.sh gate ID BUSINESS --units external --since EPOCH [--time
common options: --copies DIR, --bin DIR, --instance NAME, --units user|external
--events N is the total of the per-table counts the post-backup-events refusal prints: events and
every table in COUNTED.
--send needs --sso in the run's Console flags, given or carried. --no-sso needs --sso in the
pointer's, and takes the Console flags from the rollback set of the activation that turned it on.
--no-sso drops the pointer's --send, and so does a rollback to a Console without --sso.
```
This is an operator plan, not an action agents run. Both reviews and the
+9 -3
View File
@@ -22,9 +22,15 @@ edits it by hand, and the agents can't change it. He will ask you to confirm it
## Your playground
A folder named in the `playground` list of that entry is your playground. Each playground folder sits
inside one book folder, so one per book works well, for example `<book>/playground/`. You choose the
names and tell Jason. Until you have one, every note in a book folder is canon.
A folder named in the `playground` list of that entry is your playground. A playground folder can sit
at one of three levels:
- Book level: inside one book folder, for ideas about that book, for example `<book>/playground/`.
- Universe level: outside every book folder, for ideas about a universe that aren't tied to one book.
- A new universe: a draft universe entry with a name and its own playground folders and no books, for a
world you are only starting. When it is real, Jason turns it into a plain universe with books and you
move the notes in Obsidian.
You choose the names and tell Jason. Until you have one, every note in a book folder is canon.
What the playground means for the agents:
- They search canon unless they ask for the playground on purpose.
+1 -1
View File
@@ -111,7 +111,7 @@ repo, so a bad mapping gives exit 3 even when the repo path is wrong too.
| 4 | `library survey: usage: mosaic library survey <repo> [--mapping <file>]` | no repo, two repos, an unknown flag, `--mapping` without a file or twice | Fix the command |
| 3 | `library survey: library-mapping-invalid: <file> is not a readable JSON file` | missing, unreadable by `mosaic`, or not JSON | Fix the file or its mode |
| 3 | `library survey: library-mapping-invalid: <file> <reason>` | the entry breaks the schema. The reason names the key | Give it to Jason for the draft |
| 3 | `library survey: library-playground-outside-book: <file> universes.<u>.playground "<prefix>" is not inside one of its books` | a playground prefix outside its universe's books | Give it to Jason for the draft |
| 3 | `library survey: library-playground-other-universe: <file> universes.<u>.playground "<prefix>" lies inside book folder "<book>" of universes.<v>`, or `holds book folder` | a playground prefix inside, or holding, another universe's book folder | Give it to Jason for the draft |
| 2 | `library survey: library-git: <repo> is not a directory` | wrong path | Check `<repo>` |
| 2 | `library survey: library-git: repository has no readable HEAD commit (project-git)` | not a repo, a folder inside one, another owner, no commits, a `HEAD` with no commit, or no `git` on `PATH` | Check each. If `<repo>` is the bus mirror, stop and report to Sage |
| 2 | `library survey: library-git: <other reason>`, such as `tree listing refused (project-git)` | git failed or ran past 100 seconds | Report to Sage |
@@ -0,0 +1,29 @@
# Brief: row 253 F5 follow-up, supervised legacy AS_USER calls in the case scripts
Sage, 2026-10-22. Source: row 253's DESIGN-C4 addendum (Gander 968db0bcd, :111-124) and Duckworth's
addendum ruling (duckworth-0111, RULING-c4-addendum.md at 45288e584, F5 and C4).
## Brief: supervised legacy AS_USER calls
Owner Gander. Reviewer Duckworth. Gate owner Sage. Starts after row 253 lands.
Why: when a terminal is present, sudo runs the command in a new session (use_pty), and it can't relay
SIGKILL. Row 253 puts its new config query and peer probe under an inline supervisor that scans the
session it owns. It leaves the older calls in `case1-accept.sh` as they are. These are the `timeout 330 ...
talk` path (case1-accept.sh.txt:305 in Gander's frozen snapshot) and the other AS_USER diagnostics (:326,
:335, :355, :396). Each of these wraps sudo in a GNU timeout outside the uid drop. That has the same gap:
when it fires, it kills timeout's process group, and the command keeps running in its separate session.
What to build:
1. Every remaining AS_USER call in the case scripts runs under row 253's supervisor text. That's the same
bytes, pinned by row 253's C5 test, with its own inner and outer bounds and reasons. The talk path
keeps its 300 s conversation and its stdout and stderr diagnostics.
2. Or, for any call that can't take the supervisor, the header says it isn't supervised and why, and the
DEFERRED entry stays open for that call by name.
Tests: extend case-sudo-fixture.mjs cases in both fake modes (exec and session hop) to each changed call.
Each one needs a no-leftover assertion and its exact reason. Mutants: drop the supervisor from the talk path,
and drop the outer bound from one diagnostic. Each must be killed.
Gate: Duckworth approves the manifest, the touched test files run whole, every scripts/test-*.sh runs,
and the mutants are killed.
@@ -0,0 +1,65 @@
# A fire or a delete revokes the instance's grants (194/236 follow-up, MVP)
Sage, 2026-10-22, for mos-1271. Dewey found it on the row 236 rebase look (dewey-0815). Row 281, Gitea #1786, Vikunja 524.
## Why
Row 194's `instance.fire` revokes only the roles entry's credential services. It does this through
`revoke()` in packages/bus/src/runtime.mjs (:151), which instance-settings.mjs `fire()` calls as its
beforeWrite (:337, :343). Row 256's human delete goes through the same `revoke()`. Neither touches a
grant. A fired agent's host grants (row 235) stay on every host they name, and its Gitea user and token
(row 236) stay live. They last until someone runs `mosaic hosts revoke AGENT HOST` or `mosaic grants
revoke AGENT` by hand. Those CLI verbs are the human's. The tester's `hosts.revoke` and `grants.revoke`
refuse `tester-dev-only` outside a dev business (broker.mjs :1573 and #grantsCaller). The Console has
no revoke route. Nothing tells the human that a fired instance still holds a grant.
Live is 86f95bb27, which carries 235's host grants but not 236. dragon-lin runs as tester with no real
host grants, so nothing live holds a grant today. The Case 1 install on .53 will hold them.
## Brief: no grant outlives its instance
`--after` 236. It lands before the Case 1 install on .53 hires any instance with a grant (mos-1271
item 4). It doesn't hold the dragon-lin re-pin. It comes after 274 in the landing order unless Mos
moves it.
Design before code. The design goes in the packet BUILD.md. The reviewer approves it before any code.
What to build:
1. A fire, and a delete, end every grant the instance holds before the write that removes the
instance. That covers each host grant on each host, through the same steps `hosts.revoke` takes
(the grant.revoked record, the key removal and the host line, row 235 r6 section 6). It also covers
the Gitea grant, through the same steps `grants.revoke` takes (row 236 #436b).
2. Fail closed, as `revoke()` does for credentials. If any grant revoke can't complete, the verb stops,
the organization file stays unchanged, and the refusal names the instance, the grant and the reason.
A repeated fire then picks up where the first stopped, as 194's repeated fire already does for step
C (webby-0011 B2). A grant that's already revoked counts as done. The design says how a host that
can't be reached is handled. It may not drop a grant record while its key could still be on the
host.
3. The fire's result, and the event the human sees, list the grants revoked as well as the credential
services.
4. Nothing else changes. The hosts and grants verbs keep their callers and refusals. The tester's
`tester-dev-only` stays. No new Console route.
Tests, each in the owning package, whole files:
- A fire of an instance with a host grant on two hosts and a Gitea grant revokes all three. It records
each revoke the way the manual verbs do, and a later host.run or Gitea use by that agent refuses.
- The same for the human's delete (row 256's path).
- A grant revoke that fails: the fire refuses, the file is unchanged, and the instance and its other
grants are as they were or recorded as revoked, per the design. A repeat fire then completes.
- An instance with no grant fires exactly as it does today, with no new events.
Mutants, each killed by a named test:
- M1. The fire skips the host grant revokes.
- M2. The fire skips the Gitea grant revoke.
- M3. A failed grant revoke is ignored, so the fire goes on to the write.
- M4. The delete path skips grant revokes.
Gate: the reviewer approves the design and then the manifest. The bus, business and cli suites run
whole, with every touched test file whole on its own. The Docker gitea-grant test runs. Every
scripts/test-*.sh is green on Sage's gate, and M1-M4 are killed. Any red goes to Sage, and a red Sage
can't place goes to Mos.
Owner Megavolt. Reviewer Dewey. Gate owner Sage.
@@ -0,0 +1,92 @@
# The owner sees a thread agent's claims (10-27 row)
Sage, 2026-10-22, for mos-1262. Row 205's message filter found the gap (morgana-0072, ruling sage-2430).
## Why
PRD :1083-1084 promises that what a thread's agent does with the owner's authority stays visible to the
owner: its decisions, inbox entries, claims, tasks and project files. FED R6 shows users the same list word
for word. Today a thread run's `role.claim` writes `thread_claims` only and returns at broker.mjs:2682. The
`agents` verb (:3309) reads `role_claims` only, and no other owner verb shows thread claims. So the owner
can't see a thread agent's claim.
Mos ruled this a 10-27 row, not post-v1 (mos-1262). No user thread goes live before it lands, the same
bind as the 193b1a filter. Q176 Case 1 ("Shared and private agent sessions available per user; RBAC")
needs it. If it can't make 10-27, that's a scope question for Jason, and it goes to Mos first.
## Brief: the owner sees thread claims
Chained on row 205 (193b1a). Start from refactor after 205 lands.
What to build:
1. A human or reader session's `agents` result also lists every thread claim currently held in the
business: thread id, holder run, harness and claim time. A claim counts as held when its thread's
latest `thread_claims` op is `claim`. Role claim rows keep their current shape and order. Thread
claim rows are added after them, marked so a reader can tell the two apart.
2. A role session's `agents` result is unchanged.
3. The rows carry no message content and no thread name a user wrote. The 205 filter still hides user
messages. This row adds claims only.
4. The CLI's `agents` output (packages/cli format.mjs reads `c.role` and `holder_run`) shows the thread
rows without breaking the role rows.
5. Row 205's governance pin (message-filter.test.mjs, the "claims" item) moves from `threadsState` to the
owner's `agents` call. Its comment naming this row goes away.
If the builder finds a cleaner shape, for example a separate owner verb, it goes in BUILD.md to Duckworth
before code. A widening beyond owner and reader sessions goes to Mos (mos-1202).
Tests:
- Owner `agents` shows a held thread claim. After revoke or release, it doesn't.
- A role session's `agents` result is byte-equal to before for the same store.
- No message body or user-written thread text appears in the output.
- The CLI renders a mixed list.
Mutants, each killed by a named test:
- M1. Thread rows dropped from the owner result.
- M2. The held check reads the first op instead of the latest, so a released claim shows.
- M3. Thread rows added to a role session's result.
Gate: Duckworth approves the manifest. The touched test files run whole, the bus and cli suites and every
scripts/test-*.sh are green on Sage's gate, and the mutants are killed.
## Ruling on design r1 (sage-2487)
Duckworth approved Morgana's design r1 (3e9939b7b) as D1: a separate human and reader verb `thread.claims`,
with `agents` unchanged, under conditions C1-C3 (duckworth-0118, agents/duckworth/work/row1782/DESIGN-r1.md
at 13fb9abc5). The brief allowed that shape. Brief item 5's pin moves to `thread.claims`, as C3 records.
Row 277 builds it.
The Console doesn't show thread claims under D1, and an owner who uses only the Console won't run
`mosaic agents`. So PRD :1083-1084 isn't met until the Console shows them too. That's the follow-up brief
below, with the same bind as row 277.
## Brief: the Console shows thread claims
Chained on row 277. Start from refactor after 277 lands.
What to build:
1. The Console's Agents view gets a "Thread claims" section for a human or reader session. It reads
`thread.claims` and lists each held claim: thread id, holder run, harness and claim time.
2. The existing role rows (webui public/bus.js :447, :462, :474) keep their shape, counts and order.
3. No message body and no thread name a user wrote appears in the section.
4. An empty result shows an empty-state line, not an error. A refusal from `thread.claims` shows the
refusal and leaves the role rows intact.
Tests:
- The section lists a held claim and drops it after release.
- Role rows and their counts are byte-equal to before for the same store.
- No message body or user-written thread text in the rendered section.
- A refused `thread.claims` call leaves the role rows rendered.
Mutants, each killed by a named test:
- M1. The section is not rendered.
- M2. Thread claims are counted into the role totals.
Gate: Webby approves the manifest. The touched test files run whole, the webui and bus suites and every
scripts/test-*.sh are green on Sage's gate, and the mutants are killed. No user thread goes live before this
row lands.
+61
View File
@@ -0,0 +1,61 @@
# The bus tests' scratch sshd proves it owns its port (194 gate follow-up, MVP)
Sage, 2026-10-22, for mos-1270. Row 194 b16's gate found it (honker-0125), and Honker's diagnosis placed it
on the base (honker-0127).
## Why
`sshd()` in packages/bus/tests/grant-executor.test.mjs (:57-97, from row 249, 9de5a57ab) and the same
helper in packages/bus/tests/host-run.test.mjs (:79-97) pick a port with
`20000 + Math.floor(Math.random() * 20000)`, start sshd on it, and return the port as soon as
`listening(port)` connects. This host's ephemeral range is 32768-60999, so about a third of the picks fall
inside it.
Honker's diagnosis run hung on base ceadad993, with no row 194 code, in G23 at :433. sshd.log said "Bind to
port 38927 on 127.0.0.1 failed: Address already in use", and the sshd child had exited 255. Even so,
`listening(port)` had returned true, so the helper returned a port that our sshd never held. The install
never reached our sshd (no requests.log), the hold-add stub never wrote `holding`, and the first `until`
timed out at 10 s. Row 194's G13 red at :733 has the same shape. The connect that succeeded was either a
short-lived foreign listener or a TCP self-connect, which can succeed with no listener at all when the
source port equals the destination port. Both need a port inside the ephemeral range. Live is 86f95bb27 and
nothing running uses these helpers. This is a test defect, not a product defect.
## Brief: the scratch sshd's port is ours before a test uses it
No `--after`. This row lands before row 194 b17, which rebases onto it and reruns its gate whole
(mos-1270 item 3, the same order as row 279).
What to build, in both helpers:
1. The helper returns a port only when our sshd holds it. A connect alone isn't enough. One shape:
`daemon.exitCode === null` after `listening` returns true, plus sshd.log's "Server listening on
127.0.0.1 port N" line. The builder picks the proof and records it in BUILD.md.
2. A bind failure or an early sshd exit retries with a new port, within the existing attempt limit. When
every attempt fails, the helper still throws 'scratch sshd did not start'. Never a skip.
3. Ports come from outside the host's ephemeral range. Read /proc/sys/net/ipv4/ip_local_port_range rather
than hard-coding 32768, and keep the pick at or above 1024.
4. DEFERRED X5 binds this row because it touches grant-executor.test.mjs (DEFERRED.md, the "Grant executor
test X5 starts a `sleep 30`" entry). The `run(['sleep', '30'])` that X5 starts and then revokes
(grant-executor.test.mjs :989-990 at ceadad993) must not outlive its test. Use a stub that exits once its test dir is gone, as row 248 did for the hold stubs, or a shorter
sleep. The builder picks and records it in BUILD.md. The DEFERRED entry gets a closing line naming this
row.
5. No product file changes. Nothing outside the two test files, plus a shared test helper if the builder
moves `sshd()` into one, plus DEFERRED.md and the packet.
Tests:
- A regression: a port already held by a plain TCP listener the test opens is refused, and the helper
retries and returns a different port that our sshd holds. The test may pass a port to the helper for this.
- Every existing test in both files passes unchanged.
- Repeat: each of the two files runs whole 30 times with the cli suite as load, with no timeout. Save the
counts and durations.
Mutants, each killed by a named test:
- M1. The old acceptance: return the port on `listening(port)` alone.
- M2. The old pick, `20000 + Math.floor(Math.random() * 20000)`. If no single run kills it, record the
repeat counts and the reason, and Duckworth rules on it.
Gate: Duckworth approves the manifest. Both files run whole, the bus suite is green on Sage's gate, the
30-run repeat has no timeout, and M1 is killed. Row 194 b17's full gate then runs on top of this row. Any red
goes to Mos (mos-1270).
+23 -33
View File
@@ -1,35 +1,24 @@
# Plan: Q175 phase 2 window, the live Console switches to --sso
Sage, 2026-10-22. Draft 2 for Mos, after mos-1245. Sources: `agents/sage/work/q175-sso/DESIGN.md` sections 5
Sage, 2026-10-22. Draft 3 for Mos, after mos-1266. It names 86f95bb27, live since 04:03:00Z on 10-22. Sources: `agents/sage/work/q175-sso/DESIGN.md` sections 5
and 10 (phase 2 order, rollback, Jason-gated steps), Mos's C1 condition (mos-0977, b416d2ede), Jason's phase 2
answers (Q209, mos-1216, mos-1217), mos-1195, mos-1197 and mos-1245. Rows 192 (phase 1), 200 (`activate --sso`)
and its N1 follow-up have landed. Vikunja #412, Gitea #385.
answers (Q209, mos-1216, mos-1217), mos-1195, mos-1197 and mos-1245. Rows 192 (phase 1), 200 (`activate --sso`),
its N1 follow-up and 273 (`activate --send` and `--no-sso`) have landed. Vikunja #412, Gitea #385.
## Blocked on a tool gap
## The tool gap is closed
Two steps need `scripts/stack-release.sh` flags that don't exist at 02da6a2ae:
- `activate` has `--no-send` but no `--send`. The live pointer holds no `--send`: the 10-20 19:29:56Z
re-pin of 490e2771d gave `--no-send`, and every activation since has carried it off. So step 5 can't turn
`--send` on (mos-1195).
- `activate` has no way to turn `--sso` off. Without `--sso`, `sso_base` carries it from the pointer
(`sso: carried`), and `check_target` refuses the pointer's own copy as `already-active`. The pre-window
flags (`--public-host`, `--auth-header`, `--allow-user`) have no activate option either. So R1's
same-copy rollback can't run.
The fix is one small row, approved in mos-1246: `activate --send` and `activate --no-sso`. `--no-sso`
takes the Console flags from the rollback set of the activation that turned `--sso` on, the way `sso_base`
already does for a rollback to a copy without `--sso`. It drops `--send`, and its plan line says so. `--send`
refuses unless the run's Console flags hold `--sso`, given or carried (F39). test-release.sh gets cases for
both. Darkwing builds it, and a seat other than Sage and Darkwing reviews it. Brief:
`docs/plans/2026-10-22_activate-send-nosso.md`. It lands and is re-pinned before the window, on Mos's go,
run by Jason or Mos. The steps below assume it, and draft 3 names the new copy.
Draft 2 found two missing `scripts/stack-release.sh` flags: `activate --send` and `activate --no-sso`. Row 273
added both (mos-1246, F39). It landed as 0bbf0f6d1 (Gitea #1778), and the 86f95bb27 re-pin put it live
(activation 20261022T040256Z-86f95bb27f6c). `--send` refuses unless the run's Console flags hold `--sso`,
given or carried. `--no-sso` takes the Console flags from the rollback set of the activation that turned
`--sso` on, and it drops `--send`. Neither flag ran in that re-pin, so step 5 and rollback 1 are their first
live use. Darkwing's gate on 0bbf0f6d1 ran stack-release.test.mjs 218/218, including T53, T57 and T58.
## When
Only when Jason starts the window (mos-1197). Nothing below runs before his start. The 10-24 re-pin
condition is met: W12 passed on 7247d80c7 at 22:27:13Z (mos-1245). This plan names 7247d80c7, which already
carries phase 1, so its loader accepts a config with or without `console`. If another re-pin lands before
Only when Jason starts the window (mos-1197). Nothing below runs before his start. The re-pin condition is met:
W12 passed on 86f95bb27 at 04:26:43Z on 10-22, and Mos closed that window in mos-1266. This plan names
86f95bb27, which carries phase 1 and row 273, so its loader accepts a config with or without `console`. If another re-pin lands before
Jason starts, the plan names the copy live at that point, and step 3 runs against it.
## Who
@@ -63,12 +52,12 @@ Jason starts, the plan names the copy live at that point, and step 3 runs agains
`wc -l` is 1; `tr -cd '\r' <slot | wc -c` is 0; the last byte is a newline (`tail -c1 | od -An -c`).
3. Straggler check (C1): list every running process, unit and timer that reads config.json, with the
copy it runs from (`/proc/<pid>/exe` and cwd links, `systemctl --user list-units`, `list-timers`). Each one
must run from 7247d80c7, or be shown never to re-read config. Step 4 waits until the list is clean.
must run from 86f95bb27, or be shown never to re-read config. Step 4 waits until the list is clean.
4. Config write 1 (agent, backup from step 1): add `console.sso` with the values above and one subject
`{ "sub": "pending-first-sign-in", "name": "jason.woltje" }`. `scripts/mosaic-config.mjs validate`
reads it back before anything starts.
5. First `scripts/stack-release.sh activate 7247d80c7 mosaic-stack --sso --send --plan`. Its from line must
name 7247d80c7 (`--expect-from` is for `--units external` only, so the live user units refuse it as a
5. First `scripts/stack-release.sh activate 86f95bb27 mosaic-stack --sso --send --plan`. Its from line must
name 86f95bb27 (`--expect-from` is for `--units external` only, so the live user units refuse it as a
usage error). Then the same command without `--plan`, the same way earlier re-pins handled the
Console. The tester stays the private socket from row 196 (`--tester-socket`). The `--plan` output
names every flag it drops (`--public-host`, `--auth-header`, `--allow-user`) and every flag it carries.
@@ -107,15 +96,16 @@ Jason starts, the plan names the copy live at that point, and step 3 runs agains
## Rollback
1. Same-copy re-activate (mos-1245 R1): `scripts/stack-release.sh activate 7247d80c7 mosaic-stack --no-sso
1. Same-copy re-activate (mos-1245 R1): `scripts/stack-release.sh activate 86f95bb27 mosaic-stack --no-sso
--plan`, then the same without `--plan`. R1 asked for `--expect-from 7247d80c7`, but `--expect-from` is
for `--units external` only and the live units are user units, so the plan's from line must name
7247d80c7 instead. The `--plan` output must show the pre-window Console flags from the step 1 pointer backup: `--port 7332 --public-host
86f95bb27 instead. The `--plan` output must show the pre-window Console flags from the step 1 pointer backup: `--port 7332 --public-host
mosaic.woltje.com --auth-header X-authentik-username --allow-user jason.woltje --tester-socket`, exactly.
`--no-sso` drops `--send` (mos-1246 F39), so there's no `--send`. A plan that differs is a stop. Mos or Jason restarts the Console.
2. Restore config.json from the step 1 backup, which removes `console`. 7247d80c7 loads config with or
2. Restore config.json from the step 1 backup, which removes `console`. 86f95bb27 loads config with or
without the key, so the order of 1 and 2 doesn't matter on this copy.
3. STATE after the rollback reads config.json, the unit's Console flags and the pointer.
4. Last resort only, and it waits for Jason: `scripts/stack-release.sh rollback` goes to the previous
distinct copy, 4429a104f at schema 3i. The store has been at 3j since 21:59Z on 10-21, so that rollback
needs `--restore`, and any events after the backup make it Jason's call (header condition 3).
4. Last resort only, and it goes to Mos first: `scripts/stack-release.sh rollback` goes to the previous
distinct copy, 7247d80c7. Both copies are at schema 3j and 7247d80c7 carries phase 1, so it's a pointer
flip with no `--restore`. Its `--plan` must show the pre-window Console flags above. Any restore goes to
Mos and ends the window (mos-1226).
+45
View File
@@ -0,0 +1,45 @@
# stopRun reads a live run's status at the deadline (191 follow-up, MVP)
Sage, 2026-10-22, for mos-1269. Row 194 b15's gate found it (honker-0121, sage-2510).
## Why
`stopRun` in packages/processing-control/src/runtimes.mjs (row 191, 4bdc7e0e8) waits in
`while (now() <= deadline)` (:333). Its final status read is inside `if (now() >= deadline)` (:356-359),
and the loop sleeps `Math.min(20, Math.max(1, deadline - now()))` (:361). A real sleep can wake past the
deadline. When it does, the loop condition fails before the final read runs, and :363 returns `'unknown'`
for a process that is still alive and ignoring SIGTERM and SIGKILL. The expected outcome is `'alive'`.
Row 194's T9a (cli lifecycle.test.mjs:562) caught it in the loaded cli suite: 'unknown' where 'alive' was
expected, while the same file passed alone. Row 191's tests missed it because their fake clock advances by
exactly the sleep, so it never overshoots. Live is 86f95bb27, which doesn't carry 191, so nothing running
is affected.
## Brief: stopRun's deadline branch is the only time exit
No `--after`. 191 has landed. This row lands before row 194 b16, which rebases onto it (mos-1269 item 4).
What to build:
1. The time loop's only exit on time is the existing deadline branch, so the final status read always runs
once the deadline passes. One shape is `for (;;)` with the `now() >= deadline` branch as the time exit.
The builder picks the shape and records it in BUILD.md.
2. The other exits stay as they are: record gone (the catch), identity changed, ended or not alive, and the
deadline branch's own mapping of `alive`, `killed`, `stopped`, `identity-changed` and `unknown`.
3. Nothing else in runtimes.mjs changes. Vikunja 502's four items stay out (mos-1269 item 3). They follow
row 194.
Tests:
- A fake sleep advances the clock past the deadline while the process stays alive and ignores both
signals. stopRun reports `'alive'`.
- The existing stop-run tests pass unchanged.
Mutants, each killed by a named test:
- M1. The old loop condition `while (now() <= deadline)` with the fall-through `return finish('unknown')`.
Gate: Dewey approves the manifest. The processing-control package's tests (runtimes.test.mjs and
stop-run.test.mjs among them) and every other touched test file run whole, the business and cli suites are
green on Sage's gate, and M1 is killed. Row 194 b16's full gate then runs on top of this row. Any red goes to
Mos (mos-1269).
+64
View File
@@ -319,6 +319,10 @@ at every gate. Started 2026-09-12 during the control board MVP.
waits 30 s before it calls a lone `sleep 30` a leak. The row 197
lanes scan only at lane end, about 15 minutes after pkg-bus, so they
can't see it (mos-1169). (2026-10-21)
Closing note, row 280 (2026-10-22): both scratch sshd fixtures install a
bounded 30/60-second sleep stub that exits when its test directory is gone;
other sleep arguments exec /usr/bin/sleep unchanged. Evidence is recorded
in agents/rocko/work/queue-280/BUILD.md.
- **Row 66 live checks: the unslop notice on a live managed session, both harnesses.**
(mos-1186, Vikunja #454.) Row 66's gate asked for one landing session per
@@ -380,6 +384,66 @@ at every gate. Started 2026-09-12 during the control board MVP.
credential and a real `release.sh activate`, and records both results on
the re-pin row. Morgana's wording (row 244 BUILD.md), Webby N2 on row 244
r1 (#1757). (2026-10-21)
- **Creative instances: the human can't delete one.** (Sage's ruling
sage-2164 on the row 194 b4 rebase, #1692.) Row 213 put the creative
definitions in END_PROTECTED, and row 194's `deleteRefusal`
(`instance-settings.mjs`) refuses END_PROTECTED for every actor, so
the human can't delete a companion instance and fire refuses one too.
The Agents page hides Delete on the listing's `endProtected`. The fix
threads the actor through `deleteRefusal` and the launcher's delete
pass, so the human may delete one and the tester may not
(`creativeHumanOnly`). Until then, the human can delist
(instances.set launch: false) but neither fire nor delete removes a
creative entry (sage-2166). The row covers fire and delete together;
Sage files it. (2026-10-21)
- **A fire stuck between step A and step B has no human escape.** (Webby
N4 on row 194 b5, webby-0094; Sage's ruling sage-2241, #1692.) The
broker's chief fire records `fire.stopped` at step A, and `#firing`
refuses every launch and take of the instance until a `fire.done`. The
events are in the store, so a bus restart doesn't clear them. Row 194
b6 runs the writer's checks against the file before step A
(`instance.fireCheck`), so a keep the bus hasn't loaded no longer
strands an instance. A step B that keeps refusing still does: a
credential revoke that fails every time, or a file a human changed
between the check and the write. Each repeat fire runs step A again
and refuses at B. The fix is a human verb, never an agent one, that
records the end of the firing (a `fire.done`, or a new
`fire.abandoned` that `#firing` also counts) with a reason. Its test:
a fire whose step B refuses leaves the instance `instance-firing`, the
verb as the chief or the tester refuses, and the verb as the human
clears it so the instance launches again. (2026-10-21)
- **A fire's done with more than 64 stops refuses, and nothing flags a
run left alive.** (Webby N2 on row 194 b8 r2, webby-0100; Sage's ruling
sage-2290, #1692.) `lifecycleDone` caps `stops` at 64 and refuses
`invalid-request` above it (`broker.mjs`). The launcher's fireStop has
already signalled every run through stopRun by then, so the killed runs
are dead. But the refused done writes no lifecycle.end.killed or
lifecycle.end.alive event and no lifecycle.done, and the end request
stays taken. A run the stop left alive, or couldn't confirm (unknown),
gets no Console flag. The fire itself is unaffected, because
`fire.stopped` and `fire.done` are written at the verb. No MVP instance
comes near 64 runs. The follow-up row either raises the cap or splits
the done. Its test: a fire's done over the cap refuses, and the runs it
left alive still get the alive flag. (2026-10-22)
- **A fired or deleted instance keeps its grants.** (Dewey on the row 236
rebase look, dewey-0815; Mos mos-1271; Sage sage-2558.) Row 194's
`instance.fire` and row 256's delete revoke only the roles entry's
credential services through `revoke()` in `runtime.mjs`. The agent's
host grants (row 235) and its Gitea user and token (row 236) stay live
until a human runs `mosaic hosts revoke` or `mosaic grants revoke`. The
Console has no revoke route and doesn't say a grant is left. Row 281
(#1786) closes it: a fire or a delete revokes every host and Gitea grant
the instance holds, fail closed, with a test for each kind. It lands
before the .53 Case 1 install hires an instance with a grant.
(2026-10-22)
- **The runs suite doesn't gate a change to `scripts/mosaic-task.mjs`.**
(Dewey N7 on row 191, repeated on the Vikunja 502 review.)
`packages/runs/tests/task-cli.test.mjs` spawns `scripts/mosaic-task.mjs`,
but row 191's gate didn't run the runs suite. test-task.sh covers the same
list and show paths, so nothing is known broken. Binds the next row that
changes `scripts/mosaic-task.mjs`: its gate runs the runs suite whole.
(2026-10-22)
## Queue
+16 -10
View File
@@ -33,7 +33,7 @@ Gaps found while working go to `docs/plans/DEFERRED.md`, not here.
<!-- mosaic-queue:begin -->
Generated from `docs/plans/queue.json` revision 3102 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
Generated from `docs/plans/queue.json` revision 3180 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|---|---|---|---|---|---|---|---|---|
@@ -227,10 +227,10 @@ Generated from `docs/plans/queue.json` revision 3102 by `queue render`. Do not e
| 188 | stack-release tests: a carried proof pair and a proof file inside the repository (P19 409, 410; gates Part B) | darkwing; reviewers filbert | #1686 | done | 187 settled | Filbert approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_proof-test-gaps.md` § Brief: tests for the two proof-file paths row 187 left open | Landed 8fc0ee216 (author Darkwing) on 23a609c5c; patch-id c9a56b167c07 matches candidate 86aa549bad75 and Filbert's r1 reading; stack-release.sh blob 2586a2a37cd2 unchanged. Sage gate passed 10:58Z: cli 453/453 and every package and scripts/test-*.sh green. Filbert approve 29433, rev 2212; N1 (REPO logical path) to P19 after Part B (mos-0959). |
| 189 | stack-release activate --no-send drops the Console's --send and keeps the pointer in step (Q175 interim, mos-0972, P19 413) | sage; reviewers dewey | #1687 | done | 163 done | Dewey approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_activate-no-send.md` § Brief: activate --no-send drops the Console's --send | Landed ace27dc5e on refactor (cherry-pick of candidate d6116771f onto a4a42dbac). Gate GATE_PASSED on 0c177e9ef (base 0217b71c8), 2026-10-20T19:09:37Z-19:20:36Z, 25 suites rc=0. patch-id ce89378718f6 for both 0c177e9ef and ace27dc5e; range-diff differs only in the message. Landed manifest hashes to 67af69bd (Dewey approve 29485). Base drift 0217b71c8..a4a42dbac: 7 doc/README files, none on manifest paths, no test reads them. Brief re-pinned to blob 29a9e6d2d. |
| 190 | several Mosaic Stack instances on one host: per-instance config, data root, units and ports (MVP T1.1, mos-0974, Vikunja #414) | darkwing; reviewers filbert | #1688 | done | — | Filbert approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the design mutants killed (sage) | `docs/plans/2026-10-20_multi-instance.md` § Brief: several Mosaic Stack instances on one host | Landed 00d4cc1f6 on refactor (base 99a406ed3). Patch-id 5ec610a7 matches git diff 176a6bf4b..df65a3d4c on the 25 manifest paths. Manifest cc0ea9e8, Filbert approve r3 29713. Gate on d0bbc5c1c: packages 2664/0, scripts 429/0. Mutants 20/20. Unpushed. |
| 191 | one kill switch for the bus, every run, Discord and new launches, from the CLI and the Console (MVP T1.4, mos-0974, Vikunja #414) | rocko; reviewers dewey | #1689 | in-progress | — | Dewey approves DESIGN.md then the manifest; cli, bus, discord and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_kill-switch.md` § Brief: one kill switch for the bus, every run, Discord and new launches | — |
| 191 | one kill switch for the bus, every run, Discord and new launches, from the CLI and the Console (MVP T1.4, mos-0974, Vikunja #414) | rocko; reviewers dewey | #1689 | done | — | Dewey approves DESIGN.md then the manifest; cli, bus, discord and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_kill-switch.md` § Brief: one kill switch for the bus, every run, Discord and new launches | Landed 0fa7bf421 fast-forward on 86f95bb27 (10 commits, 89 paths; manifest 0bcbf337 = 88 paths without BUILD.md). Dewey manifest APPROVE c30359 rev 3118. Gate: 77 mutants killed (75 assert + SR08, KS54 timeout with controls), suites green on Sage's window; N7 closed by runs suite 43/0/0 on 0fa7bf421. N6, clock fix, :52 assertion, generic cause go to Vikunja 502 (mos-1267/1268). Rebase next: 205, 236, 194, 253, 274, 268. |
| 192 | Q175 phase 1: the Console verifies SSO itself (--sso), OIDC RP with oauth4webapi 3.8.8, no live change (MVP T1.2, #412) | launchpad; reviewers webby | #1690 | done | — | Webby approves the manifest; webui and cli suites, scripts/test-config.sh and every scripts/test-*.sh green on Sage's gate; design section 9 mutants killed (sage) | `docs/plans/2026-10-20_sso-phase1.md` § Brief: Q175 phase 1, the Console verifies SSO itself (--sso) | Landed 4429a104f on refactor (squash of launchpad/row192 53bb59462..37582e820, round 2 manifest 6f8cee14...7787). patch-id 4a4869026 matches the reviewed diff on all 18 paths. Gate on 088087ac9: package suites 2758/0, scripts 429/0, mutants 22 of 23 killed (#23 equivalent, sage-1407). npm ci in canonical, oauth4webapi 3.8.8. Live re-pin is a window with Mos (mos-1099 item 3). |
| 193 | many humans per business, human roles, private and shared threads, one access.json store (MVP T1.3, Vikunja #414/#415) | morgana; reviewers duckworth | #1691 | in-progress | — | Duckworth approves DESIGN.md then the manifest; bus, business, cli and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users.md` § Brief: many humans per business, human roles, private and shared threads | — |
| 194 | agent lifecycle from the Console and the CLI (create, launch, terminate, regenerate, delete) with a per-agent hire/fire veto (MVP T1.5, Vikunja #414/#415) | honker; reviewers webby | #1692 | in-progress | — | Webby approves DESIGN.md then the manifest; bus, cli, business and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_agent-lifecycle.md` § Brief: agent lifecycle from the Console and the CLI, with a per-agent hire/fire veto | sage-1610 (honker-0049): a human add takes tracker {bot, botId}, required for a vikunja definition, refused otherwise, checked by checkBot and bot uniqueness; CLI and API only. Delete accepts a tracker bot when all credentials are store refs, and its result names the bot for removal by hand in Vikunja. No Vikunja account is created (Q188 untouched). Mutants: drop the requirement, refuse the bot on delete, skip checkBot on add. |
| 194 | agent lifecycle from the Console and the CLI (create, launch, terminate, regenerate, delete) with a per-agent hire/fire veto (MVP T1.5, Vikunja #414/#415) | honker; reviewers webby | #1692 | done | — | Webby approves DESIGN.md then the manifest; bus, cli, business and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_agent-lifecycle.md` § Brief: agent lifecycle from the Console and the CLI, with a per-agent hire/fire veto | Landed fd6dd327c on refactor, a fast-forward on e122aad8e. b18 is b17 3f451772d rebased: 16 commits =, series patch-id 64303253f8e6, packages equal b17. Webby approve webby-0120 on manifest 29f131ab. Honker gate on fd6dd327c: runb8 39 files 503/0/0, mutants 17/17; gate.sh 18 packages 3668/0, ten scripts rc=0, Docker up, skips environment-only and named. Overlap with 13 and 257 clean. #1692 closed. |
| 195 | row 195: the Case 1 install on 10.1.1.53 as mosaic under /srv/mosaic, reusing C2-13's scripts by input under row 190's canon (Q181, Q182/Q183, Q185; ops-01 root steps under #423 step 1) | darkwing; reviewers duckworth | #1693 | done | 190 done, 226 done, 233 done | Duckworth approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_hub-own-uid.md` § Brief: the Case 1 install on 10.1.1.53 (row 195) | LANDED b65014647 (pushed 361ba2387 tip). Patch-id 656e43ff25de = 4ced5e63d..37171f65b; verify-commit ok, 5 paths. Combined gate with 213 on 38b83ea5c+paths 09:45:52-09:57:52Z: business 200/0 bus 542/0 harness 140/0 cli 663/0, all 10 scripts green, L1 killed. N1-N3 to DEFERRED next; follow-up darkwing/row195-n1 1e3c4767c. |
| 196 | the human boundary across uids and humans: Console, tester listener, broker.sock and the path-bound human proof (P19 333, before federation R3, mos-0989) | gander; reviewers duckworth | #1694 | done | — | Duckworth approves DESIGN.md then the manifest; bus, webui and cli suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-boundary.md` § Brief: the human boundary across uids and humans (P19 333) | Landed 7247d80c7 on 666173292: 39 manifest paths of 527b9a1e6 (CANDIDATE.sha256 537d30ae), patch-id 6f3777d593e6 matches f48725cfc..527b9a1e6 on the manifest, blob diff empty, tree matches merge-tree. Gate gander-0066 2334/0/1, all 10 shells, 38/38 mutants. Approved rev 2973 and rev 2986 (Duckworth, c30156). Overlap: 202 cli README and 194 Honker branches rebase onto it. BUILD-LOG and C1 DEFERRED follow doc-only. |
| 197 | a second gate lane: two full gates at once on dragon-lin, design first (mos-0977 item 6) | herb; reviewers webby | #1695 | done | — | Webby approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate, singly and in the two-lane proof (sage) | `docs/plans/2026-10-20_second-gate-lane.md` § Brief: a second gate lane, two full gates at once on dragon-lin | Landed a36523713 on refactor (base 5afff5490, pushed 18:40Z): Herb's c45ddeb6b cherry-picked. Range-diff = (only the cherry-pick line), patch-id 79c46b98c685 equals c45ddeb6b and candidate A d2de56bd2; 37 paths equal, marker test absent (mos-1174 check). Proof 6 PROOF_PASSED 18:40:06Z, sage-r197p6: 8 waves 26/26 steps, cb A 124 B 125, probe ok, no event or cleanup errors, final CLEAR/CLEAR. Webby approved r8 (rev 2867). |
@@ -238,10 +238,10 @@ Generated from `docs/plans/queue.json` revision 3102 by `queue render`. Do not e
| 199 | MVP T1 item 6: a credential store per agent, own git identity, pushes to a remote (G11, G13; mos-0974) | megavolt; reviewers duckworth | #1697 | done | — | Duckworth approves DESIGN.md; Mos rules on the authority model; Duckworth approves the manifest; bus, business, cli and tasks suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: a credential store per agent, own git identity, pushes to a remote | Landed cb5fdb064 on refactor (base 76134dfde), pushed. Patch-id 828fa21a matches git diff 00d4cc1f6..86963d281 on the 37 manifest paths; docs/TOOLS.md 3-way with row 200, hunk unchanged. Manifest 35f900fd, Duckworth approve r1 29779. Gate on 9e1085816+patch: packages 2735/0, scripts 429/0; landed blobs equal the gate tree. |
| 200 | stack-release.sh activate --sso for the Q175 phase 2 window (row 192 F3; chained on 192) | launchpad; reviewers webby | #1698 | done | — | Webby approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_stack-release-sso.md` § Brief: stack-release.sh activates a Console with --sso (Q175 phase 2 prerequisite) | Landed 9e1085816 on refactor (base 722136772), pushed. Patch-id 7ebfa6a5 matches git diff 00d4cc1f6..9df40878f on the 3 manifest paths. Manifest b3ead55c, Webby approve r3 29772. Gate on 9df40878f: packages 2675/0, scripts 429/0. Mutants 13/14, M12 ruled equivalent (sage-1446). |
| 201 | FED R1: access.json loader and validator, and covers (#414) | gizmoduck; reviewers duckworth | #1699 | done | — | Duckworth approves the manifest; business suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R1, the access.json loader and validator | Landed afad4993f on refactor (squash of gizmoduck ae3c36f24..44e36fc4c, manifest 225597146daf...0c520). patch-id ba604ad5e matches the reviewed diff on all 3 paths. Gate on 44e36fc4c in the slot (gizmoduck-0060): business 173/0, 10 scripts 422/0, mutants 44/44. Duckworth approve 29581. |
| 202 | FED R5: mosaic access list, grant and revoke (#414) | quackerjack; reviewers duckworth | #1700 | in-review, round 2, request posted | — | Duckworth approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R5, mosaic access list, grant and revoke | Chained on row 201's candidate (Q177), not its done: claimable now, lands after 201 and rebases onto its landing (Sage) |
| 202 | FED R5: mosaic access list, grant and revoke (#414) | quackerjack; reviewers duckworth | #1700 | done | — | Duckworth approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R5, mosaic access list, grant and revoke | Chained on row 201's candidate (Q177), not its done: claimable now, lands after 201 and rebases onto its landing (Sage) |
| 203 | FED R2: the federation key in the system config (#414) | quackerjack; reviewers webby | #1701 | briefed | — | Webby approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R2, the federation key in the system config | Chained on row 192's candidate (Q177): starts on 192's candidate, lands after 192 (Sage) |
| 204 | 193a: access level check widened to viewer (#1691) | morgana; reviewers duckworth | #1702 | done | — | Duckworth approves the manifest; business suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193a, the access level check widened to viewer | Landed d0bbc5c1c on refactor (base c9f374284). Patch-id 92c18aea matches git diff 44e36fc4c..f869fd062 on the 3 manifest paths. Manifest d38de8c9, Duckworth approve 29595. Gate 2789/0 on 4385b9ec1, mutants 7/7. Unpushed. |
| 205 | 193b1a: the message filter (#1703; FED R3 and 193b1b build on it) | morgana; reviewers duckworth | #1703 | briefed | 196 done | Duckworth approves the manifest; bus suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b1a, the message filter | — |
| 205 | 193b1a: the message filter (#1703; FED R3 and 193b1b build on it) | morgana; reviewers duckworth | #1703 | done | 196 done | Duckworth approves the manifest; bus suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b1a, the message filter | Landed ce6a96ad6 fast-forward on 0fa7bf421 (3 commits, 4 paths, manifest 858463b1). Duckworth APPROVE r1 c30375 (signed :124 test diff, mos-1262). Gate: Morgana slot205 10 files, bus 652/0, M1 M11-M14 killed, 0 skips; Sage test-*.sh 10/10, 3 by-design skips. N1/N3 to 205b or R3, N2 to 193b2 brief, N4 = #1782 before any user thread. Rebase next: 268 r4, 194, 236, 253, 274. |
| 206 | 193b2: thread shares, schema 3k-3l after the #436b build (#1691; live needs a --migrate window with Jason) | morgana; reviewers duckworth | #1704 | queued | 205 done, 234 done, 236 done, 275 done | Duckworth approves the manifest; bus suite, cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b2, thread shares (schema 3l) | — |
| 207 | 193c: mosaic access user verbs and --level viewer (#1691) | morgana; reviewers duckworth | #1705 | queued | 202 done | Duckworth approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193c, mosaic access user verbs and --level viewer | — |
| 208 | 193d: Console shared list, share controls and viewer read-only (#1691; also after FED R6 when filed) | morgana; reviewers duckworth | #1706 | queued | 192 done, 206 done | Duckworth approves the manifest; webui suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193d, the Console's shared list, share controls and viewer read-only | — |
@@ -272,7 +272,7 @@ Generated from `docs/plans/queue.json` revision 3102 by `queue render`. Do not e
| 233 | Installer I3: stack-upgrade.sh root driver and the ops-01 host run sheet (MVP, Q187, Vikunja #432) | darkwing; reviewers filbert | #1735 | done | 232 done | Filbert approves the manifest. The test file, the cli suite and every scripts/test-*.sh are green on Sage's gate, with the mutants killed. (sage) | `docs/plans/2026-10-20_installer-rows.md` § I3 the upgrade command and the host run sheet | Landed 57f59e0c3 on 5465fd52d (pushed 05:39Z). verify-commit matches the manifest (7 paths). Patch-id b16ac924 equals the candidate's (1a1dc80ca vs e65a3ab4d). Modes match. Sage's gate on the landing tree: cli 617/0, scripts/test-*.sh 585/0. Filbert N1, N4 and N5 go to row 240, N2 and N3 to DEFERRED. |
| 234 | Machine grant row a: host enrollment, store records and migration 3i-3j (#436a, design r6 f9039682e, lands by 10-25 before the .53/.54 installs) | gizmoduck; reviewers duckworth | #1740 | done | 210 done | Duckworth approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. The design's section 10 row a tests and mutants pass, the migration test included. (sage) (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row a: enrollment, store records and migration 3i-3j | LANDED c771a36f4 (pushed 06:16Z 10-21) on caa4b5944: r3 c783d5e39 41 paths via temp index, modes from ls-tree. patch-id 32bbbea2bd6f equals the candidate's; verify-commit ok 41 paths. Gate on the c783d5e39 tree 06:02:12Z-06:15:26Z: 15 packages 2890/0, ten scripts/test-*.sh 585/0. Duckworth r3 approve 29935 rev 2683. N10, N12 to row 235. |
| 235 | Machine grant row b: raise binding, executor, reconcile and revoke (#436, design r6 f9039682e, C1, N1, G27, G28) | gizmoduck; reviewers duckworth | #1741 | done | 234 done | Duckworth approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. Section 10's row b tests and mutants pass, G27, G28 and the upsert mutant included. (sage) (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row b: raise binding, executor, reconcile and revoke | LANDED b1fece6f0, pushed (rode Darkwing SESSIONS aa19a2464). Cand ed4193316, manifest dad377ca8fe4, 13 paths, verify-commit ok, patch-id c355d2fc457d. Gate 10:30:55-10:42:43Z: bus 596/0, business 200/0, cli 666/0, all scripts green. Brief Gate met: duckworth-0089 approve, upsert mutant equivalent, row b and creative tests and mutants on Gizmoduck slot runs (38 of 39 killed, B17 equivalent). |
| 236 | #436b Gitea grant path build: seat accounts and tokens through an approved grant, schema 3j-3k (design r5 1be535aca, due 10-27 at risk; live waits for Jason's section 8 approval and bootstrap step) | megavolt; reviewers dewey | #1742 | in-progress | 210 done, 234 done | Dewey approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. Section 9's mutants are killed, N9's restart test included. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: #436b Gitea grant path, build (schema 3j-3k) | — |
| 236 | #436b Gitea grant path build: seat accounts and tokens through an approved grant, schema 3j-3k (design r5 1be535aca, due 10-27 at risk; live waits for Jason's section 8 approval and bootstrap step) | megavolt; reviewers dewey | #1742 | done | 210 done, 234 done | Dewey approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. Section 9's mutants are killed, N9's restart test included. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: #436b Gitea grant path, build (schema 3j-3k) | Landed bd5f11ded on refactor, a fast-forward on be2b008cc. It is 08840f3c1 rebased past 194 (commit 1 unions only, 2-7 =) plus 2 stale-pin fixes and a gitea-not-configured raise test (sage-2559). Dewey approve on manifest 0b00269b. Megavolt gate under Sage CLEAR: 2c green, bus 765, business 228, cli 832, ten scripts rc=0, mutants 40/40; skips named, env-only. Overlap 13/257 clean. Schema 3j-3k. #1742 closed. |
| 237 | Row 199 follow-up N1: one decision publishes once (publish-busy or #spent recheck) | megavolt; reviewers duckworth | #1743 | done | 199 done | Duckworth approves the manifest. The bus suite and every scripts/test-*.sh are green on Sage's gate, and the mutant is killed. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: row 199 follow-up N1, one decision publishes once | Landed d7d943199 on refactor (local, push pending), patch-id c5f4beb4 = e42415935, manifest bb777cb0 blobs checked. Sage gate 01:31-01:44Z on 816d48b03+4 paths: M1 killed (bus 1/13, cli 1/8), every package suite and scripts/test-*.sh green (bus 478, cli 564). |
| 238 | row 200 follow-up N1: the sso-config hint carries --instance NAME (#1744) | launchpad; reviewers webby | #1744 | done | 200 done | Webby approves the manifest; stack-release.test.mjs whole, cli suite and every scripts/test-*.sh green on Sage's gate; the mutant killed (sage) | `docs/plans/2026-10-20_stack-release-sso.md` § Brief: row 200 follow-up N1, the sso-config hint names the instance | Landed d49b1f2bd (pushed 07:25Z) on 86249ac55: the 3 manifest paths from 4c90099ea. Patch-id b9d0bb0ee3c7 equals the candidate's; modes and blobs match; verify-commit ok (3 paths). Sage's combined 228+238 gate on c515273db plus both rows' 6 paths, 07:11:30Z to 07:23:34Z: seat 165/0, cli 640/0, all ten scripts/test-*.sh 585/0. Mutant (sso-config drops --instance) killed, survived none. Round 1 N1 stays on the #1744 carry list. |
| 239 | Row 194 follow-up: the credential revoke runs under the writer's lock, refusals after a revoke say so, and a stale-revision fire test (duckworth-0074, mos-1158) | honker; reviewers duckworth | #1745 | queued | 194 done | Duckworth approves the manifest. The bus, business, cli and webui suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) (sage) | `docs/plans/2026-10-20_agent-lifecycle.md` § Brief: row 194 follow-up, the revoke under the writer's lock and a fire test | — |
@@ -292,7 +292,7 @@ Generated from `docs/plans/queue.json` revision 3102 by `queue render`. Do not e
| 253 | F27: case2-unit.sh and case1-accept.sh move to --tester-socket and refuse --tester-port with stack-release's migration wording; no .53/.54 copy with 196 until this lands (mos-1198 item 4) | gander; reviewers duckworth | #1758 | in-progress | 196 done | reviewer approves DESIGN.md then the manifest; cli and webui suites whole and every scripts/test-*.sh on Sage's slot; mutants per DESIGN.md; static check covers scripts and tests (sage) | `docs/plans/2026-10-21_tester-socket-scripts.md` § Brief: case2-unit.sh and case1-accept.sh move to --tester-socket (F27) | — |
| 254 | Library sync follow-up: client timeout, STATUS codes, README (222 deferrals, #1759) | megavolt; reviewers dewey | #1759 | done | 196 done | Dewey approves the manifest; bus, library and webui suites whole and every scripts/test-*.sh green on Sage's slot; the timeout and STATUS mutants killed (sage) | `docs/plans/2026-10-21_library-sync-followup.md` § Gate | Landed 331597709 (candidate b31300d79 blobs and modes on 41b0cf755; patch-id 96216216 equal, range-diff differs only in the message) plus doc eb9672c91 (Dewey N1 README sentence closed; N2 four 502 codes to DEFERRED, binding the Console library routes row). Merged-tree rerun on Sage's slot 23:44-23:55Z: library 77/0, bus 628/0, webui 305/0 with 1 skip, cli 749/0. Megavolt's gate megavolt-0102 covers scripts and both mutants. |
| 255 | Bundled Pocket ID for users with no OIDC provider, design (Q203, #1760) | launchpad; reviewers webby | — | queued | 252 done | Webby approves DESIGN.md; design only, build rows follow after Sage checks it against Q203, O6, REQ-WEB-2 and REQ-INST-1 (sage) | `docs/plans/2026-10-21_bundled-pocket-id.md` § Gate | Vikunja 458, Gitea #1760, mos-1202 item 2. REQ-INST-2 lands in PRD 1.10 as post-v1 (mos-1204). Design only: no install, no download, no .53/.54, dragon-lin keeps Authentik. |
| 256 | the human deletes a creative instance; any other actor refused, fire unchanged (REQ-COS-3, before the v1 tag) | honker; reviewers webby | #1761 | briefed | 194 done | Webby approves the manifest; touched test files whole plus business, bus, cli and webui suites and every scripts/test-*.sh green on Sage's slot; both mutants killed (sage) | `docs/plans/2026-10-21_creative-delete-human.md` § Brief: the human deletes a creative instance (REQ-COS-3, after row 194) | — |
| 256 | the human deletes a creative instance; any other actor refused, fire unchanged (REQ-COS-3, before the v1 tag) | honker; reviewers webby | #1761 | in-progress | 194 done | Webby approves the manifest; touched test files whole plus business, bus, cli and webui suites and every scripts/test-*.sh green on Sage's slot; both mutants killed (sage) | `docs/plans/2026-10-21_creative-delete-human.md` § Brief: the human deletes a creative instance (REQ-COS-3, after row 194) | — |
| 257 | Q198 S1: pressure parser and pure rules evaluator (post-v1) | morgana; reviewers filbert | #1762 | in-review, round 2, request posted | — | Reviewer approves the manifest; touched test files whole plus the package suite green on Sage's slot; both brief mutants killed (post-v1, MVP keeps slot priority) (sage) | `docs/plans/2026-10-21_post-v1-row-briefs.md` § Brief: Q198 S1 pressure parser and pure rules evaluator | — |
| 258 | Email E01: mail PRD addendum draft and schemas (post-v1) | unassigned | #1763 | queued | — | Reviewer approves the manifest; the row's named tests whole plus the mail suite and touched suites green on Sage's slot; reviewer-named mutants killed (post-v1, MVP keeps slot priority) (sage) | `docs/plans/2026-10-21_post-v1-row-briefs.md` § Brief: E01 mail PRD addendum draft and schemas | — |
| 259 | Email E03: mail ledger, state and quotas (post-v1) | unassigned | #1764 | queued | 258 done | Reviewer approves the manifest; the row's named tests whole plus the mail suite and touched suites green on Sage's slot; reviewer-named mutants killed (post-v1, MVP keeps slot priority) (sage) | `docs/plans/2026-10-21_post-v1-row-briefs.md` § Brief: E03 mail ledger, state and quotas | — |
@@ -304,14 +304,20 @@ Generated from `docs/plans/queue.json` revision 3102 by `queue render`. Do not e
| 265 | Case 2 C2-09b rooms: room.open, room.send with @tags, room.read | rocko; reviewers dewey | #1770 | queued | 219 done | Dewey approves the manifest. The brief's tests run whole plus the bus suite and every touched suite on Sage's slot. Grep for hash pins on changed files before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-09b rooms: room.open, room.send with @tags, room.read | — |
| 266 | Case 2 C2-10c Console room view: merged timeline and @name completion | researcher; reviewers dewey | #1771 | queued | 225 done, 265 done | Dewey approves the manifest. The webui suite is green on Sage's gate, along with every touched suite. Grep for hash pins on changed files before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-10c Console room view: merged timeline and @name completion | Haiku high subagent under decision 129. |
| 267 | Case 2 C2-15 hybrid universes: exclusions, pins and the end-to-end crossover | morgana; reviewers filbert | #1772 | queued | 216 done, 217 done, 224 done | Filbert approves the manifest. The brief's tests and the end-to-end crossover run whole plus every touched suite on Sage's slot. Grep for hash pins on changed files before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-15 hybrid universes: exclusions, pins and the end-to-end crossover | R8 accepted (sage-2218). Webby's R8 noise note (gosalyn-0127) is for the builder. |
| 268 | Case 2 C2-01c playground levels: universe-level prefixes and draft universes | gizmoduck; reviewers webby | #1773 | in-review, round 2, request posted | — | Webby approves the manifest. The brief's tests run whole plus the library suite and every touched suite on Sage's slot; the brief's gate grep over packages and docs/case2 finds no library-playground-outside-book. Grep for hash pins on changed files before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-01c playground levels: universe-level prefixes and draft universes | MVP first claim (mos-1240). Lands before C2-05a (216) and C2-06 (217) read classify; if either has a candidate first, the change goes into it too (gosalyn-0134). Code library-playground-other-universe accepted (sage). |
| 268 | Case 2 C2-01c playground levels: universe-level prefixes and draft universes | gizmoduck; reviewers webby | #1773 | done | — | Webby approves the manifest. The brief's tests run whole plus the library suite and every touched suite on Sage's slot; the brief's gate grep over packages and docs/case2 finds no library-playground-outside-book. Grep for hash pins on changed files before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-01c playground levels: universe-level prefixes and draft universes | Landed c27207f6e fast-forward on ce6a96ad6 (1 commit, 9 paths, digest caa0fa4e). r4 is a carry of r3 aca53c4f0: range-diff =, patch-id 2942acc8 equal, blobs equal. Webby APPROVE round 2 on ecd9b35eb stands; carries webby-0112 (r3) and webby-0113 (r4, notes 34e1ab2bf). Gate: Gizmoduck r4 rerun brief 14/14, library 83/83, business 209/209, 0 skips; gate grep empty. 194 rebases next (business.test.mjs). |
| 269 | 264b T1 item 7: per-session native store, workspace and single-writer fence | gizmoduck; reviewers duckworth | #1774 | briefed | 264 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Tests T1 run half, T2 workspace and path mutants; whichever of 191 and 264b lands second wires 191's admission checks (Q5). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | — |
| 270 | 264c T1 item 7: compaction count, exact for Pi | gizmoduck; reviewers duckworth | #1775 | briefed | 269 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Test T3 Pi half; Claude shows unknown with the Vikunja 466 reason (Q6). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | — |
| 271 | 264d T1 item 7: clear context through row 194's lifecycle stop | gizmoduck; reviewers duckworth | #1776 | briefed | 194 done, 269 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Tests T4 and T6; rebased onto 194's landing with no 194 code copied (Q4). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | — |
| 272 | 264e T1 item 7: mosaic session CLI and Console chooser | gizmoduck; reviewers duckworth | #1777 | briefed | 264 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Tests T5 legacy single human and T7 Console half; controls for unlanded verbs refuse with a reason (Q1, Q7). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | — |
| 273 | Q175 phase 2: stack-release.sh activate --send and --no-sso (F39, mos-1246) | darkwing; reviewers filbert | #1778 | briefed | — | Filbert approves the manifest. stack-release.test.mjs and stack-release-external.test.mjs run whole plus scripts/test-release.sh on Sage's slot, and the brief's mutants are killed. (sage) | `docs/plans/2026-10-22_activate-send-nosso.md` § Brief: activate --send and --no-sso | — |
| 274 | FED R3: broker user principal (#414) | morgana; reviewers dewey | #1779 | briefed | — | Dewey approves the manifest. The bus and harness suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_federation-r3.md` § Brief: FED R3, the broker user principal | — |
| 273 | Q175 phase 2: stack-release.sh activate --send and --no-sso (F39, mos-1246) | darkwing; reviewers filbert | #1778 | done | — | Filbert approves the manifest. stack-release.test.mjs and stack-release-external.test.mjs run whole plus scripts/test-release.sh on Sage's slot, and the brief's mutants are killed. (sage) | `docs/plans/2026-10-22_activate-send-nosso.md` § Brief: activate --send and --no-sso | landed 0bbf0f6d1 03:35:55Z, FF of 378a76b85; rebase of cac7d0f67, 6 range-diff pairs =, patch-ids equal, manifest e1031023 unchanged. Gate by Darkwing on 0bbf0f6d1: stack-release.test 218/218, external 25/25, test-release.sh rc 0, T58 c0 pass c1 fail, M1-M7 killed. Filbert APPROVE rev 3111 c30308, carry c30319. Docs 5365adaef (TOOLS.md N1 + BUILD-LOG). #1778. |
| 274 | FED R3: broker user principal (#414) | morgana; reviewers dewey | #1779 | done | — | Dewey approves the manifest. The bus and harness suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_federation-r3.md` § Brief: FED R3, the broker user principal | LANDED 4c485c63b ff from 47a35bf43 (6 Morgana commits). Dewey APPROVE c30487 on 4c485c63b (manifest 00047b52, 13 paths). Gate 10:09-10:22Z: 23 touched files 0 fail, bus 787, harness 151, business 228, cli 832, seat 167, 10 scripts rc=0 (skips test-queue 1, test-task 2), 22/22 mutants. M3x wall code not-holder (kit comment said human-required). r5 f58cfc361 red, void. Overlap 13/257 clean. |
| 275 | 193b1b: viewer verbs and board refusals (also after FED R4 when filed) | morgana; reviewers duckworth | #1780 | briefed | 205 done, 274 done | Duckworth approves the manifest. The bus suite and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b1b, viewer verbs and board refusals | — |
| 276 | Row 253 F5: supervise the legacy AS_USER talk and diagnostics in the case scripts | gander; reviewers duckworth | #1781 | briefed | 253 done | Duckworth approves the manifest. The touched test files run whole, every scripts/test-*.sh is green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_case-sudo-legacy-followup.md` § Brief: supervised legacy AS_USER calls | — |
| 277 | The owner sees a thread agent's claims: an owner or reader read lists held thread claims (10-27, mos-1262; no user thread live before it) | morgana; reviewers duckworth | #1782 | in-progress | 205 done | Duckworth approves the manifest. The touched test files run whole, the bus and cli suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_owner-thread-claims.md` § Brief: the owner sees thread claims | — |
| 278 | The Console Agents view shows thread claims (follow-up to row 277; no user thread live before it) | gander; reviewers webby | #1783 | briefed | 277 done | Webby approves the manifest. The touched test files run whole, the webui and bus suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_owner-thread-claims.md` § Brief: the Console shows thread claims | — |
| 279 | stopRun reads a live run's status at the deadline: the deadline branch is the only time exit (191 follow-up, MVP, mos-1269; lands before 194 b16) | rocko; reviewers dewey | #1784 | done | — | Dewey approves the manifest. The processing-control package's tests and every other touched test file run whole, the business and cli suites are green on Sage's gate, and M1 is killed. (sage) | `docs/plans/2026-10-22_stoprun-deadline.md` § Brief: stopRun's deadline branch is the only time exit | LANDED ceadad993 (carry of cad927137 onto 343b6dd76: range-diff 1:= 2:=, patch-ids 6d8ae7f4/e484c297 equal, Dewey APPROVE dewey-0809 c30409 + carry). Fix: stopRun loop is for(;;), deadline branch is the only time exit. Sage gate green: business 209/0/0, cli 794/0/0, discord processing+stop 9/0/0; Rocko stop-run 13/13, processing-control 62/62, M1 killed. Overlap sweep clean (13, 257). Next: 194 b16 rebases onto it (mos-1269). |
| 280 | Bus tests: the scratch sshd proves it owns its port (194 gate follow-up, MVP, mos-1270) | rocko; reviewers duckworth | #1785 | done | — | Duckworth approves the manifest. Both files run whole, the bus suite is green on Sage's gate, the 30-run repeat has no timeout, and M1 is killed. Row 194 b17's full gate then runs on top. Any red goes to Mos (mos-1270). (sage) | `docs/plans/2026-10-22_sshd-port-race.md` § Brief: the scratch sshd's port is ours before a test uses it | Landed b0fd28b6f 07:57:30Z, ff from da5d3651d: b765f5cb8 (Duckworth approve rev 3153, manifest 27806c05) + slim packet. Sage gate bus 656/0/0, ge 41, hr 26 (c30431). Repeat 2010/2010 under cli 1588/1588, no timeout; M1/M2 killed 4/4. Full evidence d6c924764 on rocko/row280-da5d-20261022. N2-N4 carry in BUILD.md. 194 b17 rebases on b0fd28b6f. |
| 281 | A fire or a delete revokes the instance's host and Gitea grants (194/236 follow-up, MVP, mos-1271) | megavolt; reviewers dewey | #1786 | in-progress | 236 done | Dewey approves the design, then the manifest. Bus, business, cli and every touched test file run whole, plus the Docker gitea-grant test and every scripts/test-*.sh, green on Sage's gate. M1-M4 killed. Lands before .53 Case 1 hires a granted instance. Red goes to Sage, then Mos. (sage) | `docs/plans/2026-10-22_fire-revokes-grants.md` § Brief: no grant outlives its instance | — |
<!-- mosaic-queue:end -->
+2662 -55
View File
File diff suppressed because one or more lines are too long
+48 -4
View File
@@ -225,6 +225,11 @@ an operator inspect content and delivery evidence after an uncertain result.
| `hosts.remove` | `{host}`; the human or the tester. Deletes the record, writes `grant.host.removed` and removes the installer key. A `host_grants` record for the host is `host-in-use`, a guarded event naming a host line left on the host `host-line-left`. |
| `hosts.revoke` | `{agent,host,reason}`; the human, the business's assistant, or the tester when `bus.tester` is exactly `true` (row 235, #1741, r6 section 6). Anyone else gets `revoke-refused`. The tester check reads `bus.tester` again at the call, `tester-dev-only`. One transaction deletes the `host_grants` record and writes `grant.revoked` with `hostLine:"pending"`. With no await between, the key leaves memory and its file goes, and a `host.run` or executor in flight aborts. Then `remove <user> <tag>` runs on the host and `grant.revoke.closed` records `hostLine:"removed"` or `hostLineLeft`. A revoke with no record still runs, with `granted:false`. Returns `{agent,host,tag,granted,keyFile,hostLine or hostLineLeft}`. A restart with a pending marker runs the remove once the bus is up; `hosts.status` lists its tag under `revoking`. |
| `hosts.sweep` | `{host}`; the human or the tester (r6 X3). Runs `list <user>` and removes each listed tag this business recorded for the host in a `grant.step` or `grant.revoked` that no live grant holds. Another business's line and an unrecorded `mosaic-` line stay. Writes `grant.host.swept {host,removed,left}`; `hostLineLeft` then counts from it. Returns `{host,removed,hostLineLeft}`. |
| `grants.status` | `{service:"gitea"}`; the human, the tester or the chief (row 236, #1742, #436b r5). The admin slot's url, account and rotate-by date with `rotationDue`, `adminTokenLeft`, the `gitea_users` and `gitea_grants` records, approved decisions not yet run (`spentAtNextStart`), grants `running` or `settlePending`, agents held as `tokenUnknown`, the `serviceLeft` entries a sweep retries, and revokes still `revoking`. No token. |
| `grants.run` | `{decision}`; the human only. Starts the Gitea executor of a `credential.mint` decision with a gitea grant the human said yes to, as the yes itself does (X1). Step 0's refusals come back with no record: `not-authorized`, `decision-consumed`, `grant-token-unknown`, `grant-busy`, `gitea-not-configured`. Returns `{decision,agent,started:true}`; the outcome is a `grant.applied` or `grant.failed` and a notice. |
| `grants.revoke` | `{service:"gitea",agent,reason}`; the human, the business's assistant, or the tester when `bus.tester` is exactly `true`. The token leaves memory and its file goes, an executor in flight aborts, then one transaction deletes the `gitea_grants` record and writes `grant.revoked` with the token ids to delete. The deletes run with the admin token after C1 to C4, and `grant.revoke.closed` records `serviceLeft`, a list. `credentials.revoke AGENT gitea` and `credentials.delete` do the same for a stack-made user; delete also deletes every token of the user and sets `prohibit_login` (fire). |
| `grants.sweep` | `{service:"gitea"}`; the human or the tester. Closes open revokes, settles open rotations again, and retries each `serviceLeft` entry. A fire retry (`tokens`, `login`) first checks the user's id as the revoke close does, and a gone or remade user is skipped with `gitea-user-gone` or `gitea-user-foreign`. Writes `grant.sweep` and `grant.swept {left,skipped,settled,closed}`. |
| `grants.admin.set`, `grants.admin.rotate`, `grants.admin.remove` | The human only (#436b r5 section 3). set takes `{service,url,account,rotateBy,token}`, checks C1 to C4 with the token, then writes the slot under `<configDir>/secrets/grants/<business>/gitea`; a slot already there is `grant-admin-exists`. rotate `{service,rotateBy}` mints a successor admin token and names the old one in `adminTokenLeft`; `{service,finish:true}` deletes it. Rotation voids the approved decisions not yet run, as their `adminSha256` no longer matches. remove drops the slot. Each writes `grant.admin` with no secret. A left admin token that set or remove drops from `adminTokenLeft`, or one shown as `unknown`, has to be deleted by hand at Gitea. A running executor, revoke close or sweep keeps the admin api it started with: rotate voids only decisions not yet run, and remove takes effect from the next run. |
Each of the three leaves one receipt in the organization's `changes.jsonl`: the actor and a
`detail` of `{verb,agent,kind,date or reason}`, never a value. A change to the store alone writes
@@ -340,6 +345,17 @@ A new holder doesn't inherit the previous holder's unread deliveries;
decisions stay with the role. Unheld and released roles aren't listed, so a
message stranded for a role nobody holds shows once a run claims the role.
Thread claims (row 277, #1782): a thread run's `role.claim` claims its thread
in `thread_claims`, which `agents` doesn't read, so `agents` lists roles only.
`thread.claims` takes no arguments and lists the held thread claims in the
business, oldest thread first, as `{thread, instance, holder_run, harness,
at}`. A claim is held while its thread's latest `thread_claims` row is a
claim, so a released or revoked thread drops out. Human and reader sessions
only: a role session refuses `human-required` and the tester
`tester-refused`. The result carries the thread id but never its name, which
a user wrote, nor any message. `mosaic agents` prints these as `thread` lines
after the roles; `mosaic agents --json` and `--tester` print `agents` alone.
Unknown keys, roles, references, actions and verbs refuse. If writing refusal
evidence fails, the caller receives the static `storage-unavailable` code. `human` is the reserved
recipient. Message classes are REQUEST, ASSIGNMENT, REVIEW-REQUEST, REVIEW-RESULT,
@@ -711,10 +727,10 @@ scope probes and rotation/expiry notifications; S2 never probes real services.
## Schema and verification
`schema.sql` is v3j, SHA-256
`0a7b633ce7eec9d0bb37c3255f4244d97bd0c8eac38db91a11b4ef6cc982ec8c`.
It has 18 tables, 72 triggers and five views. Source digest and a trusted full
sqlite_master digest are checked at open, alongside schema version `3j`, WAL,
`schema.sql` is v3k, SHA-256
`aba9240825a35fdcc741439d0708efa33afcf519d11cac9952c7207ad99250f0`.
It has 20 tables, 72 triggers and five views. Source digest and a trusted full
sqlite_master digest are checked at open, alongside schema version `3k`, WAL,
quick_check and foreign-key integrity. Missing metadata refuses; it never
reinitializes an existing file. Full synchronous WAL transactions either commit
or roll back. SQL UPDATE, DELETE and INSERT OR REPLACE refuse on evidence tables.
@@ -882,6 +898,12 @@ step runs in one transaction, and a failure leaves the store at 3i. The 3i fixtu
`tests/fixtures/schema-3i.sql`, is pinned from 8fede5fc6's tree, and `tests/grant-migration.test.mjs`
covers the step and the trigger.
Schema 3k (row 236, #1742, #436b r5 section 7) adds only two tables: `gitea_users`, the Gitea
users the stack made (user, id and the nonce in their full name), and `gitea_grants`, the live Gitea
grants (user, token id and name, projects, rotate-by date and decision), whose key references its
`gitea_users` row. Each write goes in one transaction with a `grant.*` event. The step runs in one
transaction, and a failure leaves the store at 3j. `tests/gitea-migration.test.mjs` covers it.
The managed runner calls usage.begin before an actual harness turn and usage.end
after it, including a failed/stopped turn. Each takes only {turn: deliveredMessageId}.
The broker requires the bound role holder and its delivery, binds run identity,
@@ -935,3 +957,25 @@ narrow cross-instance reach, not reach within one instance.
The Console provenance option trusts verified code and process metadata, not an independent
bus-side OIDC exchange. That trade must be approved, not hidden behind the phrase zero trust.
## Durable instance processing control
`processing.status` is an authorized read. `processing.halt` ({}) and
`processing.resume` ({generation}) require the existing human proof before
validation or effects. They are management verbs independent of channels and
never tester or role grants. The strict state under the resolved dataRoot's
control/ directory is authoritative; reserved processing.halt/resume/stop
actions mirror its durable transitions and write-once stop receipts with
verified human and generation. Generic event writers cannot forge them. No
schema change is needed. Invalid state refuses work and leaves control reads
and human recovery reachable. Halt then resume repairs an invalid leaf; an
unsafe parent is not automatically repaired.
The bus remains a read/control service while new agent work, tracker polling,
notifications and launches pause. Narrow exit cleanup remains possible. Every
managed starter carries a generation and publishes identity before releasing
work. Stops inspect root/boot/start identity or generated container id/labels,
TERM concurrently, then KILL after five seconds and observe for two more.
Unknown targets remain visible and prevent resume. Existing run evidence is
not synthesized or rewritten. First activation stops old processing before
trusted initialization and aligns the copy and image in the human's window.
+21
View File
@@ -394,3 +394,24 @@ CREATE TRIGGER events_grant_actor BEFORE INSERT ON events
AND (json_extract(NEW.body, '$.action') GLOB 'grant.*' OR json_extract(NEW.body, '$.operation') GLOB 'grant.*')
AND NOT (NEW.actor_role IS NULL AND NEW.actor_run IS 'grant')
BEGIN SELECT RAISE(ABORT, 'only the grant writer records grant events'); END;
CREATE TABLE gitea_users (
business TEXT NOT NULL,
agent TEXT NOT NULL,
user TEXT NOT NULL,
id INTEGER NOT NULL CHECK (id > 0),
nonce TEXT NOT NULL CHECK (length(nonce) = 16 AND NOT nonce GLOB '*[^0-9a-f]*'),
decision TEXT NOT NULL REFERENCES decisions(id),
PRIMARY KEY (business, agent)
) STRICT;
CREATE TABLE gitea_grants (
business TEXT NOT NULL,
agent TEXT NOT NULL,
user TEXT NOT NULL,
token_id INTEGER NOT NULL CHECK (token_id > 0),
token_name TEXT NOT NULL,
projects TEXT NOT NULL CHECK (json_valid(projects) AND json_type(projects) = 'array' AND json_array_length(projects) > 0),
rotate_by TEXT NOT NULL,
decision TEXT NOT NULL REFERENCES decisions(id),
PRIMARY KEY (business, agent),
FOREIGN KEY (business, agent) REFERENCES gitea_users(business, agent)
) STRICT;
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -1,17 +1,18 @@
import { connect } from 'node:net';
import { BusError, PROJECT_SAVE_VERB, PROJECT_SAVE_REQUEST_MAX } from './broker.mjs';
export class Client {
constructor({ path, cap, human, tester, timeout = 5000 }) {
constructor({ path, cap, human, tester, user, timeout = 5000 }) {
this.path = path;
this.cap = cap;
this.human = human;
this.tester = tester;
this.user = user;
this.timeout = timeout;
}
call(verb, args = {}) {
const request =
JSON.stringify({
...(this.human ? { human: this.human } : this.tester ? { tester: this.tester } : { cap: this.cap }),
...(this.human ? { human: this.human } : this.tester ? { tester: this.tester } : this.user ? { user: this.user } : { cap: this.cap }),
verb,
args,
}) + '\n';
@@ -27,7 +28,7 @@ export class Client {
socket.destroy();
error ? reject(error) : resolve(value);
};
socket.setTimeout(this.timeout, () => end(new BusError('outcome-unknown')));
socket.setTimeout(['processing.halt', 'processing.resume'].includes(verb) ? Math.max(this.timeout, 20000) : this.timeout, () => end(new BusError('outcome-unknown')));
socket.on('connect', () => socket.write(request));
socket.on('error', () => end(new BusError('outcome-unknown')));
socket.on('end', () => end(new BusError('outcome-unknown')));
+65 -5
View File
@@ -116,20 +116,26 @@ export class Credentials {
#revoked = new Set();
// Row 210 (design D5, D9): instance:host.<id> -> the AbortController of the call in flight.
#inflight = new Map();
// Row 236 (#436b section 5): instance:service entries whose file memory never loads, the
// grant-token-unknown pairs. A revoke or a new issue clears one.
#held = new Set();
#storeRoot;
#repoRoots;
#dataRoot;
// storeRoot is <configDir>/secrets/agents (design D1); a reference with store: true must name the
// path under it for its own business, agent and service.
constructor({ references = {}, repoRoots = [], dataRoot, env = process.env, storeRoot = null }) {
// path under it for its own business, agent and service. `held` lists "instance:service" keys whose
// reference loads and whose file doesn't (#436b section 5).
constructor({ references = {}, repoRoots = [], dataRoot, env = process.env, storeRoot = null, held = [] }) {
this.#storeRoot = storeRoot;
this.#repoRoots = repoRoots;
this.#dataRoot = dataRoot;
for (const k of held) this.#held.add(k);
try {
for (const [instance, services] of Object.entries(references))
for (const [service, ref] of Object.entries(services)) {
const date = this.#checkRef(instance, service, ref);
this.#refs.set(instance + ':' + service, { ref, date });
if (this.#held.has(instance + ':' + service)) continue;
const value = this.#read(ref, env, service);
if (value !== null) this.#tokens.set(instance + ':' + service, { ...value, date });
}
@@ -319,14 +325,66 @@ export class Credentials {
this.#refs.set(instance + ':' + service, { ref: { service, store: true, file, [service === 'vikunja' ? 'expires' : 'rotateBy']: date }, date });
this.#tokens.set(instance + ':' + service, { ...value, date });
this.#revoked.delete(instance + ':' + service);
this.#held.delete(instance + ':' + service);
return this.status().find((e) => e.instance === instance && e.service === service);
}
// Row 236 (#436b section 5, settle forward): memory takes the store file as it is now, with the
// constructor's checks, and `date` becomes the entry's date. A held entry refuses.
reload(instance, service, date) {
const k = instance + ':' + service;
const { ref } = this.#known(instance, service);
if (this.#held.has(k) || !ref.store) deny('credential-reference');
if (!day(date)) deny('credential-date');
const value = this.#read(ref, {}, service);
if (value === null) deny('credential-file');
this.#refs.set(k, { ref: { ...ref, rotateBy: date }, date });
this.#tokens.set(k, { ...value, date });
this.#revoked.delete(k);
}
// Row 236 (#436b section 5, grant-token-unknown): memory drops the token, as revoke's memory step,
// and a call in flight aborts. The file stays for the human to look at, and nothing loads it again
// until a revoke or an issue.
hold(instance, service) {
const k = instance + ':' + service;
this.#known(instance, service);
this.#tokens.delete(k);
this.#held.add(k);
this.#inflight.get(k)?.abort();
}
held(instance, service) {
return this.#held.has(instance + ':' + service);
}
// Row 236 (#436b section 5, settle): `operation` gets the token the store file holds now, read with
// the constructor's checks, or null when there is no file. Memory is untouched. The result is checked
// as use() checks it.
async useFile(instance, service, operation) {
const { ref } = this.#known(instance, service);
if (!ref.store) deny('credential-reference');
const value = this.#read(ref, {}, service);
const result = await operation(value?.token ?? null);
this.assertClean(result);
if (value && JSON.stringify(result)?.includes(value.token)) deny('credential-leak');
return result;
}
// Row 236 (#436b section 5, a first grant's open commit at restart): the store file goes, and with it
// a token no record backs. An entry with a token in memory refuses: only a held entry is undone so.
unlinkStore(instance, service) {
const k = instance + ':' + service;
const { ref } = this.#known(instance, service);
if (this.#tokens.has(k) || !ref.store) deny('credential-reference');
try {
unlinkSync(ref.file);
} catch (e) {
if (e.code !== 'ENOENT') deny('credential-file');
}
}
// Memory first, then the store file (design section 6, D12). A file or env reference outside the
// store is never unlinked; the caller turns its reference into store, so a restart finds nothing.
revoke(instance, service) {
const { ref } = this.#known(instance, service);
this.#tokens.delete(instance + ':' + service);
this.#revoked.add(instance + ':' + service);
this.#held.delete(instance + ':' + service);
// Row 210 (design D9): a host.run in flight for this agent and host is killed first.
this.#inflight.get(instance + ':' + service)?.abort();
if (ref.store) {
@@ -425,9 +483,11 @@ export class Credentials {
date,
...(hostKey ? { fingerprint: hostKey.fingerprint, publicKey: hostKey.publicLine } : {}),
state: !this.#tokens.has(key)
? this.#revoked.has(key)
? 'revoked'
: 'not-issued'
? this.#held.has(key)
? 'held'
: this.#revoked.has(key)
? 'revoked'
: 'not-issued'
: service !== 'vikunja'
? left <= 0
? 'rotation-due'
+461
View File
@@ -0,0 +1,461 @@
// Row 236 (#1742, #436b design r5 at 1be535aca): the trusted half of the Gitea grant path, run in the bus
// child. The broker gets it as `giteaGrant` and holds every store write and every step's order. This module
// holds what touches the network or a secret file: the HTTPS calls to Gitea (each made through the
// broker's `log`, which writes grant.call before and grant.result after), the admin slot (section 3c), the
// binding's shape check (section 2), the admin checks C1 to C4 (section 3a), the start pre-load of held
// pairs (section 5) and `giteaKeys`, the broker's narrow handle on Credentials.
//
// The admin token lives at <configDir>/secrets/grants/<business>/gitea/admin, 0600 in 0700 dirs, with
// admin.json next to it ({url, account, rotateBy, setAt}, no secret). Both are written tmp, fsync, rename,
// as row 199's issue writes. Neither value reaches an event, a message or a log.
import { request as httpsRequest } from 'node:https';
import { createHash, randomBytes } from 'node:crypto';
import {
closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmdirSync, unlinkSync, writeSync,
} from 'node:fs';
import { join } from 'node:path';
import { BusError, GRANT_EVIDENCE } from './broker.mjs';
import { day, privateDir } from './credentials.mjs';
import { definitionSha256, grantTag } from './host-grant.mjs';
import { canonicalJson } from '../../business/src/util.mjs';
import { forkUrl, giteaName, ID_PATTERN, parseGiteaUrl } from '../../business/src/vocabulary.mjs';
const deny = (code) => {
throw new BusError(code);
};
const sha256 = (value) => createHash('sha256').update(canonicalJson(value)).digest('hex');
// Section 2: the seat token's scopes, fixed by code (row 199 A2). Section 3b: the admin token's, exactly.
// Both sorted, as Gitea reports scopes.
export const SEAT_SCOPES = Object.freeze(['read:user', 'write:repository']);
export const ADMIN_SCOPES = Object.freeze(['write:admin', 'write:repository', 'write:user']);
// F6: the child-scope cap first ships in v1.27.0.
export const MIN_VERSION = Object.freeze([1, 27, 0]);
// Step 5: pages of 50, at most 20 of them.
export const REPO_PAGE = 50;
export const REPO_PAGES = 20;
const TOKEN = /^[-A-Za-z0-9._~]+\n?$/;
const MAX_BODY = 4 * 1024 * 1024;
const CALL_MS = 15000;
const META_KEYS = ['url', 'account', 'rotateBy', 'setAt'];
const AT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/;
// C1: numbers, never strings, so 1.9.0 is below 1.27.0. A prerelease is below its release, so
// 1.27.0-rc0 refuses (F6). Build metadata after "+" is ignored. Anything that doesn't parse refuses.
const VERSION = /^v?(\d{1,6})\.(\d{1,6})\.(\d{1,6})(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/;
export function versionAtLeast(value, floor = MIN_VERSION) {
const m = typeof value === 'string' ? VERSION.exec(value) : null;
if (!m) return false;
const v = [m[1], m[2], m[3]].map(Number);
for (let i = 0; i < 3; i++) if (v[i] !== floor[i]) return v[i] > floor[i];
return !m[4];
}
// Section 2's binding. Anything off refuses invalid-request; the broker checks the agent, its definition,
// identity, publish list and the admin slot inside the raise transaction. rotateBy is a real calendar day
// after `today`, the bus clock's UTC day: the rule row 199's host grants use (hostGrantCheck), since 199
// names no other rotation window. Returns a fresh object with only the bound keys.
export function giteaGrantCheck(grant, today) {
const allowed = ['method', 'agent', 'rotateBy', 'target'];
if (!grant || typeof grant !== 'object' || Array.isArray(grant)) deny('invalid-request');
const own = Object.keys(grant);
if (own.some((k) => !allowed.includes(k)) || allowed.some((k) => !own.includes(k))) deny('invalid-request');
if (grant.method !== 'gitea') deny('invalid-request');
if (typeof grant.agent !== 'string' || !ID_PATTERN.test(grant.agent)) deny('invalid-request');
const target = grant.target;
if (!target || typeof target !== 'object' || Array.isArray(target) || Object.keys(target).join() !== 'projects') deny('invalid-request');
const projects = target.projects;
if (!Array.isArray(projects) || projects.length < 1 || projects.length > 50) deny('invalid-request');
if (projects.some((p) => typeof p !== 'string' || !ID_PATTERN.test(p)) || new Set(projects).size !== projects.length) deny('invalid-request');
if (!day(grant.rotateBy) || grant.rotateBy <= today) deny('invalid-request');
return { method: 'gitea', agent: grant.agent, target: { projects: [...projects] }, rotateBy: grant.rotateBy };
}
// Section 2: over the agent's identity, its publish list and the remote of every project in it, since
// step 5 checks against all of them. `projects` is the business's project map.
export const identitySha256 = (role, projects) =>
sha256({
identity: role.identity ?? null,
publish: role.publish ?? [],
remotes: Object.fromEntries((role.publish ?? []).map((p) => [p, projects?.[p]?.remote ?? null])),
});
// Section 2: over the admin slot's metadata, never the token.
export const adminSha256 = (meta) => sha256({ url: meta.url, account: meta.account, setAt: meta.setAt });
// Step 3: `mosaic-<tag>-<decision>`. grantTag already starts with "mosaic-".
export const tokenName = (business, agent, decision) => `${grantTag(business, agent, 'gitea')}-${decision}`;
export { definitionSha256 };
// Section 3d: one HTTPS call. Never rejects: no answer (refused, reset, TLS failure, timeout, a body over
// the cap) is status 0. A redirect is returned as its status, never followed. `auth` is {token} for the
// `token` header or {basic: [account, token]} (F1). The result is {status, json, total}, total from
// X-Total-Count; it holds no header and no request detail.
export function send({ url, method, path, auth, body, ca, timeoutMs = CALL_MS }) {
return new Promise((resolve) => {
let done = false,
req = null;
const end = (r) => {
if (done) return;
done = true;
clearTimeout(timer);
resolve(r);
};
const timer = setTimeout(() => {
req?.destroy();
end({ status: 0, json: null, total: null });
}, timeoutMs);
try {
const headers = {
accept: 'application/json',
authorization: auth.basic ? 'Basic ' + Buffer.from(auth.basic.join(':')).toString('base64') : 'token ' + auth.token,
};
const data = body === undefined ? null : Buffer.from(JSON.stringify(body));
if (data) {
headers['content-type'] = 'application/json';
headers['content-length'] = String(data.length);
}
req = httpsRequest(new URL(path, url), { method, headers, ca, agent: false }, (res) => {
const chunks = [];
let size = 0;
res.on('data', (c) => {
size += c.length;
if (size > MAX_BODY) {
req.destroy();
end({ status: 0, json: null, total: null });
} else chunks.push(c);
});
res.on('end', () => {
let json = null;
try {
json = size ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : null;
} catch {}
const t = res.headers['x-total-count'];
end({ status: res.statusCode, json, total: t === undefined || !/^\d+$/.test(t) ? null : Number(t) });
});
res.on('error', () => end({ status: 0, json: null, total: null }));
});
req.on('error', () => end({ status: 0, json: null, total: null }));
req.end(data ?? undefined);
} catch {
end({ status: 0, json: null, total: null });
}
});
}
const segment = (value) => encodeURIComponent(String(value));
// C1 to C4 (section 3a), each call through the api's log. Throws the refusal code. Returns the admin
// token's own name from C3 (F7), which rotate deletes by.
export async function adminChecks(api, account) {
const v = await api.version();
if (v.status !== 200) deny('gitea-failed');
if (!versionAtLeast(v.json?.version)) deny('gitea-too-old');
const self = await api.self();
if (self.status === 401 || self.status === 403) deny('grant-admin-account');
if (self.status !== 200) deny('gitea-failed');
if (self.json?.login !== account || self.json?.is_admin !== true) deny('grant-admin-account');
const t = await api.tokenScopes();
if (t.status !== 200) deny(t.status === 401 || t.status === 403 ? 'grant-admin-account' : 'gitea-failed');
const scopes = t.json?.scopes;
if (!Array.isArray(scopes) || [...scopes].sort().join() !== ADMIN_SCOPES.join()) deny('grant-admin-scope');
const r = await api.reposCheck();
if (r.status !== 200 || !Array.isArray(r.json)) deny('gitea-failed');
if (r.json.length !== 0 || (r.total !== null && r.total !== 0)) deny('grant-admin-not-dedicated');
return typeof t.json?.name === 'string' ? t.json.name : null;
}
// One token file's text, read with Credentials' checks: no symlink, a regular file the stack's user owns,
// 0600, at most 16 KiB, then the token format. null if `missing` and there is no file.
function readSecretFile(file, { missing = false } = {}) {
let fd;
try {
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
} catch (e) {
if (missing && e.code === 'ENOENT') return null;
deny('credential-file');
}
try {
const s = fstatSync(fd);
if (!s.isFile() || s.uid !== process.getuid() || (s.mode & 0o777) !== 0o600 || s.size > 16384) deny('credential-file');
return readFileSync(fd, 'utf8');
} catch (e) {
if (e instanceof BusError) throw e;
deny('credential-file');
} finally {
closeSync(fd);
}
}
export function checkToken(token) {
if (typeof token !== 'string' || token.length > 16384 || !TOKEN.test(token)) deny('credential-format');
const t = token.replace(/\n$/, '');
if (t.length < 16) deny('credential-format');
return t;
}
function writeFileAtomic(dir, name, text) {
const file = join(dir, name);
const tmp = file + '.tmp';
try {
unlinkSync(tmp);
} catch (e) {
if (e.code !== 'ENOENT') deny('credential-file');
}
let fd;
try {
fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
writeSync(fd, text);
fsyncSync(fd);
} finally {
if (fd !== undefined) closeSync(fd);
}
renameSync(tmp, file);
const dirFd = openSync(dir, constants.O_RDONLY);
try {
fsyncSync(dirFd);
} finally {
closeSync(dirFd);
}
}
// The trusted Gitea half. `ca` is a PEM the HTTPS calls trust instead of the system's, for the tests'
// fake Gitea; production passes none. `send` is replaceable only for the tests' failure rows.
export function giteaGrant({ configDir, ca = undefined, timeoutMs = CALL_MS, transport = send }) {
const grantsRoot = join(configDir, 'secrets', 'grants');
const slotDir = (business) => join(grantsRoot, business, 'gitea');
const slotDirs = (business) => [grantsRoot, join(grantsRoot, business), slotDir(business)];
// Section 3c: the slot's metadata, or null when there is no slot. A slot dir or file that isn't the
// stack user's own and private refuses credential-file, as does metadata of any other shape.
const adminMeta = (business) => {
for (const dir of slotDirs(business)) if (!privateDir(dir, { missing: true })) return null;
const text = readSecretFile(join(slotDir(business), 'admin.json'), { missing: true });
if (text === null) return null;
let meta;
try {
meta = JSON.parse(text);
} catch {
deny('credential-file');
}
if (
!meta || typeof meta !== 'object' || Array.isArray(meta) ||
Object.keys(meta).sort().join() !== [...META_KEYS].sort().join() ||
!parseGiteaUrl(meta.url).url || !giteaName(meta.account) || !day(meta.rotateBy) ||
typeof meta.setAt !== 'string' || !AT.test(meta.setAt)
)
deny('credential-file');
return { url: meta.url, account: meta.account, rotateBy: meta.rotateBy, setAt: meta.setAt };
};
const adminToken = (business) => {
for (const dir of slotDirs(business)) privateDir(dir);
return checkToken(readSecretFile(join(slotDir(business), 'admin')));
};
// The admin api over one token. Basic on the token routes (F1), the `token` header elsewhere. Every
// call goes through log(op, run, after).
const adminApi = (url, account, token, log) => {
const header = { token };
const basic = { basic: [account, token] };
const go = (op, method, path, auth, body, after) => log(op, () => transport({ url, method, path, auth, body, ca, timeoutMs }), after);
return {
version: () => go('version', 'GET', '/api/v1/version', header),
self: () => go('self', 'GET', '/api/v1/user', header),
tokenScopes: () => go('token-scopes', 'GET', '/api/v1/token', header),
reposCheck: () => go('repos-check', 'GET', '/api/v1/user/repos?limit=1&page=1', header),
userGet: (user) => go('user-get', 'GET', `/api/v1/users/${segment(user)}`, header),
userCreate: (body, after) => go('user-create', 'POST', '/api/v1/admin/users', header, body, after),
userPatch: (user, body) => go('user-patch', 'PATCH', `/api/v1/admin/users/${segment(user)}`, header, body),
tokenList: (user, page) => go('token-list', 'GET', `/api/v1/users/${segment(user)}/tokens?limit=50&page=${page}`, basic),
tokenCreate: (user, name, scopes = SEAT_SCOPES, op = 'token-create') =>
go(op, 'POST', `/api/v1/users/${segment(user)}/tokens`, basic, { name, scopes: [...scopes] }),
tokenDelete: (user, idOrName) => go('token-delete', 'DELETE', `/api/v1/users/${segment(user)}/tokens/${segment(idOrName)}`, basic),
};
};
return {
giteaGrantCheck,
identitySha256,
adminSha256,
definitionSha256,
tokenName,
adminMeta,
openGrants: openGiteaGrants,
tokenUnknown,
checkToken,
forkUrl,
repoPage: REPO_PAGE,
repoPages: REPO_PAGES,
// The admin api over the slot's token, for a grant, an undo, a revoke or a sweep. Refuses
// grant-admin-missing with no slot.
admin(business, log) {
const meta = adminMeta(business);
if (!meta) deny('grant-admin-missing');
return { meta, api: adminApi(meta.url, meta.account, adminToken(business), log) };
},
adminChecks,
// The seat token's calls, made with the minted token (steps 4 and 5) or the store file's (settle).
seat(url, token, log) {
const header = { token };
const go = (op, method, path, body) => log(op, () => transport({ url, method, path, auth: header, body, ca, timeoutMs }));
return {
self: () => go('seat-self', 'GET', '/api/v1/user'),
repos: (page) => go('seat-repos', 'GET', `/api/v1/user/repos?limit=${REPO_PAGE}&page=${page}`),
forkGet: (owner, repo) => go('fork-get', 'GET', `/api/v1/repos/${segment(owner)}/${segment(repo)}`),
fork: (owner, repo) => go('fork', 'POST', `/api/v1/repos/${segment(owner)}/${segment(repo)}/forks`, {}),
whoami: () => go('token-whoami', 'GET', '/api/v1/token'),
};
},
// Section 3e: `set` checks C1 to C4 with the given token before it writes anything. A slot already
// there refuses grant-admin-exists, whatever its url (remove first). Returns the metadata written.
async setAdmin(business, { url, account, rotateBy, token, setAt }, log) {
const t = checkToken(token);
if (adminMeta(business)) deny('grant-admin-exists');
await adminChecks(adminApi(url, account, t, log), account);
if (adminMeta(business)) deny('grant-admin-exists');
const meta = { url, account, rotateBy, setAt };
this.writeAdmin(business, t, meta);
return meta;
},
// The slot's write: the token, then admin.json, each tmp, fsync, rename. Dirs are made 0700.
writeAdmin(business, token, meta) {
mkdirSync(join(configDir, 'secrets'), { recursive: true, mode: 0o700 });
for (const dir of slotDirs(business)) {
if (!privateDir(dir, { missing: true })) mkdirSync(dir, { mode: 0o700 });
privateDir(dir);
}
writeFileAtomic(slotDir(business), 'admin', checkToken(token) + '\n');
writeFileAtomic(slotDir(business), 'admin.json', JSON.stringify(meta) + '\n');
},
// Section 3d rotate: C1 to C4, the successor minted for the admin account itself and named by the
// UTC second, C2 and C3 with it, then the write. The old token is deleted by name with the new one;
// `left` names it if that fails. A name already taken (two rotations in one second) refuses before
// anything is written. `stamp` is yyyymmddThhmmssZ.
async rotateAdmin(business, { rotateBy, setAt, stamp }, log) {
const { meta, api } = this.admin(business, log);
const oldName = await adminChecks(api, meta.account);
const name = `mosaic-grant-${stamp}`;
if (name === oldName) deny('grant-admin-name-taken');
const r = await api.tokenCreate(meta.account, name, ADMIN_SCOPES, 'admin-rotate');
if (r.status === 400 || r.status === 422) deny('grant-admin-name-taken');
if (r.status !== 201 || typeof r.json?.sha1 !== 'string') deny('gitea-failed');
const successor = checkToken(r.json.sha1);
const next = adminApi(meta.url, meta.account, successor, log);
const self = await next.self();
if (self.status !== 200 || self.json?.login !== meta.account || self.json?.is_admin !== true) deny('grant-admin-account');
const t = await next.tokenScopes();
if (t.status !== 200 || !Array.isArray(t.json?.scopes) || [...t.json.scopes].sort().join() !== ADMIN_SCOPES.join()) deny('grant-admin-scope');
const written = { url: meta.url, account: meta.account, rotateBy, setAt };
this.writeAdmin(business, successor, written);
let left = null;
if (oldName === null) left = 'unknown';
else {
const d = await next.tokenDelete(meta.account, oldName);
if (d.status !== 204 && d.status !== 404) left = oldName;
}
return { meta: written, tokenName: name, adminTokenLeft: left };
},
// rotate --finish: the old token by name, with the slot's token.
async finishRotate(business, oldName, log) {
const { meta, api } = this.admin(business, log);
await adminChecks(api, meta.account);
const d = await api.tokenDelete(meta.account, oldName);
if (d.status !== 204 && d.status !== 404) deny('gitea-failed');
},
// Section 3d remove: the slot's files and dir go. No network call.
removeAdmin(business) {
if (!adminMeta(business)) deny('grant-admin-missing');
for (const name of ['admin', 'admin.json', 'admin.tmp', 'admin.json.tmp'])
try {
unlinkSync(join(slotDir(business), name));
} catch (e) {
if (e.code !== 'ENOENT') deny('credential-file');
}
try {
rmdirSync(slotDir(business));
} catch {
deny('credential-file');
}
},
// Step 2: the never-stored password of a new user and the nonce in its full_name.
password: () => randomBytes(24).toString('base64url'),
nonce: () => randomBytes(8).toString('hex'),
};
}
// The store reference for an agent's gitea token, as Credentials checks it.
export const giteaReference = (storeRoot, business, agent, rotateBy) => ({
store: true,
file: storeRoot ? join(storeRoot, business, agent, 'gitea') : null,
rotateBy,
});
// The grant executor's calls on Credentials, keyed by (business, agent). The broker gets this and never
// Credentials itself, so it still has no use(); useFile hands the file's token only to a settle call.
export function giteaKeys({ credentials, storeRoot }) {
const at = (business, agent) => business + '/' + agent;
return {
claim: (business, agent) => credentials.claim(at(business, agent), 'gitea'),
release: (business, agent, signal) => credentials.release(at(business, agent), 'gitea', signal),
abortClaim: (business, agent) => credentials.abortClaim(at(business, agent), 'gitea'),
known: (business, agent) => credentials.status().some((e) => e.instance === at(business, agent) && e.service === 'gitea'),
register: (business, agent, rotateBy) => credentials.register(at(business, agent), 'gitea', giteaReference(storeRoot, business, agent, rotateBy)),
unregister: (business, agent) => credentials.unregister(at(business, agent), 'gitea'),
issue: (business, agent, token, rotateBy, commit) => credentials.issue(at(business, agent), 'gitea', token, rotateBy, commit),
revoke: (business, agent) => credentials.revoke(at(business, agent), 'gitea'),
reload: (business, agent, rotateBy) => credentials.reload(at(business, agent), 'gitea', rotateBy),
hold: (business, agent) => credentials.hold(at(business, agent), 'gitea'),
held: (business, agent) => credentials.held(at(business, agent), 'gitea'),
useFile: (business, agent, fn) => credentials.useFile(at(business, agent), 'gitea', fn),
unlinkStore: (business, agent) => credentials.unlinkStore(at(business, agent), 'gitea'),
};
}
// Section 5, before Credentials loads, read-only on the store: each gitea_grants record gives its agent
// a store reference to gitea, and `held` lists the pairs whose file memory must not load. Those are an
// agent whose last gitea grant.failed is grant-token-unknown with no grant.revoked after it, and an
// agent with an open first grant whose last step is commit: its file may hold a token no record backs,
// and the broker's reconcile unlinks it. Only grant evidence counts (duckworth-0067 N1). `references`
// is changed in place: a record's reference replaces a business-file gitea reference, which names the
// same store path or a file the record supersedes.
export function giteaStart(store, storeRoot, businesses, references) {
const held = [];
for (const g of store.all('SELECT business, agent, rotate_by FROM gitea_grants ORDER BY business, agent')) {
if (!Object.hasOwn(businesses, g.business)) continue;
const refs = (references[g.business + '/' + g.agent] = { ...(references[g.business + '/' + g.agent] ?? {}) });
refs.gitea = giteaReference(storeRoot, g.business, g.agent, g.rotate_by);
}
for (const business of Object.keys(businesses)) {
for (const agent of tokenUnknown(store, business).keys()) held.push(business + '/' + agent + ':gitea');
for (const o of openGiteaGrants(store, business))
if (o.step?.step === 'commit' && o.step.previousTokenId === null) held.push(business + '/' + o.agent + ':gitea');
}
return { held: [...new Set(held)] };
}
const evidence = (store, business, where, ...args) =>
store.all(`SELECT seq, body FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND ${where} ORDER BY seq`, business, ...args)
.map((e) => ({ seq: e.seq, body: JSON.parse(e.body) }));
// agent -> {decision, seq}: the agents whose last gitea grant.failed is grant-token-unknown with no gitea
// grant.revoked after it (section 5's held row).
export function tokenUnknown(store, business) {
const last = new Map();
for (const e of evidence(store, business, "json_extract(body,'$.method')='gitea' AND json_extract(body,'$.action') IN ('grant.failed','grant.revoked')"))
if (e.body.action === 'grant.revoked') last.delete(e.body.agent);
else if (e.body.code === 'grant-token-unknown') last.set(e.body.agent, { decision: e.body.grantDecision, seq: e.seq });
return last;
}
// Every gitea grant spent with no grant.applied or grant.failed after its spend: {decision, agent, step,
// seq, revokedAfter}. step is the last grant.step body or null, seq that step's or the spend's, and
// revokedAfter says a gitea grant.revoked of the agent came after it (N9). A step 0 grant.failed comes
// before the spend, so only ends after it count.
export function openGiteaGrants(store, business) {
const out = [];
for (const spend of evidence(store, business, "json_extract(body,'$.operation')='grant.gitea'")) {
const decision = spend.body.decision;
const mine = evidence(store, business, "json_extract(body,'$.grantDecision')=? AND seq > ?", decision, spend.seq);
if (mine.some((e) => e.body.action === 'grant.applied' || e.body.action === 'grant.failed')) continue;
const last = mine.filter((e) => e.body.action === 'grant.step').at(-1) ?? null;
out.push({ decision, agent: last?.body.agent ?? null, step: last?.body ?? null, seq: last?.seq ?? spend.seq });
}
for (const o of out)
o.revokedAfter =
o.agent !== null &&
evidence(store, business, "json_extract(body,'$.method')='gitea' AND json_extract(body,'$.action')='grant.revoked' AND json_extract(body,'$.agent')=? AND seq > ?", o.agent, o.seq)
.length > 0;
return out;
}
+3 -2
View File
@@ -25,8 +25,9 @@ try {
const client = new Client({
path: process.argv[2],
// Row 254: library.sync fetches for up to 100 s and the server holds its socket for 120 s,
// so wait past that for the server's own answer. Every other verb keeps the 5 s default.
timeout: request.verb === 'library.sync' ? 130_000 : undefined,
// so wait past that for the server's own answer. Row 236: the grants verbs call Gitea and get the
// same wait. Every other verb keeps the 5 s default.
timeout: request.verb === 'library.sync' || request.verb.startsWith('grants.') ? 130_000 : undefined,
human: {
business: request.business,
pid: me.pid,
+24
View File
@@ -0,0 +1,24 @@
-- Row 236 (#436b, #1742, design r5 section 7): the Gitea users this stack made and the Gitea grants
-- live in the store. The grant executor, revoke and sweep are the only writers, each in the
-- transaction of its event. Only new tables; the grant event trigger from 3j covers their events.
CREATE TABLE gitea_users (
business TEXT NOT NULL,
agent TEXT NOT NULL,
user TEXT NOT NULL,
id INTEGER NOT NULL CHECK (id > 0),
nonce TEXT NOT NULL CHECK (length(nonce) = 16 AND NOT nonce GLOB '*[^0-9a-f]*'),
decision TEXT NOT NULL REFERENCES decisions(id),
PRIMARY KEY (business, agent)
) STRICT;
CREATE TABLE gitea_grants (
business TEXT NOT NULL,
agent TEXT NOT NULL,
user TEXT NOT NULL,
token_id INTEGER NOT NULL CHECK (token_id > 0),
token_name TEXT NOT NULL,
projects TEXT NOT NULL CHECK (json_valid(projects) AND json_type(projects) = 'array' AND json_array_length(projects) > 0),
rotate_by TEXT NOT NULL,
decision TEXT NOT NULL REFERENCES decisions(id),
PRIMARY KEY (business, agent),
FOREIGN KEY (business, agent) REFERENCES gitea_users(business, agent)
) STRICT;
+9
View File
@@ -25,6 +25,10 @@ const LAUNCH_OPS = new Set([
'mayLaunch',
'landHolders',
'landRelease',
'lifecycleRequests',
'lifecycleTake',
'lifecycleDelete',
'lifecycleDone',
]);
function launchOp(m) {
if (m.op === 'identity') return runtime.identity(m.cap);
@@ -55,6 +59,11 @@ function launchOp(m) {
// role run started before its decision was resolved.
if (m.op === 'landHolders') return runtime.landHolders(m.business);
if (m.op === 'landRelease') return runtime.landRelease(m.record);
// Row 194 (#1692, D1.1): the launcher's lifecycle pass.
if (m.op === 'lifecycleRequests') return runtime.lifecycleRequests(m.business);
if (m.op === 'lifecycleTake') return runtime.lifecycleTake(m.record);
if (m.op === 'lifecycleDelete') return runtime.lifecycleDelete(m.record);
if (m.op === 'lifecycleDone') return runtime.lifecycleDone(m.record);
if (m.op === 'credentialStatus') return runtime.credentialStatus(m.business, m.role);
// Every LAUNCH_OPS name has its line above. A missing one used to answer as credentialStatus,
// whose [] for an unknown role read as "no holders" for landHolders (row 156 mutant P02).
+200 -20
View File
@@ -13,16 +13,29 @@ import { publishRemote, workingRepo, WorkingError } from '../../seat/src/working
import { createLoader, hasModule } from '../../plugins/src/loader.mjs';
import { pluginSettings } from '../../business/src/plugin-settings.mjs';
import { instanceSettings } from '../../business/src/instance-settings.mjs';
import { organizationWriter } from '../../business/src/organization.mjs';
import { organizationWriter, OrganizationError } from '../../business/src/organization.mjs';
import { credentialVerbs } from './credential-verbs.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { loadRole } from '../../business/src/role.mjs';
import { resolveInstance } from '../../business/src/resolve.mjs';
import { canonicalJson } from '../../business/src/util.mjs';
import { busBusiness } from './business.mjs';
import { webService } from './web.mjs';
import { hostExecutor, sweepRunDir } from './host-run.mjs';
import { grantKeys, hostGrant, hostReference, sweepHostKeys } from './host-grant.mjs';
import { giteaGrant, giteaKeys, giteaStart } from './gitea-grant.mjs';
import { verifyPrincipal } from './principal.mjs';
import { loadAccess } from '../../business/src/access.mjs';
import { inspectInterpreter, recordInterpreterStart } from './interpreter.mjs';
import { createPeerReader } from './peercred.mjs';
import { createUsage } from './usage.mjs';
import { createLimits } from './limits.mjs';
import { createProcessing } from '../../processing-control/src/coordinator.mjs';
import { discordProcessingHooks } from '../../discord/src/processing.mjs';
import { createContainerStopper } from '../../processing-control/src/containers.mjs';
// Row 194 (D3): the verbs the instance writer commits, whose answers carry restart.
const INSTANCE_VERBS = new Set(['instances.set', 'veto.set', 'instance.hire', 'instance.fire']);
// Trusted host API. S1 supplies resolved definitions, S6 supplies launch records and ends them.
// Human limits writes use the shared organization writer behind the verified transport.
// `tasks` is S3's adapter factory: ({broker, credentials, businesses}) => {handle, timeout, close}.
@@ -86,12 +99,17 @@ export async function startBroker({
// slot, { command, intervalMs, configDir }. A boot config is JSON and can't carry a function, so
// a librarySync without one is refused.
librarySync = null,
// FED R3 (#414): the access file and its loader context, { file, context } (R1's loadAccess).
// Without it no user binds. R6 and R8 pass it; the live boot config doesn't yet.
access = null,
}) {
if (access !== null && (typeof access?.file !== 'string' || access.context === null || typeof access.context !== 'object'))
throw new BusError('invalid-host-request');
if (librarySync !== null && (typeof librarySync?.command !== 'function'
|| (Object.hasOwn(librarySync, 'intervalMs') && !(Number.isSafeInteger(librarySync.intervalMs) && librarySync.intervalMs > 0))
|| (Object.hasOwn(librarySync, 'configDir') && typeof librarySync.configDir !== 'string')))
throw new BusError('invalid-host-request');
let store, credentials, server, adapter, syncTimer = null, syncPass = null;
let store, credentials, server, adapter, processing, syncTimer = null, syncPass = null;
try {
const references = {};
for (const [business, b] of Object.entries(businesses)) {
@@ -120,14 +138,52 @@ export async function startBroker({
// Row 235 (r6 section 6, N1): a host.<id> key file with no record goes before Credentials loads.
for (const entry of sweepHostKeys(storeRoot, Object.keys(businesses), granted))
process.stderr.write(`hosts: ${JSON.stringify({ event: 'host-key-swept', ...entry })}\n`);
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot, storeRoot });
// Row 236 (#1742, #436b r5 section 5): each gitea grant record gives its agent a store reference to
// gitea, and `held` names the files memory must not load until reconcile or a revoke.
const { held } = storeRoot ? giteaStart(store, storeRoot, businesses, references) : { held: [] };
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot, storeRoot, held });
// vikunja:19/305: the plug-in switch through the same human-only path as the limits: the verbs
// plugins and plugins.set, logged in changes.jsonl with the actor, a missing module refused.
const plugins=organization?pluginSettings({dir:organization.dir,rolesDir:organization.rolesDir,exists:hasModule}):null;
const levels=organization?await harnessLevels():()=>null;
const settings=organization?launchSettings({...organization,sessions:()=>readSessions(dataRoot),levels}):null;
// Row 194 (#1692, brief item 8, row 199 D13): a fire or a delete revokes the agent's credentials
// after the writer's checks and before its write. Fire revokes each service of the entry and keeps
// the store directory, delete calls remove (sage-1593). A service the running bus doesn't know (an
// agent created since the start) has nothing in memory, so it goes on, and then no token or tmp
// file may remain in the agent's store directory (sage-1592). Any failure stops the verb with the
// file unchanged.
const revokeFailed = (message) => new OrganizationError('credential-revoke-failed', message);
function revoke(business, agent, how, services) {
const instance = business + '/' + agent;
const outside = [];
try {
if (how === 'delete' && storeRoot) credentials.remove(instance);
else
for (const service of services) {
try {
if (!credentials.revoke(instance, service).file) outside.push(service);
} catch (e) {
if (e.code !== 'credential-unknown') throw e;
}
}
} catch (e) {
throw revokeFailed(`${agent}: ${e.code ?? 'credential-file'}`);
}
if (storeRoot) {
let left = [];
try {
left = readdirSync(join(storeRoot, business, agent));
} catch (e) {
if (e.code !== 'ENOENT') throw revokeFailed(`${agent}: store directory can't be read`);
}
if (left.length > 0) throw revokeFailed(`${agent}: ${left.sort().join(' and ')} still in the store`);
}
// A file or env reference outside the store keeps its token for a restart (row 199 section 6).
return { revoked: services, outsideStore: how === 'fire' ? outside : [] };
}
// Row 165 (#1662): the human adds and changes role instances through instances and instances.set.
const instances=organization?instanceSettings({dir:organization.dir,rolesDir:organization.rolesDir,system:organization.system,levels}):null;
const instances=organization?instanceSettings({dir:organization.dir,rolesDir:organization.rolesDir,system:organization.system,levels,revoke}):null;
const usage = await createUsage({ store, dataRoot, businesses,
log: entry => process.stderr.write(`usage: ${JSON.stringify(entry)}\n`) });
// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): the limit checks read the organization file at
@@ -138,11 +194,20 @@ export async function startBroker({
const withTokens = (id, result) => ({ ...result, tokens: limits.report(id) });
// Row 210 (#1707, design D7 step 6) and row 234 (#1740, r6 E2): the run dir and the host enrollment half.
const runRoot = organization ? join(organization.dir, 'secrets', 'run') : null;
let broker;
processing = createProcessing({ dataRoot, ...discordProcessingHooks(dataRoot), containerStop: createContainerStopper({ dataRoot }),
onTransition: () => broker.reconcileProcessing(),
onStop: stop => broker.recordProcessingStop(stop),
log: line => process.stderr.write(`${line}\n`),
});
// C2-04: mirrors are host state, never session checkouts. An unsafe
// existing mirror refuses startup before a socket can become ready.
for (const business of Object.values(businesses))
for (const [project, entry] of Object.entries(business.projects ?? {}))
if (entry.kind === 'library') await libraryModule.ensureMirror({ business, project, dataRoot });
if (entry.kind === 'library') {
try { await processing.work(() => libraryModule.ensureMirror({ business, project, dataRoot })); }
catch (e) { if (!e.code?.startsWith('processing-')) throw e; }
}
// C2-03a: the slot is <configDir>/secrets/<business>/library-gitea (design 10 step 4). Sync only
// checks that it is there; the askpass helper reads it.
const slotDir = librarySync?.configDir ?? organization?.dir ?? null;
@@ -150,10 +215,59 @@ export async function startBroker({
slot: slotDir ? join(slotDir, 'secrets', business.id, libraryModule.SLOT) : null,
...(librarySync ? { command: librarySync.command } : {}),
});
const broker = new Broker({
// Row 194 (#1692, D3): after every write that commits, the broker takes the business as a restart
// would load it. The credential references, the project roots the Credentials store protects
// and the tracker bots are fixed at start (the Credentials store, the tasks adapter), so a file
// whose references, roots or bots differ from the boot copy isn't reloaded, and the write
// answers restart: true. Hire adds neither. A human create of a credentialed definition adds
// references, so it answers restart: true, and issue works after the restart (brief item 7).
// A role not in the boot copy matches only with no references and no bot. A reference compares
// by its kind and path only: row 199's issue rewrites the date in the file outside this reload,
// Credentials keeps its own copy of the date, and the broker never reads it.
const fixedRef = ({ file, env, store }) => ({ file, env, store });
const fixedRole = r => canonicalJson({
credentials: Object.fromEntries(Object.entries(r?.credentials ?? {}).map(([service, ref]) => [service, fixedRef(ref)])),
tracker: r?.tracker ?? null,
});
const fixedAtStart = b => ({
roles: Object.fromEntries(Object.entries(b.roles).map(([role, r]) => [role, fixedRole(r)])),
sync: canonicalJson(b.tracker?.sync ?? null),
roots: canonicalJson(Object.values(b.projects ?? {}).filter(p => p.kind !== 'library').map(p => p.root).sort()),
// A library entry has no root, and its mirror is ensured at start (C2-04), so each one is fixed whole.
libraries: canonicalJson(Object.fromEntries(Object.entries(b.projects ?? {}).filter(([, p]) => p.kind === 'library'))),
});
const sameFixed = (boot, b) => fixedAtStart(b).sync === boot.sync && fixedAtStart(b).roots === boot.roots
&& fixedAtStart(b).libraries === boot.libraries
&& Object.entries(fixedAtStart(b).roles).every(([role, f]) => f === (boot.roles[role] ?? fixedRole(null)));
const booted = new Map(Object.entries(businesses).map(([id, b]) => [id, fixedAtStart(b)]));
const reloadLog = entry => process.stderr.write(`organization: ${JSON.stringify(entry)}\n`);
function reload(id) {
try {
const b = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: b, instance });
const next = busBusiness(b, resolved);
if (!sameFixed(booted.get(id), next)) {
reloadLog({ event: 'reload-needs-restart', business: id });
return false;
}
broker.reloadBusiness(id, next);
return true;
} catch (e) {
reloadLog({ event: 'reload-failed', business: id, message: e.message });
return false;
}
}
broker = new Broker({
// FED R3: the access file is read per user request; a refusal leaves the user no grant.
access: access ? () => loadAccess(access.file, access.context) : null,
hostGrant: runRoot ? hostGrant({ configDir: organization.dir, runRoot }) : null,
// Row 235 (#1741, r6 X4, X5): the grant executor's claim, register and issue, never use().
hostKeys: storeRoot ? grantKeys({ credentials, storeRoot }) : null,
processing,
// Row 236 (#1742): the Gitea grant path. The admin slot is <configDir>/secrets/grants/<business>/gitea.
giteaGrant: organization ? giteaGrant({ configDir: organization.dir }) : null,
giteaKeys: storeRoot ? giteaKeys({ credentials, storeRoot }) : null,
usage,
limits,
store,
@@ -161,12 +275,12 @@ export async function startBroker({
library: {
readFile: context => libraryModule.readLibraryFile({ ...context, dataRoot }),
status: context => libraryModule.libraryStatus({ ...context, dataRoot }),
sync: (context, { accept }) => libraryModule.sync({ ...context, dataRoot }, { ...syncOptions(context.business), accept }),
sync: (context, { accept }) => processing.work(() => libraryModule.sync({ ...context, dataRoot }, { ...syncOptions(context.business), accept })),
LibraryError: libraryModule.LibraryError,
},
project: {
readSpec: context => projectModule.readSpec({ ...context, dataRoot }),
writeRecord: context => projectModule.writeRecord({ ...context, dataRoot }),
writeRecord: context => processing.work(() => projectModule.writeRecord({ ...context, dataRoot })),
listFiles: context => projectModule.listProjectFiles({ ...context, dataRoot }),
readFile: context => projectModule.readProjectFile({ ...context, dataRoot }),
writeFile: context => projectModule.writeHumanFile({ ...context, dataRoot }),
@@ -174,19 +288,37 @@ export async function startBroker({
},
// limits and limits.set (organization.set is limits.set) carry each token limit's count; a
// plugins.set reloads the business's limit checks in the background.
organization:settings?(id,verb,args,actor)=>{
organization:settings?(id,verb,args,actor,context)=>{
if(verb==='limits')return withTokens(id,settings.read(id));
if(verb==='limits.set')return withTokens(id,settings.set(id,args,actor));
if(verb==='plugins')return plugins.read(id);
if(verb==='instances')return instances.read(id);
if(verb==='instances.set')return instances.set(id,args,actor);
// Row 65 (mos-0840): the Console's routing table.
if(verb==='routes')return settings.routes(id);
if(verb==='routes.set')return settings.setRoutes(id,args,actor);
const result=plugins.set(id,args,actor);
// Row 194 (#1692, V5): a delete's checks at the verb, with no write and no reload.
if(verb==='instances.check')return instances.checkDelete(id,args.instance);
// Row 194 b6 (webby-0094 B1): a fire's checks against the file, before step A.
if(verb==='instance.fireCheck')return instances.checkFire(id,args.name);
let result;
if(verb==='limits.set')result=withTokens(id,settings.set(id,args,actor));
else if(verb==='instances.set')result=instances.set(id,args,actor);
else if(verb==='routes.set')result=settings.setRoutes(id,args,actor);
else if(verb==='plugins.set')result=plugins.set(id,args,actor);
// Row 194 (#1692, V6): the human's keep and noHire lists, through the instance writer.
else if(verb==='veto.set')result=instances.veto(id,args,actor);
// Row 194 (D2.1, D2.2): the chief's hire and fire, with the reason in the change context.
else if(verb==='instance.hire')result=instances.hire(id,args,{actor,context});
else if(verb==='instance.fire')result=instances.fire(id,{name:args.name},{actor,context});
else throw new BusError('invalid-request');
// Row 194 (D3 item 2): one reload, after every verb that committed, then the limit checks.
const live=reload(id);
limits.refresh(id).catch(e=>limitLog({event:'limit-refresh-failed',business:id,message:e.message}));
return result;
// Row 194 b6 (webby-0094 B1, sage-2241): every instance-writer verb says whether the reload
// took, so a refused one (credentials, roots, ...) shows the restart line for a keep or a
// hire too. The other writers' answers carry no restart field.
return INSTANCE_VERBS.has(verb)?{...result,restart:!live}:result;
}:null,
// Row 194 (D2.3): the role files as they stand, for the hire checks.
roleFile: organization ? (name) => loadRole(organization.rolesDir, name) : null,
secretCheck: (value) => credentials.assertClean(value),
credentialStatus: () => credentials.status(),
// Row 199 (#1697, design section 6): issue, rotate, revoke and delete, live with no restart; each
@@ -204,7 +336,23 @@ export async function startBroker({
return false;
}
},
// Row 194 (#1692, D4.1, B8): bus.tester as the file sets it now; an unreadable file is off.
testerOn: (id) => {
try {
const b = settings ? settings.current(id) : businesses[id];
return b?.vars?.['bus.tester'] === true;
} catch {
return false;
}
},
});
broker.reconcileProcessing();
processing.start();
const processingWork = () => {
const state = processing.read();
if (state.invalid) throw new BusError(state.code);
if (state.halted) throw new BusError('processing-halted');
};
// Process identities must be supplied for every managed run when using the human transport.
const launchRecords = [];
function bindLaunch(r) {
@@ -222,7 +370,7 @@ export async function startBroker({
const bound = launches.map(bindLaunch);
const readCaps = readers.map((business) => ({ business, cap: broker.bindReader({ business }) }));
if (tasks) {
adapter = await tasks({ broker, credentials, businesses });
adapter = await tasks({ broker, credentials, businesses, processing });
if (typeof adapter?.handle !== 'function' || !Number.isSafeInteger(adapter.timeout) || adapter.timeout < 1)
throw new BusError('invalid-adapter');
}
@@ -241,9 +389,18 @@ export async function startBroker({
console: fileURLToPath(new URL('../../webui/src/cli.mjs', import.meta.url)),
};
const path = join(store.directory, 'broker.sock');
// The existing host executor owns SSH and its key lifecycle. Combine its revocation
// signal with processing cancellation without changing its proof or credential entry points.
const host = runRoot ? hostExecutor({ root: runRoot, credentials: {
busy: (...args) => credentials.busy(...args),
useFor: (session, name, fn, options) => credentials.useFor(session, name, (token, meta) =>
fn(token, { ...meta, signal: meta.signal
? AbortSignal.any([meta.signal, processing.signal()]) : processing.signal() }), options),
} }) : null;
server = await serve({
web: (business) => web.get(business) ?? null,
host: runRoot ? hostExecutor({ credentials, root: runRoot }) : null,
host: host ? { busy: (...args) => host.busy(...args),
run: (...args) => processing.work(() => host.run(...args)) } : null,
tasks: adapter ? { handle: adapter.handle, timeout: adapter.timeout } : null,
broker,
path,
@@ -256,10 +413,17 @@ export async function startBroker({
const p = verifyPrincipal({ tester: proof }, { peer, paths: principalPaths, businesses, launches: launchRecords });
return broker.bindTester({ business: p.business });
},
// FED R3 (design 3.6): checks 1 and 2 are row 196's user kind; check 3, the live grant, is bindUser's.
authenticateUser: (proof, peer) => {
const p = verifyPrincipal({ user: proof }, { peer, paths: principalPaths, businesses, launches: launchRecords });
return broker.bindUser({ business: p.business, user: p.id });
},
});
// Row 235 (r6 X3): the remote undo of interrupted host grants, once the bus is up, so a start doesn't
// wait on the network.
broker.reconcileHosts();
// Row 236 (#436b r5 section 5): the Gitea grants' remote undo, settle and open revokes, likewise.
broker.reconcileGitea();
// S6 launcher operations. The host decides capacity and the instance list; the broker
// authorizes role.launch and keeps the evidence, refusals included.
// A refusal is recorded against the caller, as request() records one.
@@ -297,11 +461,11 @@ export async function startBroker({
const { business, project, fork, user, source, dst } = broker.publishTarget(ticket);
const entry = { state: 'running' };
remotes.set(ticket, entry);
withGitea(ticket, (token) =>
processing.work(() => withGitea(ticket, (token) =>
publishRemote({ repo: workingRepo(dataRoot, business, project), fork, user, source, dst, token, tls: publishTls }).catch((e) => {
throw e instanceof WorkingError ? new BusError(e.code) : e;
}),
).then(
)).then(
(r) => Object.assign(entry, { state: 'pushed', ...r }),
(e) => {
// Not a refusal: the error's name and code only, never a message that could carry git's output.
@@ -374,7 +538,13 @@ export async function startBroker({
}
}
};
const tick = () => { if (!syncPass) syncPass = syncAll().finally(() => { syncPass = null; }); };
const tick = () => {
const state = processing.read();
if (!syncPass && !state.halted && !state.invalid)
syncPass = processing.work(syncAll).catch(e => {
if (!e.code?.startsWith('processing-')) syncLog({ event: 'library-sync-refused', code: 'library-sync-failed' });
}).finally(() => { syncPass = null; });
};
if (timed.length) {
tick();
syncTimer = setInterval(tick, librarySync?.intervalMs ?? libraryModule.SYNC_INTERVAL_MS);
@@ -390,7 +560,10 @@ export async function startBroker({
threadsState: (business) => broker.threadsState(business),
threadIdle: (r) => broker.threadIdle(r),
// Row 125 (#1619, REQ-LIMIT-4): the launcher asks the limit checks before every launch.
mayLaunch: ({ business, instance, provider, model, account = null }) => limits.mayLaunch(business, { instance, provider, model, account }),
mayLaunch: async ({ business, instance, provider, model, account = null }) => {
processingWork();
return limits.mayLaunch(business, { instance, provider, model, account });
},
identity: (cap) => broker.identity(cap),
authorizeLaunch,
checkPush,
@@ -409,6 +582,11 @@ export async function startBroker({
// Row 156 (#1651): the launcher's landing pass.
landHolders: (business) => broker.landHolders(business),
landRelease: (r) => broker.landRelease(r),
// Row 194 (#1692, D1.1): the launcher's lifecycle pass.
lifecycleRequests: (business) => broker.lifecycleRequests(business),
lifecycleTake: (r) => broker.lifecycleTake(r),
lifecycleDelete: (r) => broker.lifecycleDelete(r),
lifecycleDone: (r) => broker.lifecycleDone(r),
refuse: (cap, code, subject) => broker.refuse(cap, code, subject),
credentialStatus,
launches: bound,
@@ -417,6 +595,7 @@ export async function startBroker({
await stopSync();
await server.close();
await adapter?.close?.();
await processing?.close();
store.close();
credentials.close();
},
@@ -424,6 +603,7 @@ export async function startBroker({
} catch (e) {
await server?.close();
await adapter?.close?.();
await processing?.close();
store?.close();
credentials?.close();
throw e;
+6 -3
View File
@@ -1,6 +1,6 @@
import { createServer } from 'node:net';
import { lstatSync, chmodSync, unlinkSync } from 'node:fs';
import { BusError, TASK_VERBS, WEB_VERBS, HOST_VERBS, HOSTS_VERBS, PROJECT_FILE_VERBS, LIBRARY_VERBS, PROJECT_SAVE_VERB, PROJECT_SAVE_REQUEST_MAX } from './broker.mjs';
import { BusError, TASK_VERBS, WEB_VERBS, HOST_VERBS, HOSTS_VERBS, GRANTS_VERBS, PROJECT_FILE_VERBS, LIBRARY_VERBS, PROJECT_SAVE_VERB, PROJECT_SAVE_REQUEST_MAX } from './broker.mjs';
import { inspectInterpreter } from './interpreter.mjs';
import { createPeerReader } from './peercred.mjs';
const LIMIT = 65536;
@@ -134,11 +134,14 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
});
return;
}
// Row138: only project Git effects need an await; preserve every other sync path below.
if (r.verb === 'usage.begin' || r.verb === 'usage.end' ||
// Async effects retain the synchronous path for every other verb.
if (r.verb === 'processing.halt' || r.verb === 'processing.resume' ||
r.verb === 'usage.begin' || r.verb === 'usage.end' ||
r.verb === 'spec.read' || r.verb === 'record.write' || PROJECT_FILE_VERBS.includes(r.verb) || LIBRARY_VERBS.includes(r.verb) ||
// Row 234 (#1740): a grant raise awaits its keyscan, and hosts.check its ssh.
HOSTS_VERBS.includes(r.verb) ||
// Row 236 (#1742): the grants verbs may call Gitea several times, 15 s each at most.
GRANTS_VERBS.includes(r.verb) ||
(r.verb === 'decision.raise' && (Object.hasOwn(r.args ?? {}, 'spec_path') || Object.hasOwn(r.args ?? {}, 'spec_commit') ||
Object.hasOwn(r.args ?? {}, 'grant')))) {
const own = transient;
+7 -2
View File
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
import { createHash } from 'node:crypto';
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
export const SCHEMA_SHA256 = '0a7b633ce7eec9d0bb37c3255f4244d97bd0c8eac38db91a11b4ef6cc982ec8c';
export const SCHEMA_VERSION = '3j';
export const SCHEMA_SHA256 = 'aba9240825a35fdcc741439d0708efa33afcf519d11cac9952c7207ad99250f0';
export const SCHEMA_VERSION = '3k';
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned.
const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
@@ -61,6 +61,11 @@ const migrations = [{
from: '3i',
to: '3j',
sql: readFileSync(new URL('./migrations/3i-3j.sql', import.meta.url), 'utf8'),
}, {
// Row 236 (#1742, #436b section 7): the Gitea users the stack made and the Gitea grants. Only new tables.
from: '3j',
to: '3k',
sql: readFileSync(new URL('./migrations/3j-3k.sql', import.meta.url), 'utf8'),
}];
const timedTables = [
'events',
+43
View File
@@ -0,0 +1,43 @@
// FED R3 (#414, design 3.6): the granted user's transport shim, human-cli.mjs with its own nonce
// variable, a `user` in its input and a user proof. Only the Console's tree runs it (row 196's user
// kind checks the parent). Not a second product CLI. Input and proof never carry service tokens.
import { randomBytes } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs';
import { Client } from './client.mjs';
import { readProcess } from './human.mjs';
const file = fileURLToPath(import.meta.url);
if (!process.env.MOSAIC_BUS_USER_NONCE) {
const child = spawnSync(process.execPath, [file, ...process.argv.slice(2)], {
stdio: 'inherit',
env: { ...process.env, MOSAIC_BUS_USER_NONCE: randomBytes(32).toString('hex') },
});
process.exit(child.status ?? 2);
}
try {
const request = JSON.parse(readFileSync(0, 'utf8'));
if (
!request ||
typeof request !== 'object' ||
typeof request.user !== 'string' ||
Object.keys(request).some((k) => !['business', 'user', 'verb', 'args'].includes(k))
)
throw Error();
const me = readProcess(process.pid);
const client = new Client({
path: process.argv[2],
user: {
business: request.business,
user: request.user,
pid: me.pid,
startTime: me.startTime,
nonce: process.env.MOSAIC_BUS_USER_NONCE,
},
});
const result = await client.call(request.verb, request.args ?? {});
process.stdout.write(JSON.stringify(result) + '\n');
} catch (e) {
process.stderr.write((/^[a-z-]{1,64}$/.test(e.code ?? '') ? e.code : 'invalid-request') + '\n');
process.exitCode = 2;
}
+4 -1
View File
@@ -11,6 +11,7 @@
// every call and labels every reply untrusted (broker.mjs, requestWeb).
import { SEARCH_MAX_QUERY_CHARS, WEB_DEFAULTS, WEB_REFUSAL, WebRefusal, webFetch } from "../../discord/src/web.mjs";
import { currentProcessing } from '../../processing-control/src/effects.mjs';
export const WEB_SEARCH_NOT_CONFIGURED = "search is not configured for this business";
// Under the runner's own 30 s wait for a tool call, so a slow source is a refusal, not a lost reply.
@@ -28,6 +29,7 @@ export function webService({ search = null, maxFetchBytes = WEB_DEFAULTS.maxFetc
// so a stalled backend is cut off, not just ignored: web.mjs destroys its request on abort, and a
// search-source module gets the signal as the `signal` of its last argument.
const within = (start) => {
const processing = currentProcessing(); processing?.guard();
const controller = new AbortController();
let timer;
const late = new Promise((_, reject) => {
@@ -36,7 +38,8 @@ export function webService({ search = null, maxFetchBytes = WEB_DEFAULTS.maxFetc
reject(new WebRefusal(WEB_REFUSAL.TIMEOUT));
}, deadlineMs);
});
return Promise.race([start(controller.signal), late]).finally(() => clearTimeout(timer));
const signal = processing ? AbortSignal.any([processing.signal, controller.signal]) : controller.signal;
return Promise.race([start(signal), late]).finally(() => clearTimeout(timer));
};
async function run(verb, args) {
if (verb === "web.fetch") {
@@ -70,6 +70,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
writeFileSync(join(moduleRoot, 'src/migrations/3h-3i.sql'), step(readFileSync(here('../src/migrations/3h-3i.sql'), 'utf8')));
copyFileSync(here('../src/migrations/3i-3j.sql'), join(moduleRoot, 'src/migrations/3i-3j.sql'));
copyFileSync(here('../src/migrations/3j-3k.sql'), join(moduleRoot, 'src/migrations/3j-3k.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
@@ -77,7 +78,7 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence
const { root, path } = source3h(t), before = snapshot(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3j');
assert.equal(version(path), '3k');
// Every table's rows as they were, usage_records with its seq values and sqlite_sequence with 50.
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
assert.deepEqual(before.rows.usage_records.map(r => r.seq), [1, 2, 3]);
@@ -86,7 +87,9 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence
const old = ' account TEXT CHECK (account IS NULL),\n';
assert.equal(was.filter(r => r.sql?.includes(old)).map(r => r.name).join(), 'usage_records');
// Row 234 (#1740) adds 3i→3j's own objects after this step. Its test is grant-migration.test.mjs.
const later = ['hosts', 'host_grants', 'events_grant_actor', 'sqlite_autoindex_hosts_1', 'sqlite_autoindex_host_grants_1'];
// Row 236 (#1742) adds 3j→3k's; its test is gitea-migration.test.mjs.
const later = ['hosts', 'host_grants', 'events_grant_actor', 'sqlite_autoindex_hosts_1', 'sqlite_autoindex_host_grants_1',
'gitea_users', 'gitea_grants', 'sqlite_autoindex_gitea_users_1', 'sqlite_autoindex_gitea_grants_1'];
assert.deepEqual(master(path).filter(r => !later.includes(r.name)), was.map(r => r.name === 'usage_records' ? { ...r, sql: r.sql.replace(old, ' account TEXT,\n') } : r));
assert.equal(was.filter(r => r.type === 'trigger' && r.tbl_name === 'usage_records').length, 3);
const store = new Store(root);
@@ -125,7 +128,7 @@ test('REQ-LIMIT-4 a 3h→3i step that fails partway leaves the store at 3h with
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
// Still a whole 3h store: the real step migrates it.
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3j');
assert.equal(version(path), '3k');
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
});
@@ -137,14 +140,14 @@ test('REQ-LIMIT-4 the 3h engine refuses a migrated store, migrating or not, and
schema: ddl3h,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3h).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3j';", "export const SCHEMA_VERSION = '3h';")
.replace("export const SCHEMA_VERSION = '3k';", "export const SCHEMA_VERSION = '3h';")
.replace(/\}, \{\n {2}\/\/ Row 126 \(#1620\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3h/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3h';") && !pinned.includes('3h-3i'), 'the 3h engine has no 3h→3i step');
assert.throws(() => new Store3h(root), /schema-mismatch/);
assert.throws(() => new Store3h(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3j');
assert.equal(version(path), '3k');
assert.deepEqual(master(path), was);
assert.deepEqual(snapshot(path, after.columns).rows, after.rows);
});
+6 -2
View File
@@ -4,6 +4,8 @@
// and the ban on channel plug-ins importing a proof caller are retained.
// Row 254 (#1759) re-pins human-cli.mjs for the 130 s library.sync client timeout and webui
// bus.mjs for six library-* STATUS entries; no proof or transport change.
// Row 236 (#1742) re-pins human-cli.mjs for the same 130 s wait on the grants verbs; no proof or
// transport change.
import test from "node:test";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
@@ -14,14 +16,16 @@ import { Client } from "../src/client.mjs";
const baselines = {
"packages/bus/src/human.mjs": "0c6d96abe4607cba0b02b0638a2fc217ff8135bac9a47761a0ef015fbe9d3079",
"packages/bus/src/human-cli.mjs": "9c9af66b98ea0a490c9a880fc81c3c62414a8ee0b5ba6478608771b8c4f87932",
"packages/bus/src/human-cli.mjs": "271b9781d73cf31cc562a56180e3ebcd725b5fde148a83347a201fdef2fc28af",
"packages/cli/src/transport.mjs": "5129c44b4d9f642e825344df65c9bfb4bac4fc8d6b25343b42d39853dbd40774",
// Row 165 (#1662): seven STATUS entries for the role instance routes (organization-instance-exists,
// organization-unknown-instance, organization-credentials-out-of-scope, organization-launch-not-configured,
// organization-no-change, organization-project-mismatch, organization-unresolvable-instance), no proof or
// transport change. Row 155 (#1649) added eight for the assistant routes.
// Row 171 (#1668): project-name-is-repo maps to 409; no proof or transport change.
"packages/webui/src/bus.mjs": "e69d17d4385a4d190d15373ed1a26e270a5c6906eec9e61fcc2bb6b05849cabb",
// Row 194 (#1692, brief item 7, sage-1610 a): organization-tracker-required 400 and
// organization-tracker-bot-taken 409; no proof or transport change.
"packages/webui/src/bus.mjs": "15e8123ffdbb6d067e8a516f5823efdb5de3c869963764f27cd14bb9e4ce3313",
};
const repoFile = path => new URL("../../../" + path, import.meta.url);
+456 -7
View File
@@ -7,9 +7,13 @@ import { createHash } from 'node:crypto';
import { DatabaseSync } from 'node:sqlite';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { Store, schemaDigest } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
import { Broker, ACTIONS } from '../src/broker.mjs';
import { HIRE_ALLOWED } from '../../business/src/vocabulary.mjs';
import { startBroker } from '../src/runtime.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { busBusiness } from '../src/business.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { resolveInstance } from '../../business/src/resolve.mjs';
// Row 123 (#1617): the chief of staff ends runs without a decision and changes the organization
// file through the shared write path. REQ-ROLE-2 (one holder; revoke), REQ-ROLE-3 (the
@@ -27,7 +31,7 @@ const roles = {
pm: { definition: 'pm', ...none },
coder: { definition: 'coder', ...none },
};
const demo = () => ({
const demo = (extra = {}) => ({
demo: {
id: 'demo',
human: 'jason',
@@ -38,12 +42,13 @@ const demo = () => ({
// Row 65: runtime launches bind only on a harness the business switches on.
plugins: { harness: ['pi'] },
vars: { 'bus.tester': true },
...extra,
},
});
const PATCH = { revision: 'a'.repeat(64), limits: { total: 3 } };
function setup(t) {
function setup(t, extra = {}, { roleFile = null } = {}) {
const root = mkdtempSync(join(tmpdir(), 'bus-chief-'));
const store = new Store(root);
t.after(() => {
@@ -51,13 +56,16 @@ function setup(t) {
rmSync(root, { recursive: true, force: true });
});
const organization = [];
const writer = { error: null };
const writer = { error: null, during: null };
const b = new Broker({
store,
businesses: demo(),
businesses: demo(extra),
roleFile,
organization: (...args) => {
organization.push(args);
if (writer.during) writer.during(args);
if (writer.error) throw writer.error;
if (args[1] === 'instance.hire' || args[1] === 'instance.fire') return { revision: 'b'.repeat(64), transaction: 't-' + organization.length };
// The human's limits read answers more than the revision; organization.revision must not.
return args[1] === 'limits'
? { revision: 'b'.repeat(64), limits: { total: 7 }, models: { m: {} }, tokens: { used: 1 } }
@@ -606,7 +614,7 @@ async function database3c(t) {
let source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3j';", "export const SCHEMA_VERSION = '3c';")
.replace("export const SCHEMA_VERSION = '3k';", "export const SCHEMA_VERSION = '3c';")
.replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.notEqual(pinned, source);
assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step');
@@ -630,7 +638,7 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3j');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get("SELECT holder_run FROM role_claims WHERE role='coder'").holder_run, 'coder-run');
const b = new Broker({ store, businesses: demo(), organization: null });
@@ -645,3 +653,444 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
);
assert.throws(() => store.run(REVOKE_NEITHER), /revoke needs a resolved decision or a reason/);
});
// Row 194 (#1692, D2.4): the per-agent forbid. The chief can't end a kept instance's run with a
// reason or change its model. A decision the human resolved, and the human's own write, still can.
const KEPT = { launch: { by: 'chief', instances: ['pm', 'coder'], keep: ['coder'] } };
test('row 194 T10: the chief\'s reason end of a kept instance refuses instance-kept, holds the claim and spends nothing', (t) => {
const { call, lastClaim, allowed } = setup(t, KEPT);
assert.throws(() => call('chief', 'role.revoke', { role: 'coder', reason: 'replace it' }), /instance-kept/);
assert.deepEqual(lastClaim('coder'), { op: 'claim', decision: null, reason: null, by: 'coder-run' });
assert.equal(allowed('role.end').length, 0);
// An instance not on the list still ends with a reason.
assert.deepEqual(call('chief', 'role.revoke', { role: 'pm', reason: 'stuck' }), { revoked: true });
});
test('row 194 T10 (Q2): a role.revoke citing a decision the human resolved still ends a kept run', (t) => {
const { call, lastClaim } = setup(t, KEPT);
const d = call('chief', 'decision.raise', {
action: 'role.revoke', target: 'coder', question: 'Stop the coder?',
options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false,
});
call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
assert.deepEqual(call('chief', 'role.revoke', { role: 'coder', decision: d.id }), { revoked: true });
assert.deepEqual(lastClaim('coder'), { op: 'revoke', decision: d.id, reason: null, by: 'chief-run' });
});
test('row 194 T10 (B7): the chief\'s organization.set of a kept instance\'s model refuses before the writer; the human\'s limits.set does not', (t) => {
const { call, organization, allowed } = setup(t, KEPT);
assert.throws(() => call('chief', 'organization.set', { revision: PATCH.revision, instances: { coder: 'm' } }), /instance-kept/);
assert.throws(() => call('chief', 'organization.set', { revision: PATCH.revision, instances: { pm: 'm', coder: 'm' } }), /instance-kept/);
assert.equal(organization.length, 0);
assert.equal(allowed('organization.change').length, 0);
call('chief', 'organization.set', { revision: PATCH.revision, instances: { pm: 'm' } });
call('human', 'limits.set', { revision: PATCH.revision, instances: { coder: 'm' } });
assert.deepEqual(organization.map(([, verb, args]) => [verb, Object.keys(args.instances)]), [['limits.set', ['pm']], ['limits.set', ['coder']]]);
});
test('row 194 T13: organization.set and limits.set refuse the launch, retired, keep and noHire keys before the writer', (t) => {
const { call, organization } = setup(t, KEPT);
for (const extra of [{ launch: { keep: [] } }, { retired: [] }, { keep: ['pm'] }, { noHire: ['coder'] }])
for (const [who, verb] of [['chief', 'organization.set'], ['human', 'limits.set']])
assert.throws(() => call(who, verb, { ...PATCH, ...extra }), /invalid-request/, `${who} ${Object.keys(extra)[0]}`);
assert.equal(organization.length, 0);
});
test('row 194 T18 (D2.4): from a chief session veto.set and instances.set refuse, and nothing reaches the writer', (t) => {
const { call, organization } = setup(t, KEPT);
for (const op of ['keep', 'unkeep', 'nohire', 'hire'])
assert.throws(() => call('chief', 'veto.set', { revision: PATCH.revision, op, name: 'coder' }), /human-required/, op);
for (const op of ['add', 'change', 'delete'])
assert.throws(() => call('chief', 'instances.set', { revision: PATCH.revision, instance: 'coder', op, definition: 'coder' }), /human-required/, op);
assert.equal(organization.length, 0);
// The human's veto reaches the writer with the human as actor.
call('human', 'veto.set', { revision: PATCH.revision, op: 'unkeep', name: 'coder' });
assert.deepEqual(organization.at(-1), ['demo', 'veto.set', { revision: PATCH.revision, op: 'unkeep', name: 'coder' }, 'jason']);
});
// Row 194 (#1692, D2.1-D2.3): the chief's hire and fire. PRD 1.7 REQ-COS-3 routes role.hire and
// role.fire within-role for the chief (sage-1471, mos-1089), so they take the reason form, as
// role.end does: no decision, a reason of 1 to 500 characters that goes into the log. The role
// files are stubs, so a definition's authority is the test's to set.
const LISTED = { launch: { by: 'chief', instances: ['pm', 'coder'], keep: ['pm'], noHire: ['banned'] } };
const roleDoc = (withinRole, extra = {}) => ({ roleVersion: 2, authority: { withinRole, crossRole: [] }, hireable: [], ...extra });
const ROLE_FILES = {
'chief-of-staff': roleDoc(chiefAuthority, { hireable: ['coder', 'researcher', 'wide', 'banned'] }),
coder: roleDoc(['message.send', 'record.write']),
// A version 1 file has empty authority lists, so only the version check refuses it (M60).
researcher: { roleVersion: 1, authority: { withinRole: [], crossRole: [] }, hireable: [] },
wide: roleDoc(['message.send', 'task.create']),
banned: roleDoc(['message.send']),
pm: roleDoc(['message.send']),
};
const roleFile = (files) => (name) => {
if (!Object.hasOwn(files, name)) throw Object.assign(new Error(`no role ${name}`), { name: 'BusinessError' });
return files[name];
};
function hireSetup(t, extra = LISTED, files = ROLE_FILES) {
return setup(t, extra, { roleFile: roleFile(files) });
}
const HIRE = { name: 'helper', definition: 'coder', reason: 'more hands for the queue' };
const FIRE = { name: 'coder', reason: 'the work is done' };
const CONTEXT = (reason) => ({ instance: 'chief', run: 'chief-run', reason });
const withoutReason = ({ reason, ...rest }) => rest;
test('row 194 T8: the chief hires with a reason and no decision, the writer gets the reason as context, and the log names it', (t) => {
const { call, organization, allowed, store } = hireSetup(t);
const result = call('chief', 'instance.hire', HIRE);
assert.equal(result.transaction, 't-1');
assert.deepEqual(organization, [['demo', 'instance.hire', withoutReason(HIRE), 'chief-of-staff.chief', CONTEXT(HIRE.reason)]]);
const [use, ...rest] = allowed('role.hire');
assert.equal(rest.length, 0);
assert.equal(use.subject, 'helper');
assert.deepEqual({ reason: use.body.reason, transaction: use.body.transaction, decision: use.body.decision ?? null }, { reason: HIRE.reason, transaction: 't-1', decision: null });
// Within-role: no decision is raised or needed.
assert.equal(store.get('SELECT count(*) AS n FROM decisions').n, 0);
// vars reach the writer with the name and definition.
call('chief', 'instance.hire', { ...HIRE, name: 'aide', vars: { model: 'm', 'limits.x': 1 } });
assert.deepEqual(organization.at(-1)[2], { name: 'aide', definition: 'coder', vars: { model: 'm', 'limits.x': 1 } });
});
// T11 (D5): a business id names one business on one bus. The chief's hire and fire go to its own
// business's writer, and no argument names another.
test('row 194 T11: on a bus with two businesses, the chief of one hires and fires only in its own', (t) => {
const root = mkdtempSync(join(tmpdir(), 'bus-chief-two-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const organization = [];
const one = demo(LISTED).demo;
const b = new Broker({
store,
businesses: { demo: one, other: { ...one, id: 'other' } },
roleFile: roleFile(ROLE_FILES),
organization: (...args) => (organization.push(args), { revision: 'b'.repeat(64), transaction: 't-' + organization.length }),
});
const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
b.request(chief, { verb: 'role.claim' });
for (const verb of ['instance.hire', 'instance.fire'])
for (const extra of [{ business: 'other' }, { target: 'other' }])
assert.throws(() => b.request(chief, { verb, args: { ...(verb === 'instance.hire' ? HIRE : FIRE), ...extra } }), /invalid-request/, `${verb} ${Object.keys(extra)}`);
assert.equal(organization.length, 0);
b.request(chief, { verb: 'instance.hire', args: HIRE });
b.request(chief, { verb: 'instance.fire', args: FIRE });
assert.deepEqual(organization.map((c) => [c[0], c[1]]), [['demo', 'instance.hire'], ['demo', 'instance.fireCheck'], ['demo', 'instance.fire']]);
assert.equal(store.get("SELECT count(*) AS n FROM events WHERE business='other'").n, 0);
});
test('row 194 T8: hire and fire need a reason of 1 to 500 characters and refuse a decision key', (t) => {
const { call, organization } = hireSetup(t);
for (const reason of [undefined, '', 'x'.repeat(501), 7]) {
const r = reason === undefined ? {} : { reason };
assert.throws(() => call('chief', 'instance.hire', { name: 'helper', definition: 'coder', ...r }), /invalid-request/, String(reason));
assert.throws(() => call('chief', 'instance.fire', { name: 'coder', ...r }), /invalid-request/, String(reason));
}
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, decision: 'd-1' }), /invalid-request/);
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, decision: 'd-1' }), /invalid-request/);
for (const name of ['human', 'tester']) assert.throws(() => call('chief', 'instance.hire', { ...HIRE, name }), /invalid-request/, name);
assert.equal(organization.length, 0);
call('chief', 'instance.hire', { ...HIRE, reason: 'x'.repeat(500) });
assert.equal(organization.length, 1);
});
test('row 194 T8 (sage-1471): only the chief hires or fires; every other role refuses role-not-chief, even one granted the actions', (t) => {
const granted = { authority: { withinRole: ['message.send', 'role.hire', 'role.fire'], crossRole: [] } };
const { call, organization, allowed } = hireSetup(t, { ...LISTED, roles: { ...roles, pm: { definition: 'pm', ...granted }, coder: { definition: 'coder', ...granted } } });
for (const who of ['pm', 'coder', 'assistant']) {
assert.throws(() => call(who, 'instance.hire', HIRE), /role-not-chief/, who);
assert.throws(() => call(who, 'instance.fire', FIRE), /role-not-chief/, who);
}
assert.throws(() => call('tester', 'instance.hire', HIRE), /tester-refused/);
assert.throws(() => call('reader', 'instance.hire', HIRE), /read-only/);
assert.throws(() => call('human', 'instance.fire', FIRE), /agent-required/);
assert.equal(organization.length, 0);
assert.equal(allowed('role.hire').length + allowed('role.fire').length, 0);
});
test('row 194 T8 (sage-1471): a chief whose authority lacks role.hire or role.fire refuses decision-required before the writer', (t) => {
for (const missing of ['role.hire', 'role.fire']) {
const authority = { withinRole: chiefAuthority.withinRole.filter((a) => a !== missing), crossRole: [] };
const { call, organization, lastClaim } = hireSetup(t, { ...LISTED, roles: { ...roles, chief: { definition: 'chief-of-staff', authority } } });
const [verb, args] = missing === 'role.hire' ? ['instance.hire', HIRE] : ['instance.fire', FIRE];
assert.throws(() => call('chief', verb, args), /decision-required/, missing);
assert.equal(organization.length, 0, missing);
assert.equal(lastClaim('coder').op, 'claim', missing);
// The other action still goes through.
const [other, otherArgs] = missing === 'role.hire' ? ['instance.fire', FIRE] : ['instance.hire', HIRE];
call('chief', other, otherArgs);
assert.equal(organization.filter(([, verb]) => verb !== 'instance.fireCheck').length, 1, missing);
}
});
test('row 194 T8a: a re-hire of a fired name goes to the writer like a hire, and the writer\'s refusals pass through by name', (t) => {
const { call, organization, writer } = hireSetup(t);
const again = { name: 'coder', definition: 'coder', reason: 'needed again' };
for (const code of ['organization-instance-exists', 'organization-instance-retired', 'definition-not-hireable', 'instance-kept']) {
writer.error = Object.assign(new Error(code), { code });
assert.throws(() => call('chief', 'instance.hire', again), new RegExp(code));
}
writer.error = null;
call('chief', 'instance.hire', again);
assert.deepEqual(organization.at(-1).slice(1, 3), ['instance.hire', withoutReason(again)]);
});
test('row 194 T8b: a definition is hireable only when every action is on HIRE_ALLOWED, it is version 2, the chief lists it and noHire doesn\'t', (t) => {
assert.deepEqual([...HIRE_ALLOWED], ['message.send', 'record.write', 'review.request', 'task.update.assigned', 'task.scope.change', 'git.push.working']);
for (const action of ACTIONS) {
const files = { ...ROLE_FILES, wide: roleDoc(['message.send', action]) };
const { call, organization } = hireSetup(t, LISTED, files);
const run = () => call('chief', 'instance.hire', { ...HIRE, definition: 'wide' });
if (HIRE_ALLOWED.includes(action)) {
run();
assert.equal(organization.length, 1, action);
} else {
assert.throws(run, /definition-not-hireable/, action);
assert.equal(organization.length, 0, action);
}
}
const { call, organization } = hireSetup(t);
const hire = (definition) => call('chief', 'instance.hire', { ...HIRE, definition });
// Version 1 (M60), noHire, not on the chief's list, and no role file.
for (const definition of ['researcher', 'banned', 'pm', 'nothing'])
assert.throws(() => hire(definition), /definition-not-hireable/, definition);
assert.equal(organization.length, 0);
hire('coder');
assert.equal(organization.length, 1);
});
test('row 194 T8b: a hire without the role files configured refuses not-configured', (t) => {
const { call, organization } = setup(t, LISTED);
assert.throws(() => call('chief', 'instance.hire', HIRE), /not-configured/);
assert.equal(organization.length, 0);
assert.throws(() => new Broker({ store: null, businesses: demo(), roleFile: 'roles' }), /roleFile must be a function/);
});
test('row 194 T9: a fire revokes the claim, cancels open requests, asks the launcher to kill the runs, then delists and logs', (t) => {
const { call, organization, allowed, lastClaim, writer } = hireSetup(t);
// An open launch request of the coder, made by the human.
const open = call('human', 'instance.launch', { instance: 'coder' }).request;
let seen = null;
writer.during = ([, verb]) => { if (verb === 'instance.fire') seen = { stopped: allowed('fire.stopped').length, claim: lastClaim('coder').op, used: allowed('role.fire').length }; };
const result = call('chief', 'instance.fire', FIRE);
// Step A ran before the writer; step C after it.
assert.deepEqual(seen, { stopped: 1, claim: 'revoke', used: 0 });
assert.deepEqual(lastClaim('coder'), { op: 'revoke', decision: null, reason: 'the work is done', by: 'chief-run' });
assert.deepEqual(result.runs, ['coder-run']);
assert.deepEqual(result.cancelled, [open]);
assert.deepEqual(organization.at(-1), ['demo', 'instance.fire', { name: 'coder' }, 'chief-of-staff.chief', CONTEXT(FIRE.reason)]);
const [end] = allowed('lifecycle.end');
assert.equal(end.subject, 'coder');
assert.deepEqual({ ...end.body, request: null }, { action: 'lifecycle.end', request: null, via: 'chief', by: 'chief', runs: ['coder-run'], reason: 'the work is done', kill: true });
assert.equal(allowed('lifecycle.cancelled').filter((e) => e.body.request === open).length, 1);
const [use] = allowed('role.fire');
assert.equal(use.subject, 'coder');
assert.equal(use.body.reason, 'the work is done');
assert.equal(use.body.transaction, result.transaction);
assert.deepEqual(allowed('fire.done').map((e) => [e.subject, e.body.transaction]), [['coder', result.transaction]]);
// The human's read of the request shows the kill.
assert.equal(call('human', 'instance.request', { request: end.body.request }).kill, true);
});
// S6 (D2.2): the launcher closes a fire's end with the runs row 191's stop killed or left alive, and
// each becomes its own event against the instance. Only a fire's end carries them.
test('row 194 T9a (bus): a fire end\'s done writes lifecycle.end.killed and lifecycle.end.alive with run and pid, and an unknown stop\'s reason; a terminate\'s done or a bad stop refuses', (t) => {
const { b, call, allowed } = hireSetup(t);
call('chief', 'instance.fire', FIRE);
const [end] = allowed('lifecycle.end');
const request = end.body.request;
assert.equal(b.lifecycleTake({ business: 'demo', request }).go, true);
const done = (stops) => b.lifecycleDone({ business: 'demo', request, stops });
for (const bad of [[{ run: 'coder-run', pid: 4242, outcome: 'stopped' }], [{ run: 'coder-run', pid: 1, outcome: 'killed' }],
[{ run: 'coder-run', outcome: 'killed' }], [{ run: 'coder-run', pid: 4242, outcome: 'killed', signals: [] }], 'killed',
// sage-2289: only an alive stop carries a reason, and only unknown.
[{ run: 'coder-run', pid: 4242, outcome: 'killed', reason: 'unknown' }], [{ run: 'coder-run', pid: null, outcome: 'alive', reason: 'stuck' }]])
assert.throws(() => done(bad), { code: 'invalid-request' }, JSON.stringify(bad));
const closed = done([{ run: 'coder-run', pid: 4242, outcome: 'killed' }, { run: 'coder-other', pid: null, outcome: 'alive' },
{ run: 'coder-unknown', pid: null, outcome: 'alive', reason: 'unknown' }]);
assert.equal(closed.state, 'closed');
assert.deepEqual(allowed('lifecycle.end.killed').map((e) => [e.subject, e.body]), [['coder', { action: 'lifecycle.end.killed', request, run: 'coder-run', pid: 4242 }]]);
assert.deepEqual(allowed('lifecycle.end.alive').map((e) => [e.subject, e.body]), [['coder', { action: 'lifecycle.end.alive', request, run: 'coder-other', pid: null }],
['coder', { action: 'lifecycle.end.alive', request, run: 'coder-unknown', pid: null, reason: 'unknown' }]]);
// A terminate's end sends SIGTERM only, so its done has no stops.
const term = call('human', 'instance.end', { instance: 'pm' }).request;
assert.equal(b.lifecycleTake({ business: 'demo', request: term }).go, true);
assert.throws(() => b.lifecycleDone({ business: 'demo', request: term, stops: [{ run: 'pm-run', pid: 4242, outcome: 'killed' }] }), { code: 'invalid-request' });
assert.equal(allowed('lifecycle.end.killed').length, 1);
assert.equal(b.lifecycleDone({ business: 'demo', request: term }).state, 'closed');
});
test('row 194 T9: fire refusals come before step A and leave the claim, the requests and the file alone', (t) => {
const { call, organization, allowed, lastClaim } = hireSetup(t, { launch: { by: 'chief', instances: ['coder'], keep: ['pm'] } });
const fire = (name) => call('chief', 'instance.fire', { name, reason: 'r' });
assert.throws(() => fire('pm'), /instance-kept/);
assert.throws(() => fire('nobody'), /unknown-role/);
assert.throws(() => fire('assistant'), /organization-definition-refused/);
assert.throws(() => fire('chief'), /organization-instance-referenced/);
assert.throws(() => fire('coder'), /invalid-request/);
assert.equal(organization.length, 0);
assert.equal(allowed('fire.stopped').length + allowed('lifecycle.end').length + allowed('role.fire').length, 0);
assert.equal(lastClaim('coder').op, 'claim');
});
test('row 194 T9c: a writer failure at B leaves the instance firing, and the repeat delists and logs once', (t) => {
const { call, b, caps, organization, allowed, writer } = hireSetup(t);
writer.during = ([, verb]) => { if (verb === 'instance.fire') throw new Error('disk'); };
assert.throws(() => call('chief', 'instance.fire', FIRE), /outcome-unknown/);
writer.during = null;
assert.equal(allowed('fire.stopped').length, 1);
assert.equal(allowed('role.fire').length, 0);
// W-N1: while it fires, no launch of the instance starts by any path.
assert.throws(() => call('human', 'instance.launch', { instance: 'coder' }), /instance-firing/);
assert.throws(() => b.authorize(caps.chief, 'role.launch', { target: 'coder' }), /instance-firing/);
// The repeat has no claim to revoke. The revoked run is still bound until the launcher reports it
// ended, so the repeat asks for its kill again, then delists and logs.
const result = call('chief', 'instance.fire', FIRE);
assert.deepEqual(result.runs, ['coder-run']);
assert.equal(organization.filter(([, verb]) => verb === 'instance.fire').length, 2);
assert.equal(allowed('role.fire').length, 1);
assert.deepEqual(allowed('lifecycle.end').map((e) => e.body.kill), [true, true]);
// Once done, the instance launches again.
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
});
test('row 194 T9c: a failure at C leaves the fire unlogged and the instance firing, and the repeat logs it', (t) => {
const { call, organization, allowed, store, writer } = hireSetup(t);
// The delist commits, then the step C transaction aborts.
writer.during = ([, verb]) => verb === 'instance.fire' && store.run("CREATE TRIGGER fail_c BEFORE INSERT ON events WHEN json_extract(new.body,'$.action')='fire.done' BEGIN SELECT RAISE(ABORT, 'c failed'); END");
assert.throws(() => call('chief', 'instance.fire', FIRE));
writer.during = null;
store.run('DROP TRIGGER fail_c');
assert.equal(allowed('role.fire').length, 0);
assert.equal(allowed('fire.done').length, 0);
assert.throws(() => call('human', 'instance.launch', { instance: 'coder' }), /instance-firing/);
call('chief', 'instance.fire', FIRE);
assert.equal(organization.filter(([, verb]) => verb === 'instance.fire').length, 2);
assert.equal(allowed('role.fire').length, 1);
assert.equal(allowed('fire.done').length, 1);
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
});
test('row 194 W-N1: a fire.done before a later fire.stopped of the same instance leaves it firing; another instance\'s done does not end it', (t) => {
const { call, store } = hireSetup(t);
const step = (instance, action) =>
store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,'demo','action.allowed','chief','chief-run',?,?)",
`${action}-${instance}-${Math.random()}`, new Date().toISOString(), instance, JSON.stringify({ action, request: null, transaction: null }));
step('coder', 'fire.done');
step('coder', 'fire.stopped');
step('pm', 'fire.done');
assert.throws(() => call('human', 'instance.launch', { instance: 'coder' }), /instance-firing/);
step('coder', 'fire.done');
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
});
test('row 194 T10: a hire or fire of a kept name refuses instance-kept before the writer', (t) => {
const { call, organization, lastClaim, allowed } = hireSetup(t);
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, name: 'pm' }), /instance-kept/);
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, name: 'pm' }), /instance-kept/);
assert.equal(organization.length, 0);
assert.equal(lastClaim('pm').op, 'claim');
assert.equal(allowed('role.hire').length + allowed('role.fire').length, 0);
});
// webby-0094 B1: the file's keep, which this copy hasn't loaded, refuses at the fire check, before
// step A revokes, ends or marks anything, so the instance isn't left firing.
test('row 194 T10: a fire the writer\'s check refuses from the file stops nothing and leaves the instance launchable', (t) => {
const { call, organization, lastClaim, allowed, writer } = hireSetup(t);
writer.during = ([, verb]) => { if (verb === 'instance.fireCheck') throw Object.assign(new Error('instance-kept'), { code: 'instance-kept' }); };
assert.throws(() => call('chief', 'instance.fire', FIRE), /instance-kept/);
writer.during = null;
assert.deepEqual(organization.map(([, verb]) => verb), ['instance.fireCheck']);
assert.equal(lastClaim('coder').op, 'claim');
assert.equal(allowed('fire.stopped').length + allowed('lifecycle.end').length + allowed('role.fire').length, 0);
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
});
test('row 194 T18: hire and fire take only their own keys, and a refused call writes nothing', (t) => {
const { call, organization, allowed } = hireSetup(t);
for (const extra of [{ keep: ['x'] }, { launch: {} }, { revision: 'a'.repeat(64) }, { force: true }]) {
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, ...extra }), /invalid-request/, JSON.stringify(extra));
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, ...extra }), /invalid-request/, JSON.stringify(extra));
}
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, vars: { harness: 'pi' } }), /invalid-request/);
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, definition: 'coder' }), /invalid-request/);
assert.equal(organization.length, 0);
assert.equal(allowed('fire.stopped').length, 0);
});
test('row 194 T8, T9, T18: through the runtime the chief hires and fires with the real writer, the receipt carries the reason, and a refusal leaves the file alone', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'bus-chief-hire-'));
const rolesDir = rolesCopy(root);
// A hireable scratch definition: version 2, no credentials, every action on HIRE_ALLOWED.
writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n');
writeJson(join(rolesDir, 'aide.json'), {
roleVersion: 2, name: 'aide', title: 'Aide', contract: 'aide.md', tools: ['read'], network: 'api-only',
authority: { withinRole: ['message.send'], crossRole: [] }, credentials: [],
});
const chiefFile = join(rolesDir, 'chief-of-staff.json');
writeJson(chiefFile, { ...JSON.parse(readFileSync(chiefFile, 'utf8')), hireable: ['aide'] });
const file = join(root, 'businesses/demo.json');
writeJson(file, { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } });
mkdirSync(join(root, 'data'), { mode: 0o700 });
// The boot copy is the file's own bus form, as cli bootConfig builds it, so a write reloads.
const busForm = (dir, roles) => {
const b = loadBusiness('demo', { dir, rolesDir: roles });
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: systemFor(dir), business: b, instance });
return busBusiness(b, resolved);
};
const bus = await startBroker({
dataRoot: join(root, 'data'),
businesses: { demo: busForm(root, rolesDir) },
organization: { dir: root, rolesDir, system: systemFor(root) },
});
t.after(async () => {
await bus.close();
rmSync(root, { recursive: true, force: true });
});
const { broker } = bus;
const chief = broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
broker.request(chief, { verb: 'role.claim' });
const call = (cap, verb, args) => broker.request(cap, { verb, args });
const receipts = () =>
readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').map(JSON.parse).filter((r) => r.state === 'committed');
const document = () => JSON.parse(readFileSync(file, 'utf8'));
// The shipped chief file grants both actions, so the runtime takes the reason form.
// A refused hire: coder declares credentials and isn't on the chief's list. The file is unchanged.
const bytes = readFileSync(file, 'utf8');
assert.throws(() => call(chief, 'instance.hire', { name: 'helper', definition: 'coder', reason: 'r' }), /definition-not-hireable/);
assert.equal(readFileSync(file, 'utf8'), bytes);
call(chief, 'instance.hire', { name: 'helper', definition: 'aide', reason: 'more hands' });
assert.deepEqual(document().roles.helper, { definition: 'aide' });
assert.ok(document().launch.instances.includes('helper'));
const [h] = receipts().slice(-1);
assert.equal(h.actor, 'chief-of-staff.chief');
assert.deepEqual(h.context, CONTEXT('more hands'));
const result = call(chief, 'instance.fire', { name: 'helper', reason: 'the work is done' });
assert.equal(result.delisted, true);
assert.ok(!document().launch.instances.includes('helper'));
assert.deepEqual(receipts().slice(-1)[0].context, CONTEXT('the work is done'));
// The reloaded business sees the delist: a refused call leaves the file alone.
const after = readFileSync(file, 'utf8');
assert.throws(() => call(chief, 'instance.fire', { name: 'helper', reason: 'again', extra: 1 }), /invalid-request/);
assert.equal(readFileSync(file, 'utf8'), after);
});
test('row 194 W-N1: the launcher\'s take of a launch or regenerate of a firing instance closes it as instance-firing', (t) => {
const { b, call, store } = hireSetup(t);
const launch = call('human', 'instance.launch', { instance: 'coder' }).request;
const regenerate = call('human', 'instance.regenerate', { instance: 'pm' }).request;
// A fire's step A as the store holds it with no step C, for requests step A didn't see.
for (const instance of ['coder', 'pm'])
store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,'demo','action.allowed','chief','chief-run',?,?)",
'stopped-' + instance, new Date().toISOString(), instance, JSON.stringify({ action: 'fire.stopped', request: null }));
for (const request of [launch, regenerate]) {
const { go, refusal } = b.lifecycleTake({ business: 'demo', request });
assert.deepEqual({ go, refusal }, { go: false, refusal: 'instance-firing' });
}
});
@@ -33,7 +33,7 @@ test('explicit 3b copy migration preserves evidence and opens as 3g; normal star
const original = readFileSync(source);
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3j');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value;
@@ -75,15 +75,15 @@ test('named migration registry chains 3b through 3g to a fixture-only future ver
const sha = createHash('sha256').update(futureDDL).digest('hex');
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8');
source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`)
.replace("export const SCHEMA_VERSION = '3j';", "export const SCHEMA_VERSION = '3k';")
.replace('}];\nconst timedTables', `}, { from: '3j', to: '3k', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
.replace("export const SCHEMA_VERSION = '3k';", "export const SCHEMA_VERSION = '3l';")
.replace('}];\nconst timedTables', `}, { from: '3k', to: '3l', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL);
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql'])
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql'])
copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name));
const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
const future = new FutureStore(root, { migrateSchema: true });
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture');
assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0);
assert.equal(future.get("SELECT id FROM events").id, 'preserved');
@@ -109,7 +109,7 @@ test('offline command migrates only the supplied fixture root and reports usage
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr);
assert.match(migrated.stdout, /bus schema 3j ready/);
assert.match(migrated.stdout, /bus schema 3k ready/);
new Store(root).close();
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
assert.equal(usage.status, 2);
+191
View File
@@ -0,0 +1,191 @@
// Row 236 (#1742, #436b r5 section 9): a fake Gitea API for the grant executor, on 127.0.0.1 over TLS
// with row 199's scratch CA. It keeps users, their tokens and repositories in memory and answers the
// routes gitea-grant.mjs calls, with Gitea's auth rules as section 1 found them: the token routes take
// Basic auth as the account itself or a site admin, the rest the `token` header. Every token is a fake
// made here. The request log holds the method, the path, the auth kind and the login, never a token.
// `rule(match, answer)` makes one route answer otherwise: a status and body, `hang` (no answer, so the
// caller's timeout runs) or `drop` (the socket closes). Nothing here leaves 127.0.0.1.
import { randomBytes } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { createServer } from 'node:https';
import { scratchCa } from '../../seat/tests/fake-gitea.mjs';
const fakeToken = () => randomBytes(20).toString('hex');
export async function fakeGiteaApi(t, dir, { version = '1.27.1', admin = 'mosaic-grant', adminScopes = ['write:admin', 'write:repository', 'write:user'] } = {}) {
const tls = scratchCa(dir);
let nextId = 1;
const users = new Map();
const repos = [];
const rules = [];
const requests = [];
const state = { version };
const lower = (x) => String(x).toLowerCase();
const userJson = (u) => ({ id: u.id, login: u.login, email: u.email, full_name: u.full_name, is_admin: u.is_admin, prohibit_login: u.prohibit_login });
const repoJson = (r) => ({
id: r.id, name: r.name, full_name: `${r.owner}/${r.name}`, owner: { login: r.owner }, fork: Boolean(r.parent),
parent: r.parent ? { full_name: r.parent } : null,
});
const api = {
url: null,
ca: readFileSync(tls.ca, 'utf8'),
state,
requests,
users,
repos,
// Every token value this fake made, for the tests' leak checks. Never printed.
issued: [],
// A user and, with `scopes`, one token named `tokenName`. Returns the user and that token or null.
addUser(login, { is_admin = false, prohibit_login = false, full_name = login, email = `${login}@example.test`, scopes = null, tokenName = 'test' } = {}) {
const u = { id: nextId++, login, email, full_name, is_admin, prohibit_login, tokens: [] };
users.set(lower(login), u);
return { user: u, token: scopes ? api.addToken(login, tokenName, scopes) : null };
},
addToken(login, name, scopes) {
const u = users.get(lower(login));
const tok = { id: nextId++, name, sha1: fakeToken(), scopes: [...scopes].sort() };
u.tokens.push(tok);
api.issued.push(tok.sha1);
return tok;
},
removeUser(login) {
users.delete(lower(login));
},
addRepo(owner, name, { parent = null, collaborators = [] } = {}) {
const r = { id: nextId++, owner, name, parent, collaborators: collaborators.map(lower) };
repos.push(r);
return r;
},
tokens: (login) => users.get(lower(login))?.tokens.map((x) => ({ id: x.id, name: x.name, scopes: x.scopes })) ?? null,
rule(match, answer) {
const r = { match, answer };
rules.push(r);
return () => rules.splice(rules.indexOf(r), 1);
},
// The ops of the log, as "METHOD path" lines without the query.
calls: () => requests.map((r) => `${r.method} ${r.path.split('?')[0]}`),
};
const adminUser = api.addUser(admin, { is_admin: true, scopes: adminScopes, tokenName: 'mosaic-grant-bootstrap' });
api.admin = adminUser.user;
api.adminToken = adminUser.token.sha1;
const byToken = (sha1) => {
for (const u of users.values()) {
const tok = u.tokens.find((x) => x.sha1 === sha1);
if (tok) return { user: u, token: tok };
}
return null;
};
const authOf = (req) => {
const h = req.headers.authorization ?? '';
if (h.startsWith('token ')) return { kind: 'token', who: byToken(h.slice(6)) };
if (h.startsWith('Basic ')) {
const [login, ...rest] = Buffer.from(h.slice(6), 'base64').toString('utf8').split(':');
const who = byToken(rest.join(':'));
return { kind: 'basic', who: who && lower(who.user.login) === lower(login) ? who : null };
}
return { kind: 'none', who: null };
};
const reachable = (u) => repos.filter((r) => lower(r.owner) === lower(u.login) || r.collaborators.includes(lower(u.login)));
function route(method, path, query, auth, body) {
const who = auth.who;
const p = path.split('/').slice(3).map(decodeURIComponent);
if (method === 'GET' && path === '/api/v1/version') return [200, { version: state.version }];
if (!who) return [401, { message: 'unauthorized' }];
const me = who.user;
if (method === 'GET' && path === '/api/v1/user') return [200, userJson(me)];
if (method === 'GET' && path === '/api/v1/token') return [200, { id: who.token.id, name: who.token.name, scopes: who.token.scopes }];
if (method === 'GET' && path === '/api/v1/user/repos') {
const all = reachable(me);
const limit = Number(query.get('limit') ?? 30);
const page = Number(query.get('page') ?? 1);
return [200, all.slice((page - 1) * limit, page * limit).map(repoJson), all.length];
}
if (p[0] === 'users' && p.length === 2 && method === 'GET') {
const u = users.get(lower(p[1]));
return u ? [200, userJson(u)] : [404, { message: 'user does not exist' }];
}
if (p[0] === 'admin' && p[1] === 'users') {
if (!me.is_admin || !who.token.scopes.includes('write:admin')) return [403, { message: 'forbidden' }];
if (method === 'POST' && p.length === 2) {
if (users.has(lower(body.username))) return [422, { message: 'user already exists' }];
const u = { id: nextId++, login: body.username, email: body.email, full_name: body.full_name, is_admin: false, prohibit_login: false, tokens: [] };
users.set(lower(u.login), u);
return [201, userJson(u)];
}
if (method === 'PATCH' && p.length === 3) {
const u = users.get(lower(p[2]));
if (!u) return [404, { message: 'user does not exist' }];
if (typeof body.prohibit_login === 'boolean') u.prohibit_login = body.prohibit_login;
return [200, userJson(u)];
}
}
if (p[0] === 'users' && p[2] === 'tokens') {
if (auth.kind !== 'basic') return [401, { message: 'basic auth required' }];
const u = users.get(lower(p[1]));
if (lower(me.login) !== lower(p[1]) && !me.is_admin) return [403, { message: 'forbidden' }];
if (!u) return [404, { message: 'user does not exist' }];
if (method === 'GET' && p.length === 3) {
const limit = Number(query.get('limit') ?? 30);
const page = Number(query.get('page') ?? 1);
const list = u.tokens.map((x) => ({ id: x.id, name: x.name, scopes: x.scopes }));
return [200, list.slice((page - 1) * limit, page * limit), list.length];
}
if (method === 'POST' && p.length === 3) {
if (u.tokens.some((x) => x.name === body.name)) return [400, { message: 'access token name has been used already' }];
const tok = api.addToken(u.login, body.name, body.scopes);
return [201, { id: tok.id, name: tok.name, sha1: tok.sha1, scopes: tok.scopes }];
}
if (method === 'DELETE' && p.length === 4) {
const i = u.tokens.findIndex((x) => String(x.id) === p[3] || x.name === p[3]);
if (i < 0) return [404, { message: 'token not found' }];
u.tokens.splice(i, 1);
return [204, null];
}
}
if (p[0] === 'repos' && p.length === 3 && method === 'GET') {
const r = repos.find((x) => lower(x.owner) === lower(p[1]) && lower(x.name) === lower(p[2]));
return r ? [200, repoJson(r)] : [404, { message: 'not found' }];
}
if (p[0] === 'repos' && p.length === 4 && p[3] === 'forks' && method === 'POST') {
const up = repos.find((x) => lower(x.owner) === lower(p[1]) && lower(x.name) === lower(p[2]));
if (!up) return [404, { message: 'not found' }];
if (repos.some((x) => lower(x.owner) === lower(me.login) && lower(x.name) === lower(up.name))) return [409, { message: 'exists' }];
return [202, repoJson(api.addRepo(me.login, up.name, { parent: `${up.owner}/${up.name}` }))];
}
return [404, { message: 'no route' }];
}
const server = createServer({ key: tls.key, cert: tls.cert }, (req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', async () => {
const u = new URL(req.url, 'https://127.0.0.1');
const auth = authOf(req);
let body = null;
try {
body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : null;
} catch {}
const entry = { method: req.method, path: u.pathname + u.search, auth: auth.kind, as: auth.who?.user.login ?? null };
requests.push(entry);
const rule = rules.find((r) => r.match(entry, body));
// An answer function may be async, to hold a call open until the test lets it go.
const answer = rule ? (typeof rule.answer === 'function' ? await rule.answer(entry, body) : rule.answer) : null;
if (answer?.hang) return;
if (answer?.drop) return req.socket.destroy();
const [status, json, total] = answer && !answer.pass ? [answer.status, answer.json ?? null, answer.total] : route(req.method, u.pathname, u.searchParams, auth, body ?? {});
const headers = { 'content-type': 'application/json' };
if (total !== undefined && total !== null) headers['x-total-count'] = String(total);
res.writeHead(status, headers);
res.end(json === null || status === 204 ? undefined : JSON.stringify(json));
});
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
t.after(() => new Promise((r) => {
server.closeAllConnections?.();
server.close(() => r());
}));
api.url = `https://127.0.0.1:${server.address().port}`;
return api;
}
+396
View File
@@ -0,0 +1,396 @@
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
CREATE TABLE events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN (
'session.launched',
'session.ended',
'action.allowed',
'action.refused',
'task.created',
'task.assigned',
'task.state',
'task.closed',
'task.changed.external',
'task.conflict',
'task.missing',
'review.requested',
'review.verdict',
'human.input',
'config.refused',
'credential.expiring',
'credential.expired',
'credential.changed',
'launch.revoked',
'launch.restored',
'digest.sent')),
actor_role TEXT, actor_run TEXT,
subject TEXT,
corrects TEXT REFERENCES events(id),
body TEXT NOT NULL CHECK (json_valid(body))
) STRICT;
CREATE TABLE role_claims (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL, role TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
holder_run TEXT NOT NULL,
harness TEXT NOT NULL, address TEXT,
by TEXT NOT NULL, reason TEXT,
decision TEXT
) STRICT;
CREATE TABLE decisions (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL, project TEXT,
raised_by_role TEXT NOT NULL, raised_by_run TEXT NOT NULL,
class TEXT NOT NULL CHECK (class IN ('routine','within-role','cross-role','gated')),
action TEXT NOT NULL,
route_to TEXT NOT NULL,
question TEXT NOT NULL,
options TEXT NOT NULL CHECK (json_valid(options) AND json_array_length(options) BETWEEN 2 AND 9),
recommendation TEXT NOT NULL,
task_ref TEXT CHECK (task_ref IS NULL OR (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*')), requirement_ref TEXT,
blocking INTEGER NOT NULL CHECK (blocking IN (0,1)),
supersedes TEXT REFERENCES decisions(id)
, spec_path TEXT, spec_commit TEXT) STRICT;
CREATE TABLE decision_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
decision TEXT NOT NULL REFERENCES decisions(id),
at TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('seen','resolved','withdrawn','expired')),
by TEXT NOT NULL,
choice TEXT, note TEXT, via TEXT
) STRICT;
CREATE TABLE messages (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
from_role TEXT NOT NULL, from_run TEXT NOT NULL,
to_role TEXT NOT NULL,
class TEXT NOT NULL,
in_reply_to TEXT REFERENCES messages(id),
decision TEXT REFERENCES decisions(id),
corrects TEXT REFERENCES messages(id),
body TEXT NOT NULL
, to_name TEXT) STRICT;
CREATE TABLE deliveries (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
message TEXT NOT NULL REFERENCES messages(id),
at TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('routed','delivered','failed','read')),
holder_run TEXT, transport TEXT, address TEXT, detail TEXT
) STRICT;
CREATE TABLE task_snapshots (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
task_ref TEXT NOT NULL CHECK (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*'),
updated TEXT NOT NULL,
etag TEXT,
digest TEXT NOT NULL CHECK (length(digest) = 64 AND NOT digest GLOB '*[^0-9a-f]*'),
fields TEXT NOT NULL CHECK (json_valid(fields)),
source TEXT NOT NULL CHECK (source IN ('self','poll')),
via TEXT CHECK (via IN ('board','cursor','task','reconcile')),
read_at TEXT,
role TEXT, run TEXT,
CHECK ((source = 'self') = (role IS NOT NULL AND run IS NOT NULL)),
CHECK ((source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)),
CHECK (json_type(fields, '$.bucket') IS 'integer'
OR (source IS 'poll' AND via IS 'task' AND json_type(fields, '$.gone') IS 'text'))
) STRICT;
CREATE INDEX task_snapshots_ref ON task_snapshots (business, task_ref, seq);
CREATE TRIGGER decisions_resolve_once BEFORE INSERT ON decision_events
WHEN NEW.op IN ('resolved','withdrawn','expired') AND EXISTS (
SELECT 1 FROM decision_events WHERE decision = NEW.decision AND op IN ('resolved','withdrawn','expired'))
BEGIN SELECT RAISE(ABORT, 'decision already closed'); END;
CREATE TRIGGER decisions_resolved_choice BEFORE INSERT ON decision_events
WHEN NEW.op = 'resolved' AND (NEW.choice IS NULL OR NOT EXISTS (
SELECT 1 FROM decisions d, json_each(d.options) o WHERE d.id = NEW.decision AND json_extract(o.value,'$.key') = NEW.choice))
BEGIN SELECT RAISE(ABORT, 'resolution must name one of the options'); END;
CREATE TRIGGER role_one_holder BEFORE INSERT ON role_claims
WHEN NEW.op = 'claim' AND (SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) = 'claim'
BEGIN SELECT RAISE(ABORT, 'role already held'); END;
CREATE TRIGGER role_release_by_holder BEFORE INSERT ON role_claims
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1),'') <> 'claim'
BEGIN SELECT RAISE(ABORT, 'role is not held'); END;
CREATE TRIGGER role_release_same_run BEFORE INSERT ON role_claims
WHEN NEW.op = 'release' AND (SELECT holder_run FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
CREATE TRIGGER role_revoke_needs_decision BEFORE INSERT ON role_claims
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
CREATE TRIGGER meta_no_update BEFORE UPDATE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER meta_no_delete BEFORE DELETE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER events_no_update BEFORE UPDATE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER events_no_delete BEFORE DELETE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER role_claims_no_update BEFORE UPDATE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER role_claims_no_delete BEFORE DELETE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER decisions_no_update BEFORE UPDATE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decisions_no_delete BEFORE DELETE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decision_events_no_update BEFORE UPDATE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER decision_events_no_delete BEFORE DELETE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER messages_no_update BEFORE UPDATE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER messages_no_delete BEFORE DELETE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER deliveries_no_update BEFORE UPDATE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER deliveries_no_delete BEFORE DELETE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER meta_no_replace BEFORE INSERT ON meta WHEN EXISTS (SELECT 1 FROM meta WHERE key = NEW.key) BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER events_no_replace BEFORE INSERT ON events WHEN EXISTS (SELECT 1 FROM events WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER role_claims_no_replace BEFORE INSERT ON role_claims WHEN EXISTS (SELECT 1 FROM role_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER decisions_no_replace BEFORE INSERT ON decisions WHEN EXISTS (SELECT 1 FROM decisions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decision_events_no_replace BEFORE INSERT ON decision_events WHEN EXISTS (SELECT 1 FROM decision_events WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER messages_no_replace BEFORE INSERT ON messages WHEN EXISTS (SELECT 1 FROM messages WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER deliveries_no_replace BEFORE INSERT ON deliveries WHEN EXISTS (SELECT 1 FROM deliveries WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER task_snapshots_no_update BEFORE UPDATE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER task_snapshots_no_delete BEFORE DELETE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER task_snapshots_no_replace BEFORE INSERT ON task_snapshots WHEN EXISTS (SELECT 1 FROM task_snapshots WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER decisions_blocking_needs_task BEFORE INSERT ON decisions
WHEN NEW.blocking = 1 AND NEW.task_ref IS NULL
BEGIN SELECT RAISE(ABORT, 'a blocking decision cites the task it blocks'); END;
CREATE TRIGGER events_launch_by_human BEFORE INSERT ON events
WHEN NEW.kind IN ('launch.revoked','launch.restored') AND (NEW.actor_role IS NOT NULL OR NEW.actor_run IS NOT NULL)
BEGIN SELECT RAISE(ABORT, 'only the human revokes or restores launching'); END;
CREATE TRIGGER events_credential_body BEFORE INSERT ON events
WHEN NEW.kind GLOB 'credential.*' AND (
json_extract(NEW.body, '$.service') IS NULL OR json_extract(NEW.body, '$.service') NOT IN ('gitea','vikunja')
OR json_extract(NEW.body, '$.instance') IS NULL)
BEGIN SELECT RAISE(ABORT, 'credential events name a service and a role instance'); END;
CREATE TRIGGER events_task_missing_body BEFORE INSERT ON events
WHEN NEW.kind = 'task.missing' AND (
json_extract(NEW.body, '$.reason') IS NULL OR json_extract(NEW.body, '$.reason') NOT IN ('moved','not-found','no-access')
OR (json_extract(NEW.body, '$.reason') = 'moved' AND json_type(NEW.body, '$.project') IS NOT 'integer'))
BEGIN SELECT RAISE(ABORT, 'task.missing carries a reason, and the new project when moved'); END;
CREATE TRIGGER events_task_subject BEFORE INSERT ON events
WHEN NEW.kind GLOB 'task.*' AND (NEW.subject IS NULL OR NOT (NEW.subject GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(NEW.subject, 9) GLOB '*[^0-9/]*' AND NOT substr(NEW.subject, 9) GLOB '*/*/*'))
BEGIN SELECT RAISE(ABORT, 'a task event names its task in subject'); END;
CREATE TRIGGER events_task_created_body BEFORE INSERT ON events
WHEN NEW.kind = 'task.created' AND (
json_type(NEW.body, '$.request') IS NOT 'text'
OR NOT EXISTS (SELECT 1 FROM events WHERE id = json_extract(NEW.body, '$.request')
AND kind = 'human.input' AND business = NEW.business)
OR json_type(NEW.body, '$.requirement') IS NOT 'text'
OR NOT json_extract(NEW.body, '$.requirement') GLOB 'REQ-[A-Z]*-[1-9]*'
OR json_extract(NEW.body, '$.requirement') GLOB 'REQ-*[^A-Z0-9-]*')
BEGIN SELECT RAISE(ABORT, 'task.created cites the human.input that asked for it and a requirement id'); END;
CREATE VIEW launch_state AS
SELECT business, CASE kind WHEN 'launch.revoked' THEN 'revoked' ELSE 'allowed' END AS state, at
FROM events e WHERE kind IN ('launch.revoked','launch.restored')
AND seq = (SELECT max(seq) FROM events WHERE business = e.business AND kind IN ('launch.revoked','launch.restored'));
CREATE VIEW task_external_changes AS
SELECT p.business, p.task_ref, p.seq, p.via, p.updated, p.digest, ls.digest AS self_digest
FROM task_snapshots p
LEFT JOIN task_snapshots ls ON ls.seq = (SELECT max(seq) FROM task_snapshots
WHERE business = p.business AND task_ref = p.task_ref AND source = 'self')
WHERE p.source = 'poll'
AND p.seq = (SELECT max(seq) FROM task_snapshots WHERE business = p.business AND task_ref = p.task_ref)
AND (ls.seq IS NULL OR (p.updated >= ls.updated AND p.read_at > ls.at AND p.digest <> ls.digest));
CREATE VIEW task_current AS
SELECT s.seq, s.at, s.business, s.task_ref, s.updated, s.etag, s.digest, s.fields, s.source, s.via, s.read_at, s.role, s.run
FROM task_snapshots s
WHERE s.seq = (SELECT max(c.seq) FROM task_snapshots c
WHERE c.business = s.business AND c.task_ref = s.task_ref
AND (c.source = 'self'
OR c.read_at > coalesce((SELECT ls.at FROM task_snapshots ls
WHERE ls.business = c.business AND ls.task_ref = c.task_ref AND ls.source = 'self'
ORDER BY ls.seq DESC LIMIT 1), '')));
CREATE VIEW tasks_open AS
SELECT s.business, s.task_ref, json_extract(s.fields, '$.bucket') AS bucket, s.seq
FROM task_current s
WHERE json_type(s.fields, '$.gone') IS NULL
AND json_extract(s.fields, '$.done') = 0;
CREATE VIEW urgent_inbox AS
SELECT d.id, d.business, d.task_ref, d.question, d.at
FROM decisions d
WHERE d.class = 'gated' AND d.blocking = 1
AND NOT EXISTS (SELECT 1 FROM decision_events x WHERE x.decision = d.id AND x.op IN ('resolved','withdrawn','expired'));
CREATE TABLE task_comment_cursors (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
business TEXT NOT NULL,
task_id INTEGER NOT NULL CHECK (task_id > 0),
last_comment INTEGER NOT NULL CHECK (last_comment > 0)
) STRICT;
CREATE INDEX task_comment_cursors_latest ON task_comment_cursors (business, task_id, seq);
CREATE TRIGGER task_comment_cursors_no_update BEFORE UPDATE ON task_comment_cursors
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_no_delete BEFORE DELETE ON task_comment_cursors
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_no_replace BEFORE INSERT ON task_comment_cursors
WHEN EXISTS (SELECT 1 FROM task_comment_cursors WHERE seq = NEW.seq)
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_forward BEFORE INSERT ON task_comment_cursors
WHEN NEW.last_comment <= COALESCE((SELECT last_comment FROM task_comment_cursors
WHERE business = NEW.business AND task_id = NEW.task_id ORDER BY seq DESC LIMIT 1), 0)
BEGIN SELECT RAISE(ABORT, 'comment cursor must advance'); END;
CREATE TABLE projects (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
name TEXT NOT NULL,
repo TEXT,
created_by TEXT NOT NULL,
UNIQUE (business, name)
) STRICT;
CREATE TABLE threads (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
project TEXT NOT NULL REFERENCES projects(id),
instance TEXT NOT NULL,
owner TEXT NOT NULL,
name TEXT NOT NULL,
UNIQUE (project, name)
) STRICT;
CREATE TABLE thread_claims (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
holder_run TEXT NOT NULL,
harness TEXT NOT NULL, address TEXT,
by TEXT NOT NULL, reason TEXT,
decision TEXT
) STRICT;
CREATE TABLE message_threads (
message TEXT PRIMARY KEY REFERENCES messages(id),
thread TEXT NOT NULL REFERENCES threads(id)
) STRICT;
CREATE TABLE thread_opens (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
by TEXT NOT NULL
) STRICT;
CREATE INDEX thread_claims_thread ON thread_claims (thread, seq);
CREATE INDEX message_threads_thread ON message_threads (thread);
CREATE TRIGGER threads_project_business BEFORE INSERT ON threads
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a thread belongs to its project''s business'); END;
CREATE TRIGGER thread_one_holder BEFORE INSERT ON thread_claims
WHEN NEW.op = 'claim' AND (SELECT op FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1) = 'claim'
BEGIN SELECT RAISE(ABORT, 'thread already held'); END;
CREATE TRIGGER thread_one_per_run BEFORE INSERT ON thread_claims
WHEN NEW.op = 'claim' AND EXISTS (SELECT 1 FROM thread_claims c WHERE c.holder_run = NEW.holder_run AND c.business = NEW.business
AND c.seq = (SELECT max(seq) FROM thread_claims WHERE thread = c.thread) AND c.op = 'claim')
BEGIN SELECT RAISE(ABORT, 'run already holds a thread'); END;
CREATE TRIGGER thread_release_by_holder BEFORE INSERT ON thread_claims
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1),'') <> 'claim'
BEGIN SELECT RAISE(ABORT, 'thread is not held'); END;
CREATE TRIGGER thread_release_same_run BEFORE INSERT ON thread_claims
WHEN NEW.op = 'release' AND (SELECT holder_run FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
CREATE TRIGGER thread_revoke_needs_decision BEFORE INSERT ON thread_claims
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
CREATE TRIGGER thread_claims_business BEFORE INSERT ON thread_claims
WHEN (SELECT business FROM threads WHERE id = NEW.thread) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a thread claim names the thread''s business'); END;
CREATE TRIGGER message_threads_match BEFORE INSERT ON message_threads
WHEN NOT EXISTS (SELECT 1 FROM messages m JOIN threads t ON t.id = NEW.thread
WHERE m.id = NEW.message AND m.business = t.business AND t.instance IN (m.to_role, m.from_role))
BEGIN SELECT RAISE(ABORT, 'a threaded message is to or from the thread''s instance'); END;
CREATE TRIGGER delivery_same_thread BEFORE INSERT ON deliveries
WHEN NEW.op IN ('routed','delivered') AND (NEW.op = 'delivered' OR NEW.holder_run IS NOT NULL)
AND EXISTS (SELECT 1 FROM message_threads mt JOIN messages m ON m.id = mt.message JOIN threads t ON t.id = mt.thread
WHERE mt.message = NEW.message AND m.to_role = t.instance
AND NOT EXISTS (SELECT 1 FROM thread_claims c WHERE c.seq = (SELECT max(seq) FROM thread_claims WHERE thread = t.id)
AND c.op = 'claim' AND c.holder_run IS NEW.holder_run))
BEGIN SELECT RAISE(ABORT, 'a threaded message goes only to its thread''s holder'); END;
CREATE TRIGGER projects_no_update BEFORE UPDATE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER projects_no_delete BEFORE DELETE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER projects_no_replace BEFORE INSERT ON projects WHEN EXISTS (SELECT 1 FROM projects WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER threads_no_update BEFORE UPDATE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER threads_no_delete BEFORE DELETE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER threads_no_replace BEFORE INSERT ON threads WHEN EXISTS (SELECT 1 FROM threads WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER thread_claims_no_update BEFORE UPDATE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER thread_claims_no_delete BEFORE DELETE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER thread_claims_no_replace BEFORE INSERT ON thread_claims WHEN EXISTS (SELECT 1 FROM thread_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER message_threads_no_update BEFORE UPDATE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER message_threads_no_delete BEFORE DELETE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER message_threads_no_replace BEFORE INSERT ON message_threads WHEN EXISTS (SELECT 1 FROM message_threads WHERE message = NEW.message) BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER thread_opens_no_update BEFORE UPDATE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TRIGGER thread_opens_no_delete BEFORE DELETE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TRIGGER thread_opens_no_replace BEFORE INSERT ON thread_opens WHEN EXISTS (SELECT 1 FROM thread_opens WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TABLE instance_names (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
instance TEXT NOT NULL,
name TEXT NOT NULL,
by TEXT NOT NULL
) STRICT;
CREATE TRIGGER instance_names_unique BEFORE INSERT ON instance_names
WHEN EXISTS (SELECT 1 FROM instance_names n WHERE n.business = NEW.business AND n.name = NEW.name
AND n.instance <> NEW.instance
AND n.seq = (SELECT max(seq) FROM instance_names WHERE business = n.business AND instance = n.instance))
BEGIN SELECT RAISE(ABORT, 'an instance name is unique in its business'); END;
CREATE TRIGGER instance_names_no_update BEFORE UPDATE ON instance_names BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
CREATE TRIGGER instance_names_no_delete BEFORE DELETE ON instance_names BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
CREATE TRIGGER instance_names_no_replace BEFORE INSERT ON instance_names WHEN EXISTS (SELECT 1 FROM instance_names WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
-- Row 124 (#1618): immutable source snapshots and turn outcomes, never transcript text.
CREATE TABLE usage_records (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
run TEXT NOT NULL,
instance TEXT NOT NULL,
provider TEXT,
model TEXT,
account TEXT,
turn TEXT NOT NULL,
source TEXT NOT NULL,
phase TEXT NOT NULL CHECK (phase IN ('begin','end')),
status TEXT NOT NULL CHECK (status IN ('known','unknown')),
code TEXT,
cursor TEXT CHECK (cursor IS NULL OR json_valid(cursor)),
input INTEGER,
output INTEGER,
cache_write INTEGER,
cache_read INTEGER,
UNIQUE (business,run,turn,source,phase),
CHECK ((status='unknown' AND input IS NULL AND output IS NULL AND cache_write IS NULL AND cache_read IS NULL)
OR (status='known' AND input >= 0 AND output >= 0 AND cache_write >= 0 AND cache_read >= 0
AND input IS NOT NULL AND output IS NOT NULL AND cache_write IS NOT NULL AND cache_read IS NOT NULL))
) STRICT;
CREATE TRIGGER usage_records_no_update BEFORE UPDATE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
CREATE TRIGGER usage_records_no_delete BEFORE DELETE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
CREATE TRIGGER usage_records_no_replace BEFORE INSERT ON usage_records WHEN EXISTS (SELECT 1 FROM usage_records WHERE seq=NEW.seq OR (business=NEW.business AND run=NEW.run AND turn=NEW.turn AND source=NEW.source AND phase=NEW.phase)) BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
CREATE TABLE hosts (
business TEXT NOT NULL,
host TEXT NOT NULL,
address TEXT NOT NULL,
port INTEGER NOT NULL CHECK (port BETWEEN 1 AND 65535),
user TEXT NOT NULL,
sudo INTEGER NOT NULL CHECK (sudo IN (0,1)),
host_keys TEXT NOT NULL CHECK (json_valid(host_keys) AND json_type(host_keys) = 'array' AND json_array_length(host_keys) > 0),
installer_fingerprint TEXT,
state TEXT NOT NULL CHECK (state IN ('pending-install','ready')),
decision TEXT NOT NULL REFERENCES decisions(id),
checked_at TEXT,
PRIMARY KEY (business, host),
CHECK (state = 'pending-install' OR (installer_fingerprint IS NOT NULL AND checked_at IS NOT NULL))
) STRICT;
CREATE TABLE host_grants (
business TEXT NOT NULL,
agent TEXT NOT NULL,
host TEXT NOT NULL,
commands TEXT NOT NULL CHECK (json_valid(commands) AND json_type(commands) = 'array' AND json_array_length(commands) > 0),
rotate_by TEXT NOT NULL,
tag TEXT NOT NULL CHECK (length(tag) = 39 AND tag GLOB 'mosaic-*' AND NOT substr(tag, 8) GLOB '*[^0-9a-f]*'),
decision TEXT NOT NULL REFERENCES decisions(id),
PRIMARY KEY (business, agent, host),
FOREIGN KEY (business, host) REFERENCES hosts(business, host)
) STRICT;
CREATE TRIGGER events_grant_actor BEFORE INSERT ON events
WHEN NEW.kind = 'action.allowed'
AND (json_extract(NEW.body, '$.action') GLOB 'grant.*' OR json_extract(NEW.body, '$.operation') GLOB 'grant.*')
AND NOT (NEW.actor_role IS NULL AND NEW.actor_run IS 'grant')
BEGIN SELECT RAISE(ABORT, 'only the grant writer records grant events'); END;
@@ -0,0 +1,200 @@
// Row 236 (#1742, #436b r5 section 9, X-G3): the grant path end to end on a scratch gitea/gitea:1.27.1, as
// section 1's facts were found: a throwaway container on 127.0.0.1 over TLS with row 199's scratch CA, its
// data inside the container, removed after. The test never pulls an image; without Docker or the image it
// skips. The admin and seat tokens are scratch tokens this container made; none is printed, and no test
// here reaches git.mosaicstack.dev or any provider.
import test from 'node:test';
import assert from 'node:assert/strict';
import { randomBytes } from 'node:crypto';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
import { request } from 'node:https';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { Broker, GRANT_EVIDENCE } from '../src/broker.mjs';
import { Credentials } from '../src/credentials.mjs';
import { SEAT_SCOPES, giteaGrant, giteaKeys } from '../src/gitea-grant.mjs';
import { Store } from '../src/store.mjs';
import { scratchCa } from '../../seat/tests/fake-gitea.mjs';
const IMAGE = 'gitea/gitea:1.27.1';
const USER = 'demo-coder';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const dayFrom = (n) => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10);
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
const docker = (...args) => spawnSync('docker', args, { encoding: 'utf8', timeout: 120000 });
const skip = docker('image', 'inspect', IMAGE).status === 0 ? false : `no Docker or no local ${IMAGE}; this test never pulls`;
const freePort = () =>
new Promise((resolve, reject) => {
const s = createServer();
s.once('error', reject);
s.listen(0, '127.0.0.1', () => {
const { port } = s.address();
s.close(() => resolve(port));
});
});
// One HTTPS call to the scratch container, trusting only the scratch CA.
function call(url, ca, method, path, { auth = null, body = null } = {}) {
return new Promise((resolve) => {
const data = body ? JSON.stringify(body) : null;
const headers = { accept: 'application/json', ...(data ? { 'content-type': 'application/json' } : {}), ...(auth ? { authorization: auth } : {}) };
const req = request(url + path, { method, ca, headers, timeout: 10000 }, (res) => {
let text = '';
res.on('data', (c) => (text += c));
res.on('end', () => {
let json = null;
try {
json = text ? JSON.parse(text) : null;
} catch {}
resolve({ status: res.statusCode, json });
});
});
req.on('timeout', () => req.destroy());
req.on('error', () => resolve({ status: 0, json: null }));
req.end(data ?? undefined);
});
}
test('X-G3: on scratch 1.27.1, a grant makes the user, mints, forks, checks reach, writes the file, and revoke deletes the token', { skip, timeout: 300000 }, async (t) => {
const dir = mkdtempSync(join(tmpdir(), 'g236-docker-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const tlsDir = join(dir, 'tls');
const tls = scratchCa(tlsDir);
// The container's git user reads the scratch key; it is a throwaway key for this run only.
chmodSync(tlsDir, 0o755);
chmodSync(join(tlsDir, 'server.key'), 0o644);
const ca = readFileSync(tls.ca, 'utf8');
const port = await freePort();
const url = `https://127.0.0.1:${port}`;
const name = 'g236-' + randomBytes(6).toString('hex');
t.after(() => docker('rm', '-f', name));
const started = docker('run', '-d', '--rm', '--name', name, '-p', `127.0.0.1:${port}:3000`, '-v', `${tlsDir}:/certs:ro`,
'-e', 'GITEA__security__INSTALL_LOCK=true',
'-e', 'GITEA__database__DB_TYPE=sqlite3',
'-e', 'GITEA__service__DISABLE_REGISTRATION=true',
'-e', 'GITEA__server__PROTOCOL=https',
'-e', 'GITEA__server__CERT_FILE=/certs/server.pem',
'-e', 'GITEA__server__KEY_FILE=/certs/server.key',
'-e', `GITEA__server__ROOT_URL=${url}/`,
IMAGE);
assert.equal(started.status, 0, started.stderr);
const end = Date.now() + 120000;
while ((await call(url, ca, 'GET', '/api/v1/version')).status !== 200) {
if (Date.now() > end) assert.fail('the scratch Gitea did not answer in 120 s: ' + docker('logs', '--tail', '20', name).stdout);
await sleep(500);
}
const gitea = (...args) => docker('exec', '-u', 'git', name, 'gitea', ...args);
const password = () => randomBytes(18).toString('base64url');
for (const [user, admin] of [['mosaic-grant', true], ['acme', false]]) {
const r = gitea('admin', 'user', 'create', '--username', user, '--email', `${user}@example.test`, '--password', password(), '--must-change-password=false', ...(admin ? ['--admin'] : []));
assert.equal(r.status, 0, r.stderr);
}
const minted = gitea('admin', 'user', 'generate-access-token', '--username', 'mosaic-grant', '--token-name', 'mosaic-grant-bootstrap',
'--scopes', 'write:admin,write:repository,write:user', '--raw');
assert.equal(minted.status, 0, 'generate-access-token failed');
const adminToken = minted.stdout.trim();
assert.match(adminToken, /^[0-9a-f]{40}$/, 'the admin token has the shape of a Gitea token');
const basic = 'Basic ' + Buffer.from(`mosaic-grant:${adminToken}`).toString('base64');
for (const repo of ['app', 'lib']) {
const r = await call(url, ca, 'POST', '/api/v1/admin/users/acme/repos', { auth: 'token ' + adminToken, body: { name: repo, auto_init: true } });
assert.equal(r.status, 201);
}
const configDir = join(dir, 'cfg');
mkdirSync(configDir, { mode: 0o700 });
mkdirSync(join(dir, 'data'), { mode: 0o700 });
const storeRoot = join(configDir, 'secrets', 'agents');
const store = new Store(join(dir, 'data'));
const credentials = new Credentials({ references: {}, storeRoot, dataRoot: join(dir, 'data') });
t.after(() => {
credentials.close();
store.close();
});
const business = {
id: 'demo',
human: 'jason',
arbiters: { technical: 'pm', delivery: 'pm' },
roles: {
chief: { definition: 'chief-of-staff', ...none },
pm: { definition: 'pm', ...none },
coder: { definition: 'coder', ...none, identity: { name: 'Coder', email: '[email protected]', gitea: { url, user: USER } }, publish: ['app', 'lib'] },
},
definitions: {
'chief-of-staff': { roleVersion: 2, credentials: [] },
pm: { roleVersion: 2, credentials: [] },
coder: { roleVersion: 2, credentials: [{ service: 'gitea' }] },
},
projects: { app: { remote: `${url}/acme/app` }, lib: { remote: `${url}/acme/lib` } },
vars: {},
};
const broker = new Broker({
store,
businesses: { demo: business },
secretCheck: (value) => credentials.assertClean(value),
giteaGrant: giteaGrant({ configDir, ca, timeoutMs: 20000 }),
giteaKeys: giteaKeys({ credentials, storeRoot }),
});
const chief = broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi', address: 'chief' });
broker.request(chief, { verb: 'role.claim' });
const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
// C1 to C4 on the real server, then the slot is written.
await broker.request(human, { verb: 'grants.admin.set', args: { service: 'gitea', url, account: 'mosaic-grant', rotateBy: dayFrom(60), token: adminToken + '\n' } });
const d = await broker.request(chief, {
verb: 'decision.raise',
args: {
action: 'credential.mint', question: 'Grant coder Gitea?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false,
grant: { method: 'gitea', agent: 'coder', target: { projects: ['app'] }, rotateBy: dayFrom(30) },
},
});
broker.request(human, { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } });
const events = () => store.all(`SELECT body FROM events WHERE business='demo' AND ${GRANT_EVIDENCE} ORDER BY seq`).map((e) => JSON.parse(e.body));
const ended = () => events().find((e) => e.grantDecision === d.id && ['grant.applied', 'grant.failed'].includes(e.action));
const until = Date.now() + 120000;
while (!ended() && Date.now() < until) await sleep(100);
await broker.giteaIdle();
const applied = ended();
assert.equal(applied?.action, 'grant.applied', JSON.stringify(applied));
// The user, made with the nonce, and its id recorded.
const nonce = events().find((e) => e.grantDecision === d.id && e.step === 'user').nonce;
const u = await call(url, ca, 'GET', `/api/v1/users/${USER}`, { auth: 'token ' + adminToken });
assert.equal(u.status, 200);
assert.equal(u.json.full_name, `Coder [mosaic ${nonce}]`);
assert.equal(u.json.is_admin, false);
const row = store.get("SELECT id, nonce FROM gitea_users WHERE business='demo' AND agent='coder'");
assert.deepEqual({ ...row }, { id: u.json.id, nonce });
// One token, with the two scopes, at the id the record names.
const list = await call(url, ca, 'GET', `/api/v1/users/${USER}/tokens`, { auth: basic });
assert.equal(list.status, 200);
assert.equal(list.json.length, 1);
assert.deepEqual([...list.json[0].scopes].sort(), [...SEAT_SCOPES]);
const grant = store.get("SELECT token_id FROM gitea_grants WHERE business='demo' AND agent='coder'");
assert.equal(list.json[0].id, grant.token_id);
// The fork, with its parent.
const fork = await call(url, ca, 'GET', `/api/v1/repos/${USER}/app`, { auth: 'token ' + adminToken });
assert.equal(fork.status, 200);
assert.equal(fork.json.parent?.full_name, 'acme/app');
// The 0600 file, whose token Gitea knows as the recorded id; the value is compared, never printed.
const file = join(storeRoot, 'demo', 'coder', 'gitea');
assert.equal(statSync(file).mode & 0o777, 0o600);
const who = await call(url, ca, 'GET', '/api/v1/token', { auth: 'token ' + readFileSync(file, 'utf8').trim() });
assert.equal(who.status, 200);
assert.equal(who.json.id, grant.token_id);
// Revoke deletes the token at Gitea: gone from the list, the file and the record gone.
const out = await broker.request(human, { verb: 'grants.revoke', args: { service: 'gitea', agent: 'coder', reason: 'X-G3' } });
assert.deepEqual(out.serviceLeft, []);
await broker.giteaIdle();
const after = await call(url, ca, 'GET', `/api/v1/users/${USER}/tokens`, { auth: basic });
assert.equal(after.status, 200);
assert.deepEqual(after.json, []);
assert.equal(existsSync(file), false);
assert.equal(store.get("SELECT count(*) AS n FROM gitea_grants WHERE business='demo'").n, 0);
// No scratch token reached the store.
const text = store.all("SELECT body FROM events WHERE business='demo'").map((r) => r.body).join('\n');
assert.ok(!text.includes(adminToken), 'the admin token reached the store');
});
File diff suppressed because it is too large Load Diff
+159
View File
@@ -0,0 +1,159 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { createHash } from 'node:crypto';
import { DatabaseSync } from 'node:sqlite';
import { Store, schemaDigest } from '../src/store.mjs';
// Row 236 (#1742, #436b r5 section 7): the 3j→3k step adds the gitea_users and gitea_grants tables and
// nothing else. It runs on a 3j store built here from the pinned 3j schema (02da6a2ae's schema.sql) and
// fixture rows. No live store is read or copied.
const AT = '2026-10-21T00:00:00.000Z';
const ddl3j = readFileSync(new URL('./fixtures/schema-3j.sql', import.meta.url), 'utf8');
const here = (p) => new URL(p, import.meta.url);
const NEW = ['gitea_users', 'gitea_grants', 'sqlite_autoindex_gitea_users_1', 'sqlite_autoindex_gitea_grants_1'];
function source3j(t) {
const root = mkdtempSync(join(tmpdir(), 'gitea-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(join(root, 'bus'), { mode: 0o700 });
const path = join(root, 'bus/bus.sqlite'), db = new DatabaseSync(path);
assert.equal(createHash('sha256').update(ddl3j).digest('hex'), '0a7b633ce7eec9d0bb37c3255f4244d97bd0c8eac38db91a11b4ef6cc982ec8c');
db.exec(ddl3j);
const run = (sql, ...args) => db.prepare(sql).run(...args);
run('INSERT INTO meta VALUES (?,?)', 'schema_version', '3j');
run('INSERT INTO meta VALUES (?,?)', 'schema_digest', schemaDigest(db));
run('INSERT INTO meta VALUES (?,?)', 'comment_cursor_epoch', AT);
run(`INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking)
VALUES('d1',?,'demo','chief','run1','gated','credential.mint','human','q','["yes","no"]','yes',0)`, AT);
run('INSERT INTO events(id,at,business,kind,actor_role,actor_run,body) VALUES(?,?,?,?,?,?,?)', 'e1', AT, 'demo', 'action.allowed', null, 'grant', '{"operation":"grant.host-enroll","decision":"d1"}');
run('INSERT INTO events(id,at,business,kind,body) VALUES(?,?,?,?,?)', 'e2', AT, 'demo', 'action.allowed', '{}');
db.close(); chmodSync(path, 0o600);
return { root, path };
}
function rows(path) {
const db = new DatabaseSync(path, { readOnly: true });
try {
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants')").all()
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
} finally { db.close(); }
}
function master(path) {
const db = new DatabaseSync(path, { readOnly: true });
try {
return db.prepare('SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name').all().map(r => ({ ...r }));
} finally { db.close(); }
}
const version = (path) => {
const db = new DatabaseSync(path, { readOnly: true });
try { return db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value; } finally { db.close(); }
};
// A copy of the bus module whose store.mjs, schema.sql and 3j→3k step the caller edits.
async function engine(root, name, { store, schema, step = (s) => s }) {
const moduleRoot = join(root, name);
mkdirSync(join(moduleRoot, 'src/migrations'), { recursive: true });
const source = readFileSync(here('../src/store.mjs'), 'utf8'), edited = store ? store(source) : source;
if (store) assert.notEqual(edited, source);
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
writeFileSync(join(moduleRoot, 'schema.sql'), schema ?? readFileSync(here('../schema.sql'), 'utf8'));
for (const file of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql'])
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
writeFileSync(join(moduleRoot, 'src/migrations/3j-3k.sql'), step(readFileSync(here('../src/migrations/3j-3k.sql'), 'utf8')));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
function fresh(t) {
const root = mkdtempSync(join(tmpdir(), 'gitea-store-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
store.run(`INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking)
VALUES('d1',?,'demo','chief','run1','gated','credential.mint','human','q','["yes","no"]','yes',0)`, AT);
return store;
}
const user = (store, values = {}) => store.run(
'INSERT INTO gitea_users(business,agent,user,id,nonce,decision) VALUES(:business,:agent,:user,:id,:nonce,:decision)',
{ business: 'demo', agent: 'coder', user: 'demo-coder', id: 7, nonce: '0123456789abcdef', decision: 'd1', ...values });
const grant = (store, values = {}) => store.run(
'INSERT INTO gitea_grants(business,agent,user,token_id,token_name,projects,rotate_by,decision) VALUES(:business,:agent,:user,:token,:name,:projects,:rotate,:decision)',
{ business: 'demo', agent: 'coder', user: 'demo-coder', token: 9, name: 'mosaic-demo-coder-gitea-d1', projects: '["app"]', rotate: '2026-12-01', decision: 'd1', ...values });
test('3j copy migrates to 3k: every row kept, and sqlite_master gains only the two tables and their keys', t => {
const { root, path } = source3j(t), before = rows(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3k');
assert.deepEqual(rows(path), before);
const now = master(path);
assert.deepEqual(now.filter(r => !NEW.includes(r.name)), was);
assert.deepEqual(now.filter(r => NEW.includes(r.name)).map(r => r.name).sort(), [...NEW].sort());
const store = new Store(root);
try {
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
assert.equal(store.get('SELECT count(*) AS n FROM gitea_users').n, 0);
assert.equal(store.get('SELECT count(*) AS n FROM gitea_grants').n, 0);
user(store);
grant(store);
} finally { store.close(); }
});
test('a 3j→3k step that fails partway leaves the store at 3j with every row and the schema intact', async t => {
const { root, path } = source3j(t), before = rows(path), was = master(path);
// The real step plus a statement that fails only when events has a row, so the empty replay at
// import passes and the store's run fails after both tables exist.
const Failing = await engine(root, 'engine-failing', { step: (sql) => sql + 'INSERT INTO events SELECT * FROM events LIMIT 1;\n' });
assert.throws(() => new Failing(root, { migrateSchema: true }), /events is append-only/);
assert.equal(version(path), '3j');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3k');
});
test('the 3j engine refuses a 3k store, migrating or not, and leaves it at 3k', async t => {
const { root, path } = source3j(t);
new Store(root, { migrateSchema: true }).close();
const after = rows(path), was = master(path);
const Store3j = await engine(root, 'engine-3j', {
schema: ddl3j,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3j).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3k';", "export const SCHEMA_VERSION = '3j';")
.replace(/\}, \{\n {2}\/\/ Row 236 \(#1742[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3j/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3j';") && !pinned.includes('3j-3k'), 'the 3j engine has no 3j→3k step');
assert.throws(() => new Store3j(root), /schema-mismatch/);
assert.throws(() => new Store3j(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3k');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), after);
});
test('gitea_users: one user per business and agent, an id above 0, a 16-hex nonce and a real decision', t => {
const store = fresh(t);
user(store);
assert.throws(() => user(store, { user: 'other' }), /UNIQUE constraint failed: gitea_users\.business, gitea_users\.agent/);
user(store, { business: 'other' });
for (const nonce of ['0123456789abcde', '0123456789abcdef0', '0123456789ABCDEF', '0123456789abcdeg'])
assert.throws(() => user(store, { agent: 'x', nonce }), /CHECK constraint failed/, nonce);
assert.throws(() => user(store, { agent: 'x', id: 0 }), /CHECK constraint failed/);
assert.throws(() => user(store, { agent: 'x', id: 'seven' }), /cannot store TEXT value in INTEGER column/);
assert.throws(() => user(store, { agent: 'x', decision: 'nope' }), /FOREIGN KEY constraint failed/);
});
test('gitea_grants: one grant per agent, a projects array, a token id above 0, and only for a recorded user', t => {
const store = fresh(t);
assert.throws(() => grant(store), /FOREIGN KEY constraint failed/);
user(store);
for (const projects of ['[]', '{}', '"app"', 'not json'])
assert.throws(() => grant(store, { projects }), /CHECK constraint failed/, projects);
assert.throws(() => grant(store, { token: 0 }), /CHECK constraint failed/);
assert.throws(() => grant(store, { decision: 'nope' }), /FOREIGN KEY constraint failed/);
grant(store);
assert.throws(() => grant(store, { token: 10 }), /UNIQUE constraint failed/);
assert.throws(() => store.run("DELETE FROM gitea_users WHERE business='demo' AND agent='coder'"), /FOREIGN KEY constraint failed/);
store.run("DELETE FROM gitea_grants WHERE business='demo' AND agent='coder'");
store.run("DELETE FROM gitea_users WHERE business='demo' AND agent='coder'");
});
+41 -27
View File
@@ -6,9 +6,9 @@
// reaches another host.
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync, spawn } from 'node:child_process';
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { connect } from 'node:net';
import { createServer } from 'node:net';
import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir, userInfo } from 'node:os';
import { join } from 'node:path';
@@ -19,6 +19,8 @@ import { hostExecutor, runHost } from '../src/host-run.mjs';
import { Store } from '../src/store.mjs';
import { grantDecision, hostRecord } from './host-fixtures.mjs';
import { ephemeralRange, installSleepStub, pickPort, startScratchSshd } from './scratch-sshd.mjs';
const SSHD = '/usr/bin/sshd';
const USER = userInfo().username;
const AT = '2026-10-21T00:00:00.000Z';
@@ -35,12 +37,7 @@ const DEFINITIONS = {
pm: { roleVersion: 2, credentials: [] },
ops: { roleVersion: 2, credentials: [{ service: 'host' }] },
};
const listening = (port) =>
new Promise((resolve) => {
const s = connect(port, '127.0.0.1');
s.on('connect', () => (s.destroy(), resolve(true)));
s.on('error', () => resolve(false));
});
async function until(fn, ms = 10000) {
const end = Date.now() + ms;
while (!fn()) {
@@ -54,12 +51,13 @@ async function until(fn, ms = 10000) {
// Both also stop when the test dir is gone, since a held stub on the sshd side outlives its test (#1750).
// wrap.sh logs every request. The sudo stub logs its arguments to sudo.log and runs the command as the
// test's user.
async function sshd(t, dir) {
async function sshd(t, dir, options) {
if (!existsSync(SSHD)) throw new Error(`${SSHD} is missing: the executor tests need it and fail, never skip`);
keygen(join(dir, 'hostkey'));
keygen(join(dir, 'installer'));
writeFileSync(join(dir, 'authorized_keys'), readFileSync(join(dir, 'installer.pub')), { mode: 0o600 });
mkdirSync(join(dir, 'fake'));
installSleepStub(dir);
const stub = (name, body) => writeFileSync(join(dir, 'fake', name), `#!/bin/sh\nD='${dir}'\n${body}\n`, { mode: 0o755 });
stub('list', '[ -e "$D/fail-list" ] && exit 1\n[ -e "$D/granted" ] && grep " mosaic-[0-9a-f]*$" "$D/granted"\nexit 0');
stub(
@@ -76,26 +74,42 @@ async function sshd(t, dir) {
`#!/bin/sh\nprintf '%s\\n' "$SSH_ORIGINAL_COMMAND" >> '${dir}/requests.log'\nPATH=${dir}/fake:/usr/bin:/bin; export PATH\nexec /bin/sh -c "$SSH_ORIGINAL_COMMAND"\n`,
{ mode: 0o755 },
);
for (let attempt = 0; attempt < 5; attempt++) {
const port = 20000 + Math.floor(Math.random() * 20000);
writeFileSync(
join(dir, 'sshd_config'),
[`Port ${port}`, 'ListenAddress 127.0.0.1', `HostKey ${dir}/hostkey`, `AuthorizedKeysFile ${dir}/authorized_keys ${dir}/granted`,
`PidFile ${dir}/sshd.pid`, 'UsePAM no', 'StrictModes no', 'PasswordAuthentication no', 'KbdInteractiveAuthentication no',
`ForceCommand ${dir}/wrap.sh`, ''].join('\n'),
);
const daemon = spawn(SSHD, ['-D', '-f', join(dir, 'sshd_config'), '-E', join(dir, 'sshd.log')], { stdio: 'ignore' });
t.after(() => daemon.kill('SIGKILL'));
const deadline = Date.now() + 3000;
while (Date.now() < deadline && daemon.exitCode === null) {
if (await listening(port)) return port;
await sleep(30);
}
daemon.kill('SIGKILL');
}
throw new Error('scratch sshd did not start');
return startScratchSshd(t, dir, [
'ListenAddress 127.0.0.1', `HostKey ${dir}/hostkey`,
`AuthorizedKeysFile ${dir}/authorized_keys ${dir}/granted`, `PidFile ${dir}/sshd.pid`,
'UsePAM no', 'StrictModes no', 'PasswordAuthentication no', 'KbdInteractiveAuthentication no',
`ForceCommand ${dir}/wrap.sh`,
], options);
}
test('row 280: an occupied TCP port is attempted, refused, and retried with our sshd', async t => {
const dir = mkdtempSync(join(tmpdir(), 'sshd280-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const server = createServer(socket => socket.end());
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
t.after(() => new Promise(resolve => server.close(resolve)));
const occupied = server.address().port;
const port = await sshd(t, dir, { firstPort: occupied });
assert.match(readFileSync(join(dir, 'sshd-0.conf'), 'utf8'), new RegExp(`^Port ${occupied}\\n`));
assert.equal(readFileSync(join(dir, 'sshd-0.log'), 'utf8').includes(`Server listening on 127.0.0.1 port ${occupied}.`), false);
assert.notEqual(port, occupied);
assert.ok([1, 2, 3, 4].some(n => existsSync(join(dir, `sshd-${n}.log`)) &&
readFileSync(join(dir, `sshd-${n}.log`), 'utf8').includes(`Server listening on 127.0.0.1 port ${port}.`)));
});
test('row 280: every injected picker sample is unprivileged and outside the ephemeral range', () => {
const [lo, hi] = ephemeralRange();
for (const sample of [0, 0.25, 0.5, 0.6384, 0.75, 0.999999]) {
const port = pickPort(() => sample);
assert.ok(port >= 1024 && port <= 65535);
assert.ok(port < lo || port > hi, `sample ${sample}: ${port} inside ${lo}-${hi}`);
}
});
async function setup(t, opts = {}) {
const dir = mkdtempSync(join(tmpdir(), 'g235x-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
+11 -1
View File
@@ -86,7 +86,8 @@ test('G1, R4: the grant takes exactly method, agent, host, commands and rotateBy
for (const bad of [
noRotate,
{ ...g, extra: 1 },
{ ...g, method: 'gitea' },
// Row 236 (#1742) makes gitea a known method with its own path, so the unknown method is another name.
{ ...g, method: 'ftp' },
{ ...g, method: 'Host' },
{ ...g, agent: 'Ops' },
{ ...g, agent: '' },
@@ -101,6 +102,15 @@ test('G1, R4: the grant takes exactly method, agent, host, commands and rotateBy
assert.ok(rotateBy);
});
test('row 236 (#1742): a gitea grant on a broker with no Gitea path refuses gitea-not-configured, and nothing is stored', async (t) => {
const f = setup(t);
const before = f.decisions();
const refused = f.refusals().length;
await refusesWith(f.raise({ method: 'gitea' }), 'gitea-not-configured');
assert.equal(f.decisions(), before);
assert.deepEqual(f.refusals().slice(refused), ['gitea-not-configured']);
});
test('G2: an agent whose definition lacks host refuses host-not-declared, and an unknown agent unknown-role', async (t) => {
const f = setup(t);
const before = f.decisions();

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