Compare commits

...
Author SHA1 Message Date
jason.woltje 1261fa2d87 morgana: row 257 r2 (filbert r1 N1, N2): Object.hasOwn unknown-key refusal with inherited-name cases; critical memory edge on a 20 GiB limit (M8) 2026-10-21 17:25:20 -05:00
jason.woltje 9ffa507302 morgana: row 257 BUILD.md records sage-2202 rulings (PSI latch on first seen, PSI count across resources) 2026-10-21 17:10:40 -05:00
jason.woltje e98e37ca9f morgana: row 257 build (Q198 S1, #1762): @mosaic/supervisor pressure parsers and pure D4 rules evaluator, tests and mutant plan; tests wait for Sage's slot 2026-10-21 17:09:21 -05:00
jason.woltje cca206cdce docs: briefs for Q198 S1 and email rows E01, E03-E07 (post-v1; mos-1222, mos-1223)
E12 and E14 wait to be queued until their WAIT-row and S5 prerequisites exist.
2026-10-21 16:54:13 -05:00
jason.woltje f437973d79 docs: post-v1 email plan (Della, Ludwig approved ludwig-0005; Vikunja 460, mos-1223)
Lands docs/plans/2026-10-21_email-plan.md (merged plan, 399 lines) with
Della's independent plan and her review of Ludwig's plan, from
origin/della/email-plan b0844a5ce on c3d88840c. Della's 10 BUILD-LOG and
4 SESSIONS lines are carried as appended. All rows are post-v1; no live
mailbox, credential, DNS, spend, external mail or re-pin is authorized.
2026-10-21 16:52:52 -05:00
jason.woltje 33029f3a8a deferred: the host pi auth file entry (059577886) moves to Done with row 244 (e69260a3c); real-model suite cases run outside the gate is open (Webby N2 on row 244 r1, #1757) 2026-10-21 16:44:55 -05:00
jason.woltje e69260a3ce row 244: gate runs mount a 0600 fixture pi auth file, test-release activates on the mock adapter, and real-model test-task cases sit behind MOSAIC_LIVE_PROVIDER=1 (#1757)
Candidate morgana/row244 94ab4c2fe on a36523713, manifest 6e7e3e3c (8 paths).
Webby APPROVE r1, rev 3008 (webby-0084, REVIEW-r1.md at webby/reviews
ebb3d83bf; request #1757 c30154). Gate on Sage's slot at 81e037d20
(sage-2147): the five gate-lane files whole 51/0, test-task.sh 91/0,
test-release.sh 14/0, mutants M-G, M-L, M-A, M-T and M-F killed.
94ab4c2fe adds only BUILD.md results. Webby's drift check: a36523713..d37210288
changes none of the 8 paths and no path either suite reads; merge-tree clean.
Rulings A and C (sage-1914, sage-2061). The dropped real-model coverage goes
to DEFERRED in the follow-up doc commit.
2026-10-21 16:44:37 -05:00
jason.woltje 3a853f35dc docs: Q198 system-manager design r6.1 (Huey, Scrooge approved r6 and r6.1, mos-1222)
Integrates origin/huey/system-manager-design 1715efb78 (base 6c75887b0) as one
doc-only commit: docs/plans/2026-10-21_q198-system-manager.md (blob 4fbebd3e,
pinned code evidence base 6c75887b0) plus Huey's 14 BUILD-LOG lines and 7
SESSIONS lines, appended unchanged. Scrooge approved r6 (scrooge-0004) and
confirmed r6.1 N1-N3 closed (scrooge-0005). No package, script, runtime or
queue change. Slices S1-S6 go to the queue under Vikunja 452.
2026-10-21 16:43:25 -05:00
jason.woltje 111ec559c8 brief template: seats hand off directly to the next seat and Sage over T3 (Q210, mos-1221) 2026-10-21 16:36:53 -05:00
jason.woltje 25ffaae832 deferred: T27 sweep test leaves two sleep 60 children; the fix is a process-group kill in both t.after hooks (row 251 landing d37210288, darkwing-0117, Dewey c30168) 2026-10-21 16:36:29 -05:00
jason.woltje d372102882 row 251: T27 adds its key with -t 2 and sleeps 3.5 s, because ssh-agent counts lifetimes in whole seconds (#1756)
Candidate darkwing/row251 27f01ef63 on 3b0aef995, manifest f2bdd684
(BUILD.md and packages/bus/tests/host-run.test.mjs). Dewey APPROVE r2,
rev 2996 (#1756 c30168). Gate on Sage's slot: bus suite whole in a clean
worktree on f7a77721a plus the two manifest paths (b79ad5f2c adds docs only),
627/627 at 21:30:33Z.
Darkwing's slot runs (darkwing-0114) are on the old base and are not the
gate.
2026-10-21 16:30:50 -05:00
jason.woltje b79ad5f2cb brief: the human deletes a creative instance, chained after row 194 (REQ-COS-3, mos-1215); window plan W11 posts on 435 2026-10-21 16:20:12 -05:00
jason.woltje 3fb00018e0 window plan: draft 1 correction, 7333 acceptance was Mos's (mos-1015), Q1/Q3/Q4 answered (mos-1214) 2026-10-21 16:18:35 -05:00
jason.woltje f7a77721a9 window plan: 10-24 re-pin draft 1, NEW 7247d80c7 with --tester-socket (mos-1145, mos-1211) 2026-10-21 16:15:48 -05:00
jason.woltje 829e5f8340 sessions: Sage row 196 landing, rows 253 and 254 launched, 194 b4 ruling, queue revs to 2994, window draft 1 2026-10-21 16:14:06 -05:00
jason.woltje abb352d27e queue: revs 2977-2993; Morgana 244 start, in-review and outcome (2977-2979), Gander 196 r6 manifest (2980/2981), Sage 244 and 196 jarvis review requests, Duckworth 196 m2 approve record (2986), Sage 196 landing note and done, row 254 issue #1759, Darkwing 251 r2 in-progress and in-review (2990/2991), rows 253 and 254 briefed 2026-10-21 16:13:56 -05:00
34 changed files with 4466 additions and 68 deletions

No files matched your search

+24
View File
@@ -5189,3 +5189,27 @@ Before: managed project/thread verbs had no Console conversation area. After: fi
2026-10-20 19:34:28 UTC | Gander row 196 build paused at design mismatch | Before: approved design promised Unix tester cleanup only for the owned inode. After: scratch test shows Node/libuv close unlinks a replacement inode before JS close handlers. Evidence agents/gander/work/queue-196/evidence/tester-cleanup-blocker.txt. Sent Sage/Duckworth gander-0016/0017, recommending explicit same-uid residual with private 0700 parent retained. Interpreter, concurrent peer, principal, human, peer-transport, transport, tester and process single files passed. Partial code uncommitted, tester transport not wired. No suites, mutant batches or live actions.
2026-10-21 18:45 UTC | Gander row 196 preservation before rebase | Sage-2051, Mos order mos-1193, explicitly authorizes commit and fast-forward push of the current build before deferred full verification. Product and evidence preserved together; prior focused checks and stopped gates are recorded in the row196 packet, with no claim of full acceptance. Next: rebase onto a36523713 on new gander/row196-r2, report range-diff/patch-id/conflicts, then await Sage's clear for M1-M29 and all requested suites. No live action, force push, branch deletion or tests during hold.
2026-10-21T16:34:44Z | Huey | Q198/Q199 system-manager design, mos-1183, Vikunja 452 | Before: pinned foundation 6c75887b0 has separate bus, development queue, run evidence, turn/cohort controls and launch/usage paths; the assignment requests a mechanical manager design. After: docs/plans/2026-10-21_q198-system-manager.md proposes admission/cooldown, priority and safe interruption, durable requests, bounded retry/DLQ/replay/rerouting, Console monitoring, optional decision adapters with USD reservations, six ordered slices and three recommended owner decisions. Verification: 26 pinned file:line references resolve, ten-line summary, manual source/contract review, git diff --check and installed queue guard pass. No builds/suites, runtime writes, credential reads, service changes or paid calls. Huey queue next refused the linked worktree, then returned nothing from canonical checkout; direct brief controls this assignment. Gitea issue and queue-row capture are handed to Sage under the no-credential-read/no-queue-write boundary.
2026-10-21T18:45:47Z | Huey | Q198 system-manager design r2, mos-1194, Vikunja 452 | Before: Scrooge requested changes on add7cb506 for decision-key worker exposure, cap scope and absent sampler ownership. After: distinct MOSAIC_DECISION_OPENROUTER_KEY with S6 sessionEnv canary requirement; exclusive per-instance key with per-key caps and provider limit on every enablement; S2 owns supervisor sampler and waits until after MVP/re-pin. Also records P1 interpretation, missing-confidence fallback and stand-in timeout selection, explains verified Noul spelling, excludes notifier/gateway retry loops with citations, and cites lifecycle revocation requirement. Verification: 30 pinned citation spans resolve, ten-line summary, manual diff/source review and whitespace/queue-guard checks. No builds, suites, live changes, credential reads or paid calls.
2026-10-21T19:56:11Z | Huey | Q198 design r3, mos-1201/1201a, Vikunja 452 | Before: Scrooge approved r2 7e785a08f with three notes. After: N1 names proposed system.health bus read verb and includes broker handler/authorization in S2 scope and review; N2 labels exclusive ownership as a setup attestation that cannot be mechanically proved and identifies provider key limit as real ceiling; N3 adds recommended Q4 for P1 narrowing and removes the Mos-flag instruction. Other design content retained. Verification: manual r2-to-r3 delta review, ten-line summary, whitespace and installed queue-guard checks. No builds, suites, live changes or paid calls.
2026-10-21T20:19:53Z | Huey | Q198 design r4, mos-1203, DECISIONS Q204, Vikunja 452 | Before: r3 a36952564 presented Q1–Q4 for owner approval. After: section 9 records Q20a–Q20c verbatim as approved; D11 labels Huey’s reading of configurable daily/monthly caps with $0.10/$1 defaults in the one instance config via reviewed schema; provider key ceiling and explicit enable/key requirements retained; Q4 pending, Jason restated 2026-10-21, P1 unchanged. Verification: quotes match DECISIONS ledger, unrelated sections and P1 equal r3, ten-line summary, whitespace and installed queue guard checked. No builds, suites, live changes or paid calls.
2026-10-21T20:48:49Z | Huey | Q198 design r5, mos-1206, DECISIONS Q205/Q206, Vikunja 452 | Before: r4 1e8bccff1 left Q4 pending and long outages could exhaust request retry budgets. After: protected mode separate from unchanged D1 human kill authority; mechanical detection, safe-point pause/proof, JEV-first endpoint-before-headless probes at 60-second increments to 600 seconds without attempt limit, upstream reset waits, shared D11 probe accounting, one test session then batches, proposed three-failed-exit escalation, frozen outage clocks/counters without DLQ, advisory online-service routing, Q4 superseded and Q22 pending. Thresholds/deadlines/batches/escalation explicitly labelled Huey readings. Acceptance proposals updated, no implementation. Verification: complete Q205/Q206 read, quotes match ledger, D1/inventory/Q1–Q3 unchanged, 30 pinned citation references resolve, 11-line summary, manual consistency/diff review, whitespace and installed queue guard checked. No builds, suites, real probes, live changes or paid calls.
2026-10-21T21:04:37Z | Huey | Q198 design r6, mos-1208/mos-1210, DECISIONS Q207, scrooge-0003, Vikunja 452 | Before: r5 fd44f7c60 gated probes on JEV and lacked request-local resume failure separation. After: parallel independent probes; central JEV/smaller-model/rules endpoint chain, per-model timeout/calibration, 4 KiB metadata limit and isolated fallback credentials; subscription quota vs metered USD accounting and separately enabled paid-probe budget (proposed $0 defaults). S-F1–S-F7: bounded outage classification, request-local D6/two-resume DLQ rule and batch definition; escalation latch with authorized revision-bound system.recovery.release separate from kill clear; cooperative F5 pause/F6 proof only; proposed configurable 24-hour cumulative pause expiry; full Q205 kill sentence with labelled protected-mode interpretation; r3–r6 history. S5/S6 acceptance proposals updated. Verification: Q205/Q207 quotes match ledger, D1/inventory/Q1–Q3 unchanged, 30 pinned references resolve, summary within 15 lines, manual consistency/delta review, whitespace and installed queue guard checked. No builds, suites, real probes, live changes, credential reads or paid calls.
2026-10-21T21:39:25Z | Huey | Q198 r6.1 touch-up, mos-1222, scrooge-0004 N1–N3, Vikunja 452 | Before: approved r6 2ee68cd69 had unprovable session-call denial wording, implicit queue/late-call behavior and a partial Q207 paragraph. After: rule/S6 tests require session decision calls/output have no routing authority; non-waiting dispatchers use internal rules immediately; timed-out JEV does not occupy fallback decision slot but retains spend reservation; full Q207 paragraph copied exactly from DECISIONS; matching Q22 reading corrected. ENV_ALLOW and all runtime code unchanged. Verification: exact full-quote equality, obsolete wording absent, D1 unchanged, summary within 15 lines, manual N1–N3 delta review, whitespace and installed queue guard checked. No builds, suites, probes, live changes or paid calls. Review/handoff via T3 as Mos authorized.
2026-10-21T21:11:44Z | Della mos-1213 / Vikunja 460 planning phase | Before: PRD 1.10 has no email feature; MP 95b0298 and EP 7a64921 are read-only prior inputs. Scope: independent post-v1 email plan on della/email-plan, no implementation/live access/MVP or queue changes. Gitea issue enrollment belongs to Mos/Sage because this seat has no API credential.
2026-10-21T21:12:18Z | Della mos-1213 / Vikunja 460 planning verification | After: docs/plans/2026-10-21_email-plan-della.md is 341 lines, with 13 post-v1 rows and six Jason questions. Verified 42 pinned citation anchors, ten requested coverage areas, identical prior src trees, append-only records, clean whitespace and repository queue guard. No configured CI pipeline or CI-queue wrapper found in the current foundation; documentation-only validation applies, no product suites or live tests run. Phase B/C and Gitea issue/queue enrollment remain with Mos/Sage. Only della/email-plan is authorized for push.
2026-10-21T21:37:07Z | Della mos-1220 / Vikunja 460 phase B cross-review | Before: independent plans Della 191dff32f and Ludwig 4a27b73ac delivered. After: docs/plans/2026-10-21_email-review-by-della.md reviews all 218 Ludwig plan lines, records ten findings, five grafts, six retained strengths, nine disagreements and eleven merged Jason questions. Ludwig review 143415779 read in full after ludwig-0002 arrived; reconciliation records new mail-access action, no agent reroute and correction of Della dataRoot credential example. Original plan unchanged. Planning only, no product/MVP/queue/live change.
2026-10-21T21:43:02Z | Della mos-1223 / Vikunja 460 phase C synthesis | Before: independent plans/reviews complete, Mos selected Della base. After: docs/plans/2026-10-21_email-plan.md merges Ludwig grafts and accepted corrections, with 16 post-v1 rows (eight READY, eight WAIT/optional), eleven deduplicated Jason questions, per-row test paths and explicit Q210 non-v1 scheduling. Original Della plan/review preserved; Ludwig originals remain on their branch. Static checks cover graft/row/question/citation anchors and original blobs. No source code, suite, mailbox, secret file, service, MVP or queue change. Candidate goes to Ludwig before Sage landing/queue handoff, copying Mos.
2026-10-21T21:45:20Z | Della mos-1223 / ludwig-0004 focused corrections | Before: b096a16cc left content-key location implicit and E05 core registration marked ready. After: merged plan 399 lines fixes external configDir secret key/lifecycle namespace outside dataRoot backup/restore, E01 schema and E03 missing/expired-key restore tests, and E05/E06 test-local registry readiness with core kinds.mjs registration gated on approved E01/Q4. Sixteen rows and eleven questions retained; originals unchanged. Focused static checks and queue guard apply, no product/live changes. Reviewer will check changed lines only.
+191
View File
@@ -0,0 +1,191 @@
# Row 251 build: T27 lists no identities right after `ssh-add -t 1` (Vikunja #453)
Brief `docs/plans/2026-10-21_t27-ssh-add-lifetime.md` (rev 2931), Mos ruling mos-1185 item 2, assigned in
sage-2075. Owner Darkwing, reviewer Dewey, gate owner Sage. Branch `darkwing/row251` from 3b0aef995,
claimed in-progress at rev 2938.
## Step 1: the failure names its cause (e5e4b2e5a)
T27's two lists now carry a message that prints only when they fail. It gives the time from addKey's
start to its resolve and to each `ssh-add -l` call, the hrtime at each with how far into its second it
fell, `/proc/uptime` at each, and whether the agent process and its socket were still there. The checks
themselves did not change. The commit holds nothing else.
## Step 2: the cause
ssh-agent counts a key's lifetime in whole seconds of CLOCK_BOOTTIME, at the add and at the reap. I read
OpenSSH 10.5p1, the version on dragon-lin (`ssh -V`: OpenSSH_10.5p1), at tag V_10_5_P1 (ssh-agent.c
sha256 873691c4, misc.c sha256 ee467f43):
- `misc.c:1790-1798` and `:1828`: `monotime()` returns `ts.tv_sec` of `clock_gettime(CLOCK_BOOTTIME)`,
falling back to MONOTONIC. The fraction of the second is dropped.
- `ssh-agent.c:1315`: the `-t` constraint sets `*deathp = monotime() + seconds`. Lines 1410 and 1598
do the same for the agent's default lifetime.
- `ssh-agent.c:1064`: the reaper drops a key when `now >= id->death`, `now` being `monotime()` again.
- `ssh-agent.c:2650`: the main loop runs `reaper()` after every poll, before it reads requests. A list
that arrives after the boundary sees the key gone.
So a key added at second s plus a fraction f dies at s + N, and `-t N` keeps it N - f seconds, between
N - 1 and N. With `-t 1`, an add late in a second leaves the key milliseconds to live. That matches
Sage's probe in the brief: 8 of 80 `-t 1` adds missed, each one made at 0.9947-0.9973 of a monotonic
second and finished at 0.998-0.0008, and none earlier in the second missed. It also matches proof 5's
failure, where the first list ran 31.9 ms into the test and found no identities.
Node's hrtime is CLOCK_MONOTONIC. BOOTTIME differs from it by the time the host spent suspended. On
dragon-lin the difference read 0.000001 s at 19:09Z, so a monotonic second boundary is a BOOTTIME one
here.
`addKey` and the agent start are not the cause. `addKey` passes the lifetime through as `-t`
(`host-run.mjs:90`), and the agent starts with no default lifetime (`:175`). Production adds the key
with `timeout + 30` (`:180`), where losing up to a second makes no difference. The only caller that
can't afford the lost fraction is T27, which asks for 1 s. The fix belongs in the test's lifetime choice,
and `host-run.mjs` doesn't change.
The slot confirms this against T27 itself (below), not just from the source.
## Step 3: the fix (this commit)
T27 adds the key with `-t 2`. The agent then keeps it more than 1 s and at most 2 s. The first list
runs milliseconds after the add, so it has over a second of margin even under load. The sleep before
the second list grows from 2.5 s to 3.5 s, which keeps the old 1.5 s margin past the new 2 s ceiling.
Both checks stay: listed after the add, gone after the lifetime. A comment above the add says why the
lifetime is 2.
I considered `-t 3` with a 4.5 s sleep for a 2 s floor. One second of floor is far above anything the
first list has needed (31.9 ms in proof 5), and the concurrent-suite run below tests it under load, so I
took the smaller change.
## Base
The branch stays on 3b0aef995. By 19:15Z origin/refactor was at 6096b051b, after rows 66 and 243
landed. `git diff 3b0aef995 6096b051b` touches neither `packages/bus/src/host-run.mjs` nor
`packages/bus/tests/host-run.test.mjs`, so there's no rebase (sage-2084). Row 243 did change
`packages/bus/tests/process.test.mjs`. My slot's bus suites run that file as it was at 3b0aef995, and the
gate rerun on Sage's slot runs it as landed.
## Prediction for part A on e5e4b2e5a (written before the slot)
The hook starts `ssh-add -t 1` at the given fraction of a monotonic second. From the brief's probe, the
agent stores the key about 3 ms later. The first list reaches the agent a few ms after addKey resolves:
Node's exit event, the mark, and `ssh-add -l`'s own start. A red needs the add to land before the second
boundary and the list to land after it.
- 0.990: green. The add and the list both land before the boundary.
- 0.993, 0.995, 0.996, 0.997: red expected. The add lands before the boundary, the list after. At 0.993
and 0.997 I'm less sure, because the list (at 0.993) or the add (at 0.997) can land right at the
boundary. A green at either is in line with the cause, and so is a red.
- 0.998: green. The add lands after the boundary, so the key gets almost a full second.
Every red should show "first list got" an answer with no SHA256 line. It should also show addKey
resolving before a second boundary and the first list starting after it or within a few ms of it, with
the agent alive and its socket present.
Under sage-2084's rule, on e5e4b2e5a only a red at 0.990 or 0.998 counts toward the stop, and two such
reds stop the run. On the fix commit any red counts, and two stop it. In parts B to D, the first red stops
the run.
## Slot plan
All scripts are in `/mnt/storage/scratch/darkwing-251-out/`, launched from `/`, each behind the busy
checks and chained with `&&`, TMPDIR under `/mnt/storage/scratch/tmp`.
1. Confirmation. `align-hooks.mjs` is a load hook, outside the repo, that serves `host-run.mjs` with
`addKey` wrapped to start `ssh-add` at a chosen fraction of a monotonic second. T27 runs unchanged
under it, alone (`--test-name-pattern`). It runs at fractions 0.990, 0.993, 0.995, 0.996, 0.997 and
0.998, three times each:
- on e5e4b2e5a (step 1, `-t 1`). Expected: some first lists fail, and the step 1 message shows the
add's resolve and the list on either side of a second boundary, with the agent alive and the
socket present. These reds are the point of the run.
- on the fix commit (`-t 2`). Expected: all 18 pass.
2. `host-run.test.mjs` whole, once, on the fix commit.
3. The bus suite whole, three times in a row.
4. The bus suite whole in two clean worktrees at the same commit, started together.
## Slot results
Sage's CLEAR came in sage-2154 at 20:58:55Z. `slot-251.sh` ran parts A and B from 20:59:22Z to 21:01:33Z,
on node v26.8.1 and OpenSSH_10.5p1, with TMPDIR `/mnt/storage/scratch/tmp/r251`. At the start, BOOTTIME
minus MONOTONIC read 0.000001 s, then 0.000000 s twice (dewey-0769 N1). So the hrtime fractions below
put the agent's second boundary in the right place.
### Part A: T27 alone under `align-hooks.mjs`, 3 runs per fraction
The fraction is when the hook started `ssh-add`. The hook logged each start within 0.0001 of its target.
| fraction | e5e4b2e5a (`-t 1`) | 68f2dfd4b (`-t 2`) |
|---|---|---|
| 0.990 | 3 pass | 3 pass |
| 0.993 | 3 pass | 3 pass |
| 0.995 | 3 red | 3 pass |
| 0.996 | 3 red | 3 pass |
| 0.997 | 3 pass | 3 pass |
| 0.998 | 3 pass | 3 pass |
e5e4b2e5a went red 6 times out of 18, every time at a fraction the prediction named, so none of them
counted toward the stop. 68f2dfd4b passed all 18. The fix runs used 68f2dfd4b, whose test blob is the same
as c19b4167e's (280132c7).
Every red failed on the first list and got `"The agent has no identities.\n"`, with the agent alive and
its socket present. The step 1 message gave these hrtime fractions. "addKey start" is the mark before the
hook's wait, so its fraction is earlier than the ssh-add start.
| run | addKey resolved | first list mark |
|---|---|---|
| 0.995 rep 1 | 0.9992 (+317.6 ms) | 0.9993 |
| 0.995 rep 2 | 0.9991 (+846.5 ms) | 0.9991 |
| 0.995 rep 3 | 0.9988 (+845.0 ms) | 0.9988 |
| 0.996 rep 1 | 0.9999 (+839.8 ms) | 1.0000 |
| 0.996 rep 2 | 0.0004 (+845.0 ms) | 0.0004 |
| 0.996 rep 3 | 0.0002 (+835.9 ms) | 0.0002 |
At 0.995 the add finished about 4 ms in, before the boundary, and the list mark came just before it. The
`ssh-add -l` process then reached the agent after the boundary. At 0.996 the agent stored the key before
the boundary, and addKey resolved just after it. In both cases the key's whole second ended between the
add and the list, which is the cause step 2 gives. The two edge fractions came out green on the first
build. At 0.993 the list still reached the agent before the boundary, and at 0.997 the add landed after
it, so the key got almost a full second. The prediction allowed either result at those two.
### Part B: `host-run.test.mjs` whole on 68f2dfd4b
24 tests, 24 passed (21:01:18Z, 14.0 s).
### The stop before C1, and the resume
At 21:01:33Z the script stopped before C1, with exit 4 and no red. My own `busy.sh` matched two `sleep 60`
processes, mine, in `darkwing-r251/packages/bus`. Part B left them. They come from T27 "the start sweep
keeps a live bus dir and kills only a matching live process" (`host-run.test.mjs:528` and `:536`). Its
`t.after` SIGKILLs the `/bin/sh -c 'sleep 60; :'` parent, but the `sleep` child lives on for up to 60 s.
That code is outside this row's diff, so the row leaves it alone, and it's recorded below as a follow-up.
Sage confirmed (sage-2159) that the right move is to resume only the scope that hadn't run.
`slot-251-cd.sh` runs C and D as `slot-251.sh` has them, with the same two busy checks. Before each check,
it waits up to 75 s for those orphans to exit: my uid, my trees, a command line of exactly `sleep 60`.
It logs them as it waits. Any other process still trips the checks.
### Parts C and D: the bus suite whole on 68f2dfd4b
| run | start | tests | pass | fail | time |
|---|---|---|---|---|---|
| C1 | 21:02:48Z | 598 | 598 | 0 | 21.6 s |
| C2 | 21:04:06Z | 598 | 598 | 0 | 21.4 s |
| C3 | 21:05:26Z | 598 | 598 | 0 | 22.1 s |
| D1, darkwing-r251 | 21:06:44Z | 598 | 598 | 0 | 24.2 s |
| D2, darkwing-r251b, TMPDIR `r251-b` | 21:06:44Z | 598 | 598 | 0 | 24.2 s |
The script waited 33 s before C2, 34 s before C3, 33 s before D and 32 s before the sweep. Each time it
was waiting for that run's `sleep 60` orphans (two per suite, four after D), and nothing else. After the
wait, the sweep of my trees, the out dir and TMPDIR found 0 processes left (21:08:04Z). Sage confirmed
(sage-2159, sage-2160) that the short gitea-api and `node --check` runs they made during the slot weren't
stops. None of my busy checks refused on any of them.
These runs used the bus files as they were at 3b0aef995. Since then, origin/refactor (666173292, then
7247d80c7 and c3d88840c with row 196) has changed `broker.mjs`, `runtime.mjs` and `server.mjs`, which T27's
file imports, along with other bus files. None of those commits touch this row's two paths or
`host-run.mjs`, and `git merge-tree` of c3d88840c with 68f2dfd4b is clean. The gate rerun in
a clean worktree on Sage's slot runs T27 against them.
### Follow-up, not in this row
T27's start-sweep test leaves the `sleep 60` child of each planted shell running for up to 60 s after
the test. Killing the process group (`process.kill(-pid)`; both shells are spawned `detached`) or
`exec sleep 60` in the shell would end the child along with its parent.
+230
View File
@@ -0,0 +1,230 @@
# Row 244: gate runs use a fixture pi auth file
Brief: `docs/plans/2026-10-21_gate-env-rows.md` § "Gate runs use a fixture pi auth file" (Sage, sage-1909).
Reviewer Webby. DEFERRED entry "Gate runs mount the host's pi auth file" (059577886). Design notes
written before row 197 lands, read against `origin/herb/row197-r6` (df0c59608). Recheck every line cite
against 197's landing before code starts.
## Where Compose is reached
`compose.yaml:43` mounts `${PI_AUTH_FILE:-/home/jwoltje/.pi/agent/auth.json}` read-only. The default is
a literal path, not `$HOME`, so keeping `HOME` in the lane env doesn't change it.
Gate steps come from `discoverSteps` (`gate-lane.mjs:32`): one `pkg-<name>` step per package with tests
(not queue) and one `script-<name>` step per `scripts/test-*.sh`. A grep of those for `run-task.sh`,
`docker compose`, `hello.sh`, `verify.sh`, `agent.sh` and `release.sh activate` finds two Compose
paths. The other hits (`cohort.test.mjs` "hello" shim requests, `test-auth.sh`) don't reach Compose.
| Step | Path to Compose | Adapter |
|---|---|---|
| `test-task.sh` :150-385 | `scripts/run-task.sh run` → `mosaic-task.mjs:396` `docker compose run` with `spawnEnv = {...process.env}` | mock (`mock_config`) |
| `test-release.sh` :69, :85, :93 | `release.sh activate` → `health_check` (:41-55) → `run-task.sh run tasks/hello-marker.json` | **pi** (config :61 sets no adapter; provider zai, model glm-5.3-flash) |
`mosaic-task.mjs` passes `process.env` through, so a `PI_AUTH_FILE` exported by a suite reaches Compose.
## The conflict: test-release's healthy activation is a live provider call
"healthy activation succeeds" (:85) and "repeat activation succeeds" (:93) need `health_check` to
pass. That runs the real pi adapter, asks glm-5.3-flash for `MOSAIC_HELLO_OK`, and authenticates through
the mounted auth file. The lane's `cleanEnv` drops `ZAI_API_KEY`, so the mount is the only credential.
With a fixture auth file the provider call fails, and four checks fail: :85, the pointer check after it,
:93 and "log is append-only". So every gate run today spends a real provider call on the host credential.
Options put to Sage (morgana-0037):
- **A (recommended).** test-release's config sets `"adapter":"mock"`, and the two healthy activations
run with `MOSAIC_MOCK_RESPONSE=MOSAIC_HELLO_OK`. The fault injection still refuses, because its task
expects `MOSAIC_FAULT_<random>`. The suite keeps proving the release state machine, the image and
Compose path, and the pointer and log. It stops proving that the pi image can reach the provider.
Real `release.sh activate` runs still do that, with the real credential. No credential and no spend
in the gate.
- **B.** Keep the live call. The lane passes the real auth file explicitly. That contradicts the brief
and DEFERRED, so I don't take it without a ruling.
**Ruling: A (sage-1914, 09:51Z).** B keeps a live billed call on every gate, which is what this row
stops. Sage's conditions:
1. test-release.sh's suite config sets `"adapter":"mock"`, with a comment beside it saying why: no
credential and no provider call in the gate (row 244). The healthy activations run with
`MOSAIC_MOCK_RESPONSE=MOSAIC_HELLO_OK`.
2. Fault injection still refuses. It keeps the random `MOSAIC_FAULT_<n>` expectation, and the mock's
fixed reply can't satisfy it. Mutant M-F forces the fault task's expected value to `MOSAIC_HELLO_OK`.
It must fail the suite.
3. The fake-docker test also asserts that test-release's activations ran with the mock adapter. The
fake docker records `MOSAIC_ADAPTER`, which `mosaic-task.mjs` sets in `spawnEnv` from the resolved
config. Then a suite drifting back to pi fails the test.
4. Coverage dropped: test-release.sh no longer proves that the pi image reaches the provider. A real
`release.sh activate` still proves it, with the real credential. Sage adds a DEFERRED line at landing
if Webby wants one.
guardDocker's new refusal also gets a mutant (M-G, below).
## Change
1. `scripts/test-task.sh` and `scripts/test-release.sh`: right after `SANDBOX` is made, write
`$SANDBOX/pi/auth.json` (`{}`, mode set by `chmod 600` after the write, so it doesn't depend on the
umask, row 243) and `export PI_AUTH_FILE`. It has to be a regular file that exists, because a missing
bind source makes Docker create a directory on the host. The suites set it themselves, so the legacy
gate (outside the lane) is covered too.
2. `scripts/test-release.sh`: option A above.
3. `scripts/testing/gate-lane.mjs` `cleanEnv`: set `PI_AUTH_FILE` to `join(o.tempRoot,'pi-auth.json')`,
and `run` writes that file (`{}`, `flag:'wx'`, then chmod 0600) next to where it writes `bin/docker`.
Any later step that reaches Compose without its own fixture then gets the lane's fixture.
4. `scripts/testing/lane-docker.mjs` `guardDocker`: `compose run` also refuses unless `PI_AUTH_FILE` is
set, resolves inside `run.tempRoot` and isn't the host default (refusal text
`Compose auth mount not a lane fixture`). The guard already sees every Docker call a lane step makes,
so this is where "no gate step mounts the host file" is enforced at run time. The CLI entry passes
`process.env`.
5. `compose.yaml` is unchanged (brief: not in scope).
6. At landing: the DEFERRED entry moves to Done with the landing commit.
## Tests
In `packages/cli/tests/gate-lane.test.mjs` (row 197's file; the exact file follows 197's landing):
- `guardDocker` refuses `compose run` with `PI_AUTH_FILE` unset, set to the host default, or set outside
`tempRoot`; it accepts the lane fixture and a suite fixture under `tempRoot`. Other verbs are unchanged.
- `cleanEnv` returns a `PI_AUTH_FILE` inside `tempRoot` that isn't the host default.
- The suites, read through their Compose environment: run `test-task.sh` and `test-release.sh` with a
fake `docker` first on `PATH`. It answers `info` and `image inspect` with 0, and for `compose run` it
appends `PI_AUTH_FILE` and whether that file is a regular 0600 file to a record. Then it prints the
mock response. The test ignores the suites' own exit code and asserts that the record has at least one
`compose run` from each suite and that every entry names an existing fixture file, never
`/home/jwoltje/.pi/agent/auth.json`. The test never reads the host file. If a full suite run with the
fake docker turns out too slow or brittle, I'll say so in the review request, with timings, and fall
back to the guard and `cleanEnv` tests plus a static check that each suite exports `PI_AUTH_FILE`
before its first Compose call. I won't drop it silently.
Mutants (each must fail a test; run on Sage's slot):
- M-F: the fault task's `expectExact` forced to `MOSAIC_HELLO_OK` in `release.sh` `health_check`. Must
fail `test-release.sh` ("fault-injected activation refuses").
- M-G: the `guardDocker` `PI_AUTH_FILE` check removed. Must fail the guard test.
- M-A: test-release's config back to no adapter (pi). Must fail the fake-docker test (condition 3).
- M-L: `cleanEnv` stops setting `PI_AUTH_FILE`. Must fail the `cleanEnv` test.
Gate: those tests, `test-task.sh` and `test-release.sh` whole, the 197 lane tests, and the four
mutants, all on Sage's slot.
## Files (expected)
`scripts/test-task.sh`, `scripts/test-release.sh`, `scripts/testing/gate-lane.mjs`,
`scripts/testing/lane-docker.mjs`, `packages/cli/tests/gate-lane.test.mjs` (or a new
`gate-lane-auth.test.mjs`), `docs/plans/DEFERRED.md` (at landing), `scripts/testing/README.md` if it
lists the guard's rules.
## Correction (morgana-0040, 18:50Z)
The table above says `test-task.sh` uses only the mock adapter. That was wrong. On a36523713 two
sections run the pi adapter through `$CONFIG` (provider zai) and authenticate through the host mount:
`:443-501` (live hello, result.json written and correct, wrong expectExact, distinct run dirs, list,
fork base, fork child, fork recall, ancestor untouched, child branch file) and `:518-530` (user recall
run, recalled the user name). That's 13 checks and about 6 billed provider calls per gate run. I missed
them because I read the mock section and stopped there.
**Ruling C (sage-2061, 18:50Z).** D (keep them live) refused.
1. The run-record checks (hello, result.json, wrong expectExact, distinct run dirs, list) move to the
mock adapter. Every assertion is kept. Only the response source changes.
2. Fork recall and user recall run only under `MOSAIC_LIVE_PROVIDER=1`. Otherwise the suite prints
`skip live provider cases (row 244: no provider call in the gate)`, which the lane's requiredSkips
regex doesn't match, on purpose. A test asserts the line prints when the variable is unset and that
the live checks run, and fail under a fixture, when it's 1 (M-T).
3. The dropped coverage and the DEFERRED wording are below. Sage writes the entry at landing.
## Build (on a36523713, sage-2056)
Rechecked against 197's landing: `lane-docker.mjs:30` guardDocker, `gate-lane.mjs:109` cleanEnv and
`:181` (bin/docker written), `compose.yaml:43`, `release.sh:41-55` health_check, `mosaic-task.mjs:321-322`
spawnEnv and MOSAIC_ADAPTER, `test-release.sh:61` config. All hold.
- `scripts/testing/lane-docker.mjs`: `guardDocker(argv,run,env=process.env)`. After the option check, a
`compose run` calls `laneAuthFile`. It refuses (`Compose auth mount not a lane fixture`) unless
`PI_AUTH_FILE` is absolute, isn't the host default, resolves (realpath) inside the realpath of
`run.tempRoot`, and is a regular file. Symlinks out of the root and relative paths refuse. `info` and
`image inspect` need no auth file. The CLI entry passes `process.env`.
- `scripts/testing/gate-lane.mjs`: `cleanEnv` sets `PI_AUTH_FILE=join(o.tempRoot,'pi-auth.json')`.
`runGate` writes it (`{}`, flag `wx`, then `chmodSync` 0600) right after `bin/docker`.
`MOSAIC_LIVE_PROVIDER` isn't on cleanEnv's keep list, so the lane can't request live cases.
- `scripts/test-release.sh`: exports a `$SANDBOX/pi/auth.json` fixture (`{}`, `chmod 600` after the
write, exits 1 if the write fails). The config adds `"adapter":"mock"` with the ruling A comment. The
fault, healthy and repeat activations all run with `MOSAIC_MOCK_RESPONSE=MOSAIC_HELLO_OK`, so the
fault activation refuses only because its `MOSAIC_FAULT_<n>` expectation is wrong. (Correction,
before any run: the first build gave the fault activation no mock response. The mock then answered
empty, which fails any expectation, so M-F would have survived. Fixed in the follow-up commit.)
- `scripts/test-task.sh`: same fixture, exported only when `MOSAIC_LIVE_PROVIDER` isn't 1, so an
operator's live run keeps their own `PI_AUTH_FILE` (or Compose's default). Ruling C (a) and (b) as
above. The section header changes from "live: real runs" to "run records through the mock adapter",
the check name "live hello task..." becomes "hello task...", and the docker-less branch of the new
split prints `skip run-record cases (docker unavailable)`.
- `packages/cli/tests/gate-lane-image.test.mjs` (:21, :106, :119): the compose-run calls pass
`run.env`, the composeFixture env, which already holds a 0600 `PI_AUTH_FILE` inside tempRoot (accepted
by sage-2061).
- `scripts/testing/README.md`: one sentence on the lane fixture and the refusal.
- New `packages/cli/tests/gate-lane-auth.test.mjs`, five tests:
1. guardDocker accepts the lane fixture and a suite fixture under tempRoot. It refuses unset, empty,
the host default, a file outside, a symlink inside that points outside, a missing path, a directory
and a relative path. The option check still comes first. `info` and `image inspect` pass with no env.
2. cleanEnv sets the lane fixture path and drops `MOSAIC_LIVE_PROVIDER` and the caller's
`PI_AUTH_FILE`.
3. test-release.sh in a private copy of the checkout, with a fake `docker` first on PATH. The fake
answers every verb with 0. For `compose run` it records `PI_AUTH_FILE`, `stat -c '%F %a'` of it
(never for the host path) and `MOSAIC_ADAPTER`, and it prints `MOSAIC_MOCK_RESPONSE` when the
adapter is mock. Asserts: at least 3 compose runs, every one on a regular 0600 file under the
suite's TMPDIR, and every adapter mock (condition 3). The healthy and fault lines print OK.
4. test-task.sh, same harness: every compose run on its fixture and the mock adapter, the skip line
printed exactly twice, and the hello check OK.
5. M-T: test-task.sh with `MOSAIC_LIVE_PROVIDER=1` and an operator-style fixture. No skip line,
`FAIL fork base: teach succeeds` and `FAIL recalled user name`, at least one pi run, and every run
on the operator's file.
The copy leaves out `.git`, `node_modules` (symlinked), `v1`, `agents`, `docs`, `BUILD-LOG.md` and
`scripts/foundation`, about 14 MB. Copying is needed because `test-release.sh` rewrites `RELEASE`
while it runs, which would race other cli tests that read it. The suites ignore their own exit codes
here: the fake doesn't emit the mock adapter's env lines, so some seam checks fail by design.
- Readers: `gate-lane.mjs` is imported by gate-lane.test, gate-lane-state.test,
gate-lane-fixtures.test, gate-lane-image.test and fixtures/gate-lane-child.mjs (unchanged exports).
`lane-docker.mjs` by gate-lane-image.test. No test hash-pins any changed file. vars.test.mjs names
`scripts/test-task.sh` only as a string value.
Coverage dropped from the gate (for Webby, and for the DEFERRED entry):
- `test-release.sh` no longer proves that the pi image reaches the provider (ruling A).
- `test-task.sh` no longer proves session fork recall or onboarding user recall against a real model
(ruling C).
DEFERRED wording for Sage (sage-2061 condition 3): "**Real-model suite cases run outside the gate.**
Row 244 moved `test-task.sh` fork recall (M11) and user recall (M16) behind `MOSAIC_LIVE_PROVIDER=1`,
and `test-release.sh` activations to the mock adapter. Before any re-pin whose tree changes
`packages/harness` or the session fork code (`scripts/mosaic-task.mjs` session and `sessionForkFrom`
handling, the pi adapter), an operator runs `MOSAIC_LIVE_PROVIDER=1 bash scripts/test-task.sh` with the
real pi credential and a real `release.sh activate`, and records both results on the re-pin row."
Mutants (each must fail a named test; on Sage's slot):
- M-F: `release.sh` health_check fault task `expectExact` forced to `MOSAIC_HELLO_OK`. Fails
test-release.sh "fault-injected activation refuses", and test 3's OK match. This needs the fault
activation's `MOSAIC_MOCK_RESPONSE=MOSAIC_HELLO_OK` (see the correction above).
- M-G: the `laneAuthFile(env,run)` call removed from guardDocker. Fails test 1.
- M-A: `,"adapter":"mock"` removed from test-release's config. Fails test 3 (adapter set is pi).
- M-L: the `PI_AUTH_FILE` key removed from cleanEnv. Fails test 2.
- M-T: test 5 itself (sage-2061). As a mutant check too: the `MOSAIC_LIVE_PROVIDER` gate in test-task
forced on fails test 4 (skip line count and pi adapter).
Results, Sage's slot (sage-2147), candidate 81e037d20, 2026-10-21 20:46:16Z to 20:46:59Z. Busy check
CLEAR before and after. Image `mosaic-poc-agent:0.85.1-r0.0.12` present, so nothing was built. No
unexpected failure. Singles ran under umask 022 with TMPDIR under /mnt/storage/scratch/tmp.
| Run | rc | Result | Time |
|---|---|---|---|
| gate-lane-auth.test.mjs (new) | 0 | 5 pass, 0 fail | 14 s |
| gate-lane-image.test.mjs | 0 | 11 pass, 0 fail | 0 s |
| gate-lane.test.mjs | 0 | 13 pass, 0 fail | 0 s |
| gate-lane-state.test.mjs | 0 | 14 pass, 0 fail | 2 s |
| gate-lane-fixtures.test.mjs | 0 | 8 pass, 0 fail | 0 s |
| test-task.sh | 0 | OK 91, FAIL 0, the two live-skip lines | 12 s |
| test-release.sh | 0 | OK 14, FAIL 0, Docker section on the mock adapter | 2 s |
Mutants, each run on its named gate-lane-auth test and restored after:
| Mutant | Target | Result |
|---|---|---|
| M-G | test 1 | killed (0 pass, 1 fail) |
| M-L | test 2 | killed (0 pass, 1 fail) |
| M-A | test 3 | killed (0 pass, 1 fail) |
| M-T | test 4 | killed (0 pass, 1 fail), 6 s |
| M-F | test 3 | killed (0 pass, 1 fail) |
| M-F | real test-release.sh | killed: rc 1, OK 11, FAIL 3, the first `fault-injected activation refuses (exit 0, expected 1)` |
+144
View File
@@ -0,0 +1,144 @@
# Row 257: Q198 S1 pressure parser and pure rules evaluator
Issue #1762. Brief: `docs/plans/2026-10-21_post-v1-row-briefs.md`, "Brief: Q198 S1 pressure parser and
pure rules evaluator" and "Gate: Q198 S1" (cca206cdc). Design: `docs/plans/2026-10-21_q198-system-manager.md`
r6.1, section 8 row S1 and section 4 (D4). Owner Morgana, reviewer Filbert, gate owner Sage (sage-2196).
Post-v1: MVP rows and re-pin windows go ahead of it on the slot. Base: refactor cca206cdc.
## Recorded choices (sage-2196 accepted each)
1. **Placement.** A new package, `packages/supervisor` (`@mosaic/supervisor`), shaped like
packages/runs. Nothing on refactor parsed loadavg, PSI, meminfo or cgroup limits (`git grep`; the
only hit was an awk line in scripts/case2-precheck.sh). S2 adds the sampler process to the same
package. Packages aren't registered centrally, so no other file changes.
2. **Provider scope** (429, quota, Retry-After, the half-open probe) is out. The brief puts it in S3
and S5.
3. **PSI** is not required on a host that has never shown it, so a kernel built without PSI skips the
PSI rows instead of holding forever. Once a sample has carried PSI, it is required: a later missing
or stale PSI signal holds with `health-unknown`. That is the case sage-2196 asked for.
- Tradeoff: I latch on the first sample that carries PSI, not only on the very first sample. So PSI
that appears late and then disappears also holds. This is stricter than "present at the first
sample", and it never skips a signal the host has shown.
4. **Memory under a cgroup.** Available memory = min(MemAvailable, limit - memory.current) at each
limited level. memory.current counts page cache, so this errs toward holding.
- Follow-up, not in this row: subtract reclaimable cache using memory.stat `inactive_file`, if S2's
live samples show holds that the host's MemAvailable doesn't support.
Rulings on two readings from the build (sage-2202, re morgana-0057):
5. **PSI latch.** PSI becomes required on the first sample that carries it. This is stricter than
"present at the first sample" and fails closed: a PSI file that disappears after it was seen is a
missing signal, so health-unknown holds.
6. **PSI count across resources.** Design line 152 says "CPU some ≥20, memory full ≥2, or I/O full
≥10, for 3 samples". The window applies to the whole disjunction, so any PSI condition on 3
consecutive samples holds. If Filbert reads it per resource at review, the review finding decides,
and the fix is a single counter.
Other readings of the D4 table, made in the build:
- **Memory floors.** "Whichever demands more free memory" means the larger floor applies. Hold below
max(15% x limit, 1 GiB); release above max(25% x limit, 2 GiB).
- **Edges as written.** Hold at ≥ for load and PSI, < for memory. Release at ≤ 0.55 for load, < for
PSI, > for memory. Critical below 5% of the limit and below 1 GiB of disk.
- **Sample counts.** "For 3 samples" means 3 consecutive samples. The count resets after a gap of more
than 6 s (the stale limit) between samples. The PSI row counts as one row (ruling 6), and the
reasons name the resources over on the latest sample.
- **Memory holds on one sample.** The table's memory row has no sample count.
- **Release** needs every applicable release condition on every sample for 30 s, by the samples' own
times (`>= 30000` ms after the first qualifying sample). A dead-band sample or any hold restarts the
window, and so does a gap of more than 6 s.
- **Start held.** `initialState()` is held, because nothing has been seen yet. The first healthy
sample starts the 30 s window.
- **Missing or stale.** Required signals are load, memory, oom and disk, plus PSI once seen. A signal
holds at once with `health-unknown` if it is:
- missing;
- non-numeric, negative or zero where a divisor;
- more than 6 s older than its sample (6 s exactly is fresh);
- stamped later than its sample.
Disk and OOM are required because the critical row can't be evaluated without them.
- **Host-only OOM.** `/proc/vmstat` `oom_kill` gives the OOM counter when there's no cgroup
memory.events. The parser returns null for a kernel without it, and the sampler then has no OOM
signal (health-unknown).
- **Critical** latches. It ends only when the caller passes `reconciled` with no critical condition in
the sample, then goes through held and the normal 30 s release. The evaluator never sets
`reconciled` and has no kill-switch path. Critical reasons accumulate while latched.
- **OOM counter.** The first OOM sample is the baseline. A falling counter (a recreated cgroup) resets
the baseline with a note, not an event.
- **Clock going backwards.** A sample earlier than the last one holds with `health-unknown` (unknown
`clock`, note `clock-backwards`).
- **Swap activity** is parsed (`pswpin`, `pswpout`) and has no rule. D4 lists it as a signal with no
threshold.
- **Settings.** `resolveRules` refuses:
- unknown keys and non-numbers;
- a release at or past its hold (load, each PSI resource);
- memory release floors at or below the hold floors;
- fractions outside (0, 1);
- PSI values outside 0 to 100;
- sample counts that aren't integers ≥ 1;
- a negative release window;
- a stale limit at or below the sample interval;
- a critical memory fraction at or above the hold fraction;
- a non-positive disk floor.
- **Large byte values.** Parsed with `Number`. A memory.max above 2^53 loses precision, but it only
takes part in a min against MemTotal.
## Round 2 (Filbert r1 APPROVE c30188 notes, sage-2212)
- N1: `merge` checked unknown keys with `key in base`, so inherited names (`toString`, `constructor`,
`__proto__`) passed the check and were dropped instead of refused. It now uses
`Object.hasOwn(base, key)`, and the refusal test has those three cases.
- N2: the critical-memory edge was tested only on a 32 GiB limit, where 5% is not an integer, so a
`<` to `<=` mutant survived. The test adds a 20 GiB limit, where 5% is exactly 1 GiB, and M8 is that
mutant. I checked the other memory edges for the same gap:
- The hold floor is non-integer on 32 GiB but is tested at exactly 1 GiB on a 4 GiB limit.
- The release floors are 8 GiB on 32 GiB and 2 GiB on 4 GiB, both integers.
- The disk edge is 1 GiB.
None has the gap.
## Files
- `packages/supervisor/package.json`, `README.md`
- `src/errors.mjs`: `SupervisorError` with `kind` parse, rules or sample.
- `src/pressure.mjs`: the nine parsers, `effectiveCpus` and `effectiveMemory`.
- `src/rules.mjs`: `DEFAULT_RULES`, `resolveRules`, `initialState`, `evaluate`.
- `src/index.mjs`
- `tests/pressure.test.mjs`: parsers on kernel-shaped fixture text, with malformed, truncated and
repeated-key cases. Effective CPU cases cover the host-only fallback, cpuset, fractional and nested
`cpu.max`, and `max`. Effective memory cases cover the host-only fallback, `max`, nested levels,
usage over the limit, a limit above MemTotal and a missing memory.current.
- `tests/rules.test.mjs`: the D4 table on a fake clock.
- Each hold and release threshold on both sides of its edge.
- Three-sample counting and the gap reset.
- The 30 s release at 29 999 and 30 000 ms, and the dead-band restart.
- Missing, stale, future and malformed signals.
- PSI never present, and PSI lost after it was present.
- Critical OOM, memory and disk at their edges, the reconciliation latch and the counter reset.
- A backwards clock, input state left unchanged, and settings refusals and overrides.
- `tests/purity.test.mjs`: a static check that src imports only its own files and uses no
fs, child_process, net, os, process, dynamic import, Date or timers.
No test reads /proc, /sys or a cgroup, starts a build, opens a network socket or generates load. No new
dependency.
## Mutants (each must fail its named test; the brief requires M1 and M2)
| Mutant | Edit | Named test |
|---|---|---|
| M1 hold edge | `ratio >= rules.load.hold` becomes `>` | load holds at 0.80 for 3 samples and not at 0.7999 |
| M2 early release | `now - next.releaseSince >= rules.releaseMs` becomes `>= 0` | the first state is held, and healthy samples open it after exactly 30 s |
| M3 memory floor | the hold floor `Math.max` becomes `Math.min` | memory holds below the larger floor, on one sample |
| M4 sample count | `next.loadCount >= rules.load.samples` becomes `>= 1` | load holds at 0.80 for 3 samples and not at 0.7999 |
| M5 stale | the `now - signal.at > rules.staleMs` check is removed | a signal is stale at more than 6 s old and fresh at exactly 6 s |
| M6 effective CPU | the quota `Math.min` becomes `Math.max` | effective CPUs take the smallest of host, cpuset and every quota level |
| M7 critical latch | the `!reconciled` critical branch is removed | critical stays until reconciliation, then still waits the 30 s release |
| M8 critical memory edge (r2) | `available < rules.critical.memoryFraction * limit` becomes `<=` | memory under 5% and disk under 1 GiB are critical, at their edges |
## Gate (Sage's slot, on CLEAR only)
- The three test files, each whole, then `node --test tests/` in packages/supervisor as the package
suite.
- Then M1 to M8, each on its named test, restored after each.
- umask 022, TMPDIR from `mktemp -d /mnt/storage/scratch/tmp/gXXXXXX`.
- The kit is /mnt/storage/scratch/morgana-r257 (slot257.sh, mutate.py). It refuses unless HEAD is the
candidate and the tree is clean, and stops after two unexpected failures (mos-0823).
- Static checks before the slot: `node --check` on every file, run from `/`. No test has run yet.
@@ -0,0 +1,90 @@
# 10-24 window, notes for draft 1
Sage, 2026-10-21T19:40Z. These are working notes, not a draft. Draft 1 is cut from them once NEW is fixed.
Draft 0 is `repin-1024-draft0.md`.
## Changes since draft 0
- **Phase 2 order (mos-1197 item 3).** Phase 2 now runs after this window's re-pin is verified, not on
10-23 before it, and Jason picks the hour. So this window's OLD is the live pin, not a phase 2 copy, and
draft 0's line 14-17 and Q2 are out of date.
- **Console flags.** They stay as they are: no `--sso` and no `--send`. `--send` goes on with `--sso` in the
phase 2 window (mos-1197 2a). P0 checks that the window's activate line carries neither flag.
- **NEW (mos-1161).** NEW is the latest code landing when draft 1 is cut. At 19:40Z that is e54e05463 (row
215). 194 hasn't landed, so P3 has no lifecycle step unless it lands with a full gate first.
- **Row 66 live check (Vikunja #454, DEFERRED 3b0aef995).** This is the first re-pin whose tree includes
2c38325ec. Add a step after the window is verified. One Claude Code managed session with a prompt that
draws a known tell, then record the operator note on the RESULT, the next-turn notice and the
`unslop.jsonl` counts. Backup first (Q173). Pi isn't run (#383).
## OLD, read 19:38Z from `stack-release.sh status mosaic-stack` (read only)
- Copy 4429a104f6d65ccf165f6c83b5d1dce3147dfde0, schema 3i, pm true, activation 20261020T220206Z-4429a104f6d6.
- Console flags: `--port 7332 --public-host mosaic.woltje.com --auth-header X-authentik-username
--allow-user jason.woltje --tester-port 7333`. P1 re-reads these on the day.
- Check pass, unit flags match the pointer, 123 commits of drift.
## Schema
`git diff --name-only 4429a104f origin/refactor` shows only `packages/bus/schema.sql` and
`migrations/3i-3j.sql` (row 234). Nothing since c771a36f4 adds a step, so the migration is still 3i to 3j.
Recheck this at the cut. Row 206 (3k-3l) hasn't landed.
## Rows NEW carries after c771a36f4 (first-parent, code paths)
| Row | Commit | Live effect to check |
|---|---|---|
| 240 | c515273db | Installer I4: root host check, pins, boot unit. This window doesn't run it. |
| 228 | 86249ac55 | ZAI_API_KEY joins the session env allowlist. The live config sets no ZAI key, so the env is unchanged. Check in P3. |
| 238 | d49b1f2bd | sso-config takes `--instance` on named instances. Default instance, `--sso` off, so there's no effect. |
| 212 | ee7b2f137 | New package packages/library. The live business has no library entry. |
| 195 | b65014647 | scripts/case1-accept.sh, root only. Not run. |
| 213 | 361ba2387 | Creative roles (roles/companion.*), persona file and block, launch-settings, bundle.mjs. Live roles are chief, pm, coder and reviewer. P3: the chief's and pm's bundles are unchanged apart from the new code path, and one pm reply. |
| 231 | 29ce1e360 | Library reader fixes. No live library. |
| 235 | b1fece6f0 | Machine grant row b: raise binding, executor, reconcile, revoke and sweep (broker, host-grant, credentials, runtime). The live business has no hosts. P3: bus start runs the grant reconcile and sweep, and they change nothing. Show the log lines. |
| 221 | 300766450 | Persona import script, with a count-only dry run. Not run. yaml 2.9 joins package-lock, so P3 runs npm ci on the copy. |
| 247 | 856c1826b | Persona import follow-up. Not run. |
| 245 | 6d5545d44 | case1-accept F9 follow-up. Not run. |
| 248 | 8a04f4aa4 | Test stubs only. |
| 249 | 9de5a57ab | broker: hostLineLeft on a grant whose install never returned. No grants live. The A22b look-alike count in draft 0 already covers `hostLineLeft`. |
| 197 | a36523713 | Test lane fixture. No live effect. |
| 66 | 2c38325ec | Managed sessions run the unslop check, runner.mjs, and stack-copy.sh COPY_PATHS adds tools/unslop-hook. P3 checks that the copy has tools/unslop-hook. The live check is above. |
| 243 | 6096b051b | Tests only. |
| 215 | e54e05463 | Library entry kind, consumer skips, M9, bare mirror, library.file and library.status. The live business has no library entry. M9 refuses any organization write that changes `projects`, so P3 checks that the chief's or pm's usual organization writes don't touch projects. |
| 250 | 1b6158ce2 | Conversation shim and K12 test. No live effect on its own. |
| 222 | 0b33f5e9d | Fetch-only library sync: library.sync bus verb, a 60 s timer per library entry with a sync user, the askpass helper, and stack-copy.sh COPY_PATHS. The live business has no library entry, so the timer starts nothing. P3: the copy has packages/library/bin/askpass with its mode, and bus start logs no `library:` line. |
| 196 | 7247d80c7 | Human boundary: SO_PEERCRED on the bus socket, the interpreter receipt in bus/peercred-starts.jsonl, and the Console's private tester socket in place of `--tester-port`. The section "If 196 lands before the cut" applies. P3: bus start writes a receipt line, and the Console starts with `--tester-socket`. |
Added 2026-10-21T20:30Z. NEW at this hour is 0b33f5e9d (row 222, landed 20:13Z), the latest code
landing. PRD 1.9 (f48725cfc) and 1.10 are doc and test changes, but `packages/tasks/requirements.json`
is in the copy, so `task.create` takes REQ-WEB-2 and REQ-INST-2 as ids after the re-pin. P3: requirements
returns 70 ids at 1.10. 196 r6 (527b9a1e6, on f48725cfc) is in its gate, so the section below may apply.
Added 2026-10-21T21:02Z. 196 landed at 7247d80c7 (20:57Z), and c3d88840c after it is doc-only. NEW at
this hour is 7247d80c7, so the section below applies.
## If 196 lands before the cut
196 retires the Console's `--tester-port` (webui cli.mjs:30, "activate explicitly with --tester-socket").
The live Console runs `--tester-port 7333`, so a NEW that carries 196 changes the Console's command line.
Draft 1 would then need:
- the activate line with `--tester-socket` and its path;
- the P2 unit diff showing only that change;
- a P3 rehearsal that starts the Console with it.
bus.tester stays Jason's. The tester path changes how the test client reaches the Console, but not who can
reach it from outside, so it stays Q173 work. I confirm that with Mos in draft 1. If 196 hasn't landed, NEW
leaves it out and none of this applies.
mos-1198 item 4: until row 253 (#1758, case2-unit.sh and case1-accept.sh to `--tester-socket`) lands, no
Case 1 or Case 2 host (.53, .54) takes a copy that carries 196. This window is dragon-lin only, so it isn't
blocked, but any .53 or .54 step that names NEW waits on 253.
Row 250 landed 1b6158ce2 at 19:42Z (conversation shim and K12 test). It has no live effect on its own, but
it is now the latest code landing if the cut were made today.
## Still open from draft 0
- The P-step evidence for rows 199, 210 and now 235 at bus start, from a P3 rehearsal run.
- Recount the restore-guard table list from NEW (T46).
- Q3 operator: Darkwing if free.
@@ -0,0 +1,168 @@
# 10-24 window: re-pin 4429a104f to 7247d80c7 (rows 190, 199, 210, 234 and on, row 196 last), with 3i-3j (draft 1)
Sage, 2026-10-21, draft 1, for mos-1145 under DECISIONS Q173. Mos checks this file at its commit before P0.
Any change to this file or to NEW after that check means Mos checks it again (mos-0849/0850). The step layout
follows `repin-4429a104f.md`. Where a step is unchanged, I name it and say what differs. Draft 0 is
`repin-1024-draft0.md`, and the working notes are in `repin-1024-draft1-prep.md`.
## Who, and what nobody does
- Q3 below names the operator: Darkwing if they're free, otherwise me. Mos verifies each step's output before
the next one, and runs `rehearse` (P3).
- Jason has no step. Q173 covers a re-pin of this test system with a migration, a backup first and a STATE
report after. Nothing is pruned.
- Nobody acts as Jason. No `--by jason`, no human shell or tmux pane, and every bus read and write carries
`--tester`. `bus.tester` stays as it is. If it isn't `true`, stop at P1.
- No pi (P19 #383). No Save in the human Console, no `mosaic name`.
- Outside reach doesn't change. There's no `--sso` and no `--send`. Both go on together in the Q175 phase 2
window, which runs after this window is verified, when Jason starts it (mos-1195, mos-1197). The proof pair
stays unset. The tunnel still forwards 7332 only (P4 and W10).
- One Console flag does change. Row 196 retires the tester's TCP port. The live Console runs
`--tester-port 7333`, and NEW refuses that flag. So W5 converts it explicitly with `--tester-socket`, and
the tester listener becomes the 0600 socket `$DATA/console/tester.sock`. Only 127.0.0.1 could reach port
7333, so nobody outside gains or loses reach. Mos ruled this Q173 work in mos-1211 item 2. The socket is
narrower than loopback TCP, and Mos's acceptance in mos-1015 (on sage-1359) already accepted losing
browser access to 7333. DECISIONS has no Jason ruling on 7333 (mos-1214 item 2).
- Under mos-1198, .53 and .54 take no copy carrying 196 until row 253 lands. This window is dragon-lin only.
## Names
- OLD, live: `4429a104f6d65ccf165f6c83b5d1dce3147dfde0`, schema 3i, pointer `pm: true`, activation
`20261020T220206Z-4429a104f6d6`. The Console runs `--port 7332 --public-host mosaic.woltje.com --auth-header
X-authentik-username --allow-user jason.woltje --tester-port 7333`, read from `status` at 19:38Z. P1
re-reads all of these on the day. I don't copy them from this file.
- NEW: `7247d80c7af7ffa70fc61557406bf491cdf3bfc7`, the row 196 landing, the latest code landing when this
draft was cut (mos-1161). It is 135 commits past OLD (`git rev-list --count 4429a104f..7247d80c7`). The commit
after it, c3d88840c, is doc-only and doesn't move NEW. If 244 or 251 land before P0, NEW stays here unless
Mos rules otherwise (Q1).
- 194 hasn't landed, so P3 has no lifecycle step (mos-1161).
- `REPO`, `P`, `WIN=agents/sage/work/window-repin-7247d80c7/` and `SNAP` are as in `repin-4429a104f.md`, with
the sha checks at P0 and before S1, S2 and any D3.
## What NEW carries over OLD
This is draft 0's table, rows 201 through 234 plus PRD 1.7 and 1.8, unchanged, together with these rows from
`git log --first-parent c771a36f4..7247d80c7`, limited to packages, scripts, roles, contracts, adapters,
skills, tools, migrations and the package files:
| Row | Commit | Live effect to check |
|---|---|---|
| 240 | c515273db | Installer I4: root host check, pins and the boot unit. This window doesn't run them. |
| 228 | 86249ac55 | ZAI_API_KEY joins the session env allowlist. The live config sets no ZAI key, so the env doesn't change. P3 checks this. |
| 238 | d49b1f2bd | sso-config takes `--instance`. Default instance with `--sso` off, so no effect. |
| 212 | ee7b2f137 | New package, packages/library. The live business has no library entry. |
| 195 | b65014647 | scripts/case1-accept.sh, root only. Not run. |
| 213 | 361ba2387 | Creative roles, persona file and block, launch-settings, bundle.mjs. P3 checks that the chief's and the pm's bundles are unchanged apart from the new code path, and gets one pm reply. |
| 231 | 29ce1e360 | Library reader fixes. No live library. |
| 235 | b1fece6f0 | Machine grant row b. The live business has no hosts. P3: bus start runs the grant reconcile and sweep, they change nothing, and the log lines are shown. |
| 221, 247 | 300766450, 856c1826b | Persona import script and follow-up. Not run. yaml 2.9 joins package-lock, and W0's npm ci covers it. |
| 245, 248, 249 | 6d5545d44, 8a04f4aa4, 9de5a57ab | case1-accept F9, test stubs, and `hostLineLeft` on a grant whose install never returned. No grants are live. The look-alike count below covers `hostLineLeft`. |
| 197 | a36523713 | Test lane fixture. No live effect. |
| 66 | 2c38325ec | Managed sessions run the unslop check. COPY_PATHS adds tools/unslop-hook, and P3 checks the copy has it. W12 is the live check. |
| 243 | 6096b051b | Tests only. |
| 215 | e54e05463 | Library entry kind, consumer skips, M9 and the bare mirror. M9 refuses an organization write that changes `projects`, so P3 checks that the chief's and the pm's usual organization writes don't touch it. |
| 250 | 1b6158ce2 | Conversation shim and K12 test. No live effect on its own. |
| 222 | 0b33f5e9d | library.sync verb, a 60 s timer per library entry, and the askpass helper in COPY_PATHS. No library entry, so the timer starts nothing. P3: the copy has `packages/library/bin/askpass` with its mode, and bus start logs no `library:` line. |
| PRD 1.9, 1.10 | f48725cfc, add0f1aae | `packages/tasks/requirements.json` is in the copy. W9: the pm's requirements read answers version 1.10 with 70 ids. |
| 196 | 7247d80c7 | The human boundary. See the next section. |
## Row 196 in this window
What the code does (`scripts/stack-release.sh:791-845`, `docs/TOOLS.md` "Console tester listener"):
- An inherited `--tester-port` with no `--tester-socket` refuses `tester-transport-migration` before anything
stops. So `--tester-socket` goes on W3 and W5, and the plan's drop line must read
`--tester-port 7333 (replaced by private Unix socket)`.
- Before the stop, activate checks the socket path: `$DATA/console/tester.sock` must fit 107 bytes, and
`console/` must be absent or a directory with no symlink, owned by the instance's uid, with no group or
other bits. A failure refuses `tester-socket-path` before the stop. P1 reads `ls -ld $DATA/console`
(expected absent).
- Activate preflights the pinned root-owned system Python, records its identity, and checks the bus's fresh
start receipt. Each bus start appends one line to `$DATA/bus/peercred-starts.jsonl`. P3's rehearsal must show
the preflight passing on dragon-lin, and W9 reads the one new receipt line.
- `rehearse` starts NEW's Console with `--tester-socket` under its scratch data root when the copy carries
the socket marker (`stack-release.sh:2153-2155`). It doesn't run activate's path check from line 815, so
that check first runs live at W3. P1's `ls -ld $DATA/console` plus the path length are its only preview.
If the rehearsal Console fails to start on the socket, P3 stops and the log goes to Mos.
- Direct browser access to the tester is gone, with Mos's acceptance in mos-1015. W10's browser check on
127.0.0.1:7333 becomes `node packages/cli/src/tester-http.mjs GET /api/config` and `GET /api/bus/agents`, run from
the NEW copy against the live data root. Each must answer 200 with the same tester flags OLD's 7333 gave.
`mosaic ... --tester` still uses broker.sock.
Rollback is where 196 matters most. Without `--sso`, rollback takes the Console flags from the pointer, and
after W5 the pointer carries `--tester-socket`. OLD's Console doesn't support the socket. So rollback to OLD
drops the tester listener, the plan prints `--tester-socket (tester disabled; no TCP fallback)`, and port 7333
doesn't come back. The test client then has no tester listener until a new activate. Q4 asks Mos how to
handle that.
## Schema and backups
- `SCHEMA_VERSION` is 3i at OLD and 3j at NEW (`packages/bus/src/store.mjs:17`). The only step is
`packages/bus/src/migrations/3i-3j.sql` (row 234, tables `hosts` and `host_grants`). Nothing after c771a36f4
changes schema.sql or adds a step. `--plan` must say `migration: 3i -> 3j`.
- Restore guard, recounted at NEW (T46, `packages/cli/tests/stack-release.test.mjs:913`). COUNTED is the
same 11 tables (`stack-release.sh:404`). The untimed list has six: `host_grants`, `hosts`, `meta`,
`message_threads`, `sqlite_sequence` and `task_comment_cursors`. Each write to `hosts` or `host_grants` goes
with a `grant.*` event, which is counted. A D3 snapshot still covers them, as draft 0 says.
- W5 takes the stopped store backup every activate takes (SHA256SUMS, SCHEMA 3i, STAMP) plus the rollback
set. Before `--migrate` writes, I check that SCHEMA reads 3i and record the backup's SHA256SUMS hash. W0b
copies the business file, as last time.
- Grant look-alikes (row 234 A22b, gizmoduck-0107): P3, and again just before W5, on the backup copy. Count the
`action.allowed` events whose body.action or body.operation starts with `grant.`, or whose body has
`hostLineLeft`. Expect 0. Any nonzero count stops the window until I've read those rows.
- Rollback after the migration isn't the plain rollback. OLD is 3i and the store is 3j, so going back needs
`--restore <W5 activation>`, which loses the events written after W5. Mos decides any restore past W5.
## Before the window (nothing stopped)
| # | Who | Step |
|---|-----|------|
| P0 | Mos | The `$P` sha check, and this file's commit. |
| P1 | Sage | `status`: pointer, activation, schema 3i, Console flags, unit flags match. The business file sha256 and `business-check.mjs` LOAD_OK for OLD now and NEW after W0. `ls -ld $DATA/console` (expected absent). `bus.tester` is `true`. |
| W0 | Sage | `scripts/stack-copy.sh copy $NEW` in a gap between gate runs. The package-lock diff is empty. The copy has `tools/unslop-hook` and `packages/library/bin/askpass`, with the mode NEW's tree gives it. |
| P2 | Sage | `console-check.sh $NEW` on a stub Console on a scratch port, once without and once with `--tester-socket` under a scratch data root. CONSOLE_PASS both. Render the Console unit for NEW from REPO's `webui-service.sh` and diff it against the live unit. The only difference allowed is `--tester-port 7333` becoming `--tester-socket`. Diff the bus unit too. It must match apart from the copy path. |
| P3 | Mos | `stack-release.sh rehearse $NEW mosaic-stack --backup <latest live activation> --port <scratch>` with `--migrate` on the backup copy. It must show the 3j step, row counts before and after, the interpreter preflight passing, a receipt line, the grant reconcile and sweep changing nothing, no `library:` line, no publish or land decision open (rows 241, 242), the look-alike count 0, and one pm reply. As in mos-1107, Mos also starts NEW's Console by hand with the pointer's flags, with `--tester-socket` in place of `--tester-port`, and reads healthz, `/api/config` and the two tester-http reads. |
| P4 | Sage | The tunnel read. |
| P5 | Sage | The four service scripts are held at NEW (`git diff --quiet $NEW HEAD --` on them, and empty porcelain). If 244 or 251 lands and touches one of them, P5 fails and the window waits for Mos. |
| P6 | Sage | The 7332 matrix (a)-(g) on OLD, saved as `$WIN/matrix-old.txt`, plus the two tester reads on 7333 with curl, for W10 to compare against. |
## The window
| # | Who | Step |
|---|-----|------|
| W0b | Sage | The business file copy. |
| W1 | Sage | Idle check, the start stamp from `date -u`, then S1 with `$SNAP --held`. Open pm tasks from row 42: pm finishes or parks them before W5, or I record which piece crosses the window. |
| W2 | Sage | `status`: pointer on OLD, `pm: true`, 3i, unit flags match. |
| W3 | Sage | `stack-release.sh activate $NEW mosaic-stack --pm --migrate --tester-socket --plan`. It must show OLD to NEW, `migration: 3i -> 3j`, the backup line, the drop line for `--tester-port 7333`, and a Console line with OLD's other flags exactly: no `--send`, no `--sso`, no proof pair. Any other difference stops here. |
| W4 | Sage | The unit reads, as in the 177-185 plan. |
| W5 | Sage, on Mos's go | The same command without `--plan`. Exit 0, `result ok`, and every gate check passes, `pm_launched` included. Record the activation id, the backup path, STAMP and the backup SHA256SUMS sha256. Then `status`. |
| W9 | Sage, after Mos verifies W5 | The 177-185 W9 list, plus: the webui journal since the W5 stamp (with `UTC`) has no `Sign-in:` line; `/proc/<Console pid>/cwd` names the NEW copy; `$DATA/console/tester.sock` is 0600 in a 0700 `console/`; `peercred-starts.jsonl` gained one line for this start; the bus journal has the grant reconcile and sweep lines and no `library:` line; the pm's requirements read gives 1.10 with 70 ids. |
| W10 | Sage | The matrix again, as `$WIN/matrix-new.txt`, identical to `matrix-old.txt` apart from the lines the pin changes, each one named. The two tester reads through `tester-http.mjs` match P6's. Port 7333 has no listener (`ss -ltn`). Then P4 again. A write without a signed-in session must still be refused. That's Console POST through the main port, with no session. This is the check mos-1195 asks for before phase 2 is done, so I run it here too, as a baseline. |
| W11 | Sage | STATE report in `$WIN/STATE.md`, posted on P19 386, 412 and 435 (J2, mos-1215), and on the Gitea issues for 190, 199, 210, 234 and 196. |
| W12 | Sage, after W11 | Row 66's live check (Vikunja #454). One Claude Code managed session with a prompt that draws a known tell, then the operator note on the RESULT, the next-turn notice and the `unslop.jsonl` counts. Backup first (Q173). Pi isn't run (#383). |
## Stop conditions and rollback
The same as the 177-185 plan. Two failures in one window: stop and report (mos-0823).
Rollback before W5 writes is `rollback --plan`, then the real run. After the migration it needs `--restore
<W5 activation>`, with Mos deciding. Both drop the tester listener (see Row 196 above). Keep every copy.
Prune nothing.
## Questions for the plan check, answered (mos-1214 item 3)
- Q1: NEW stays 7247d80c7 unless a landing fixes something this window needs.
- Q2: mos-1211 item 2 rules the `--tester-port 7333` to `--tester-socket` change Q173 work.
- Q3: Darkwing operates if free, and I check. Otherwise I operate.
- Q4: after a rollback, the tester listener stays off for the window's length, and the tester goes through
broker.sock. A same-copy re-activation of OLD with `--tester-port 7333` goes to Mos only if a rollback
holds past the window day.
## What Sage ran for this plan
Read only, 2026-10-21 between 20:40Z and 21:20Z: the path-limited first-parent log c771a36f4..7247d80c7,
`git rev-list --count`, `SCHEMA_VERSION` at both ends, `git diff --name-only` on the schema and migrations,
`stack-release.sh` lines 40-47, 125-158, 404, 791-845, 886-919 and 2140-2157, T46 in
`stack-release.test.mjs:913`, `packages/cli/src/tester-http.mjs`, and TOOLS.md:1274-1310 on the tester
listener. Nothing ran against
live. OLD's values come from the 19:38Z `status` read, and P1 reads them again.
+12
View File
@@ -1432,3 +1432,15 @@ are never rewritten or removed; corrections are new entries.
2026-10-21T19:46:12Z | Dewey (Opus 5.5, 84fb346c) | row 222 r1 static review (megavolt-0095), c9aa9c2ee, manifest c2121b55 | APPROVE, comment 30138 on #1723, queue rev 2966; rulings: createObject=rename accepted, 5 s human timeout deferred with STATUS codes in the follow-up, credential.helper reset argv-only accepted; notes N1-N5; no tests run
2026-10-21T19:49:37Z | Sage (Opus 5.5, 1ef1e4f8) | row 250 landing, row 196 r5 grant (mos-1198) and resume (mos-1200), F27 row 253 | 250 LANDED 1b6158ce2 (patch-id 29786caa, Filbert rev 2957, Gizmoduck slot green), #1755 closed c30139, Vikunja 450; 196 r5 91e3696ef hunk checked by Duckworth, webui 304/0; my queue add tripped Gander's busy check, not a stop (mos-1200), resume on 05e940117; row 253 #1758 brief 0cdead66c
2026-10-21T20:46:03Z | Sage (Opus 5.5, 1ef1e4f8) | row 222 landing, PRD 1.9 and 1.10, Pocket ID design row 255, row 254 brief, 196 r6 grants | 222 LANDED 0b33f5e9d (megavolt-0098 gate, Dewey rev 2966, patch-id edb60b4f), #1723 closed c30147, done rev 2972; follow-up row 254 (#1759, after 196); PRD 1.9 f48725cfc (REQ-WEB-2, REQ-DEC-3 amended, mos-1202); 458 brief 7d08237e1 (#1760), row 255 rev 2976; PRD 1.10 add0f1aae REQ-INST-2 post-v1 (mos-1204, tasks 97/0, bus req 3/0); Vikunja 458 c478, 416 c479; queue-commit 21def2850 revs 2968-2976; 196 r6 gate gander-0066 2334/0, 38/38 killed; 244 granted sage-2147; 196 r6 resume granted sage-2145 after self-tee busy refusal (mos-1205). Miss: landed 222 before checking 196's manifest; 196 carried results voided. Class rule now (mos-1204, replaces the mos-1200 interface grep): before any landing push, compare every in-review and in-progress row's manifest against the landing's paths.
2026-10-21T21:14Z | Sage (claude-opus-5-5, 1ef1e4f8) | row 196 landed 7247d80c7 and doc commit c3d88840c (#1694 closed, comment 30159); rows 253 and 254 briefed and launched (Gander #1758, Megavolt #1759); Honker 194 b4 rebase ruling (creative delete stays refused, Agents page hides Delete on the server predicate, actor-threaded delete deferred); queue revs 2977-2994 committed abb352d27 incl. Morgana 244, Gander 196 r6, Duckworth 196 m2, Darkwing 251 r2; 10-24 window draft 1 written (NEW 7247d80c7, --tester-socket, Mos mos-1211 agrees Q173) | landed and launched; draft 1 to Mos next
2026-10-21T16:34:44Z | Huey (gpt-6.1-sol) | mos-1183, Q198/Q199 system-manager design, Vikunja 452, huey/system-manager-design on 6c75887b0 | design and six proposed slices documented; pinned citations and docs checks pass; no build, suite, live action or paid call; Gitea/queue capture remains Sage-owned
2026-10-21T18:45:47Z | Huey (gpt-6.1-sol) | mos-1194, Q198 design r2, scrooge-0001 findings 1–8, Vikunja 452 | all findings addressed in design and slice acceptance criteria; 30 citation references checked, no suites/live changes/paid calls; Mos routes r2 back to Scrooge
2026-10-21T19:56:11Z | Huey (gpt-6.1-sol) | mos-1201/1201a, Q198 design r3, scrooge-0002 N1–N3 | three doc notes applied; Q4 ready for Mos’s combined owner approval question; no builds, suites, live changes or paid calls
2026-10-21T20:19:53Z | Huey (gpt-6.1-sol) | mos-1203, Q198 design r4, DECISIONS Q204 | Q1–Q3 approved quotes recorded, cap settings interpretation labelled, Q4 pending and P1 unchanged; doc-only checks, no builds/live changes/paid calls
2026-10-21T20:48:49Z | Huey (gpt-6.1-sol) | mos-1206, Q198 r5 protected mode/outage recovery, Vikunja 452 | Q205/Q206 recorded with labelled readings and probe/resume acceptance proposals; Q4 superseded, Q22 pending; doc checks only, no builds/suites/real probes/live changes/paid calls; Scrooge re-review via Mos
2026-10-21T21:04:37Z | Huey (gpt-6.1-sol) | mos-1208/mos-1210, Q198 r6, Q207 and scrooge-0003 S-F1–S-F7 | central fallback/parallel probes, request failure and recovery release fixes, quota/probe budgets and credential isolation documented; doc checks only, no builds/suites/probes/live changes/paid calls; Mos routes r6 to Scrooge
2026-10-21T21:39:25Z | Huey (gpt-6.1-sol) | mos-1222, Q198 r6.1, scrooge-0004 N1–N3 | small doc touch-up verified, no runtime changes/builds/tests/probes/paid calls; Scrooge confirms three items before Sage handoff
2026-10-21T21:12:18Z | Della (GPT-6.1-Sol, mos-1213) | Vikunja 460 independent post-v1 email plan, della/email-plan from c3d88840c | 341-line proposal, 13 post-v1 rows, six Jason questions, 42 pinned citation anchors verified. Canonical queue next della: nothing. Planning only, no live mail/service/MVP/queue change; phase B/C with Mos, no Ludwig plan read. Commit and branch push authorized by dispatch.
2026-10-21T21:37:07Z | Della (GPT-6.1-Sol, mos-1220) | Vikunja 460 phase B email cross-review, della/email-plan | Ludwig plan 4a27b73ac and review 143415779 read in full. Cross-review has ten findings and eleven merged questions; original Della plan unchanged. Canonical queue next della: nothing. Static verification only; review push followed by direct della-0002 handoff to both threads under Q210. Phase C base choice remains with Mos.
2026-10-21T21:43:02Z | Della (GPT-6.1-Sol, mos-1223) | Vikunja 460 phase C merged email plan, della/email-plan | Mos-selected base plus Ludwig grafts; original plans/reviews unchanged. Sixteen post-v1 rows, eight READY and eight WAIT/optional; eleven Jason questions. Canonical queue next della: nothing. Docs-only checks. Commit/push then immediate della-0003 handoff to Ludwig/Mos; Sage landing/queueing follows approval.
2026-10-21T21:45:20Z | Della (GPT-6.1-Sol, mos-1223 / ludwig-0004) | merged email plan focused review fixes | External content-key/expiry location and restore tests specified; fake registry work READY, production registry gated on PRD approval/Q4. Merged plan 399 lines, original plans/reviews unchanged. Commit/push and focused Ludwig review before Sage handoff.
@@ -0,0 +1,50 @@
# Brief: the human deletes a creative instance (REQ-COS-3, after row 194)
Sage, 2026-10-21, for mos-1214 item 5 and mos-1215 item 3. Owner Honker, reviewer Webby, gate owner Sage.
Chained after row 194: `deleteRefusal` and the delete path land in 194, so this row builds on 194's
landing. The row has to land before the v1 tag for 10-27. If it slips past that date, Sage tells Mos before
194's v1 tag (mos-1215).
## Why
REQ-COS-3 (`docs/prd/mosaic-stack.md:342`) says "Only the user deletes an instance." Creative entries are
instances: they sit in the business file's `instances`, and only the human's `instances.set` adds or changes
one (C2-08 M10, row 213). Row 213 made every creative definition END_PROTECTED (`vocabulary.mjs:195`,
`END_PROTECTED = THREAD_DEFINITIONS`). In row 194, `deleteRefusal` refuses END_PROTECTED for every actor
(`organization-definition-refused`), so nobody can delete a creative instance. The human can only delist one
with `launch: false`. This row lets the human delete one, which matches END_PROTECTED's intent: a protected
end goes to the human (sage-0525).
## What this row does
1. **Human-only delete of a creative instance.** Pass the actor into `deleteRefusal`, and through every
caller: the broker's check at the verb, the launcher pass's `set op delete`, the CLI and the Console.
- A creative definition (`CREATIVE_DEFINITIONS`) whose actor is the business file's `human` passes this
check and goes on to the remaining checks (references, kept, credentials).
- Any other actor still gets a refusal. Use the code `creativeHumanOnly` gives
(`organization-creative-human-only`), so the refusal says why.
- Every other END_PROTECTED definition (the thread definitions that aren't creative, such as the chief
of staff) still refuses `organization-definition-refused` for every actor, the human included.
2. **Fire stays as it is.** `role.fire` and `instance.fire` keep refusing END_PROTECTED for every actor.
REQ-COS-3's fire is the chief's action, and the chief can never hire a creative instance.
3. **Agents page.** Row 194 hides Delete on `endProtected`. Offer Delete on a creative instance only on the
human's page, from the same server predicate, and never on the tester listener. Add one browser test for
each side.
4. **Persona file.** Say in BUILD.md what happens to `<configDir>/personas/<business>/<instance>.md` on
delete. The smallest choice is to leave it in place and record that. If you remove it, open it with
`O_NOFOLLOW` as row 213 reads it, and add a test.
5. **DEFERRED.** Close row 194's DEFERRED entry for human delete of a creative entry, and name this row in
it.
## Boundaries
- No change to who may add or change a creative instance (M10), or to `role.hire`'s bounds.
- No change to fire, veto or `launch.keep`.
- No test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units, a slot file, .53 or .54.
## Gate
Webby approves the manifest. On Sage's slot, every touched test file passes whole, along with the
business, bus, cli and webui suites whole and every `scripts/test-*.sh`. Two mutants must be killed: one
that lets a non-human actor delete a creative instance, and one that lets the human delete a non-creative
END_PROTECTED instance.
+341
View File
@@ -0,0 +1,341 @@
# Post-v1 email processing and monitoring plan (Della)
**Status:** proposal for Mos phase A, Vikunja 460, mos-1213. Planning only.
**Goal:** receive mail for named agents and users through Stack-owned connections,
then expose authorized reading, routing and drafting equally to every harness.
**Boundary:** every build row below is **post-v1**. None is required for the
2026-10-27 MVP. This document changes neither PRD 1.10 nor the live Stack.
No service, mailbox connection, live-mail read, account, DNS change or spend is authorized.
R21 binds the specialist: **“The email specialist reads and drafts, never sends.”**
## Evidence and reuse
Citations are pinned, not claims about deployed behavior. Prefixes below identify
the repository and commit for every `file:line` citation. Both prior clones were
inspected read-only. No prior application or test suite was executed.
| Prefix | Pinned evidence |
| --- | --- |
| S | Stack `c3d88840c5b6ffa62d2a6a4aa4385d388234d777`, this worktree |
| MP | `mail-processor` `95b02989f4d4276dded00c90ffd74ebb7d03ae8b` |
| EP | `email-processor` `7a6492190cd376058e29a04fd0593e86bae1a8e5` |
| SM | `origin/huey/system-manager-design` at `2ee68cd69337bb933a8fabeb04bcd71835170171`, Q198 r6, superseding r5 `fd44f7c60` |
**F1 Existing mail code:** recursive comparison found identical `src/` trees in MP
and EP. Reuse once, not as two running products. Both implement normalized messages
and a connector boundary (`src/email_processor/connectors/base.py:8,30`), M365
pagination/rate-limit handling (`connectors/m365.py:81,138`, under that same source
directory), ordered AND/OR rules (`src/email_processor/rule_engine.py:81,124`), and
bounded preview/cold-start safety (`src/email_processor/poller.py:101,122,152`).
Port these concepts and selected synthetic tests into Stack-native modules after
dependency/license review. Neither copy proves IMAP or POP3 support: connector
selection handles only M365 (`src/email_processor/poller.py:48`).
**F2 Prior intent:** EP `docs/PRD.md:25,39` describes a single-organization M365
application, not the Stack add-on. MP `docs/PRD.md:26,66,204,235` specifies generic
Graph/IMAP/POP3 drivers, separated intake/execution, fail-closed activation and
atomic staging/checkpoints. Those are useful design requirements, not implemented
features. Adopt the safety properties and conformance cases, not its independent
tenant/profile product, bootstrap authority or mandatory standalone web setup.
**F3 Drop unsafe coupling:** in both repos, `src/email_processor/poller.py:186,200,301`
checks for duplicates before effects and advances a timestamp to wall-clock now.
`src/email_processor/models.py:266` has indexes, not a unique mailbox/message key.
Replace these with durable provider cursors, unique identities and transactional
delivery. Drop connector caches keyed just by mailbox name (`poller.py:48`), direct
GLPI/ntfy effects (`action_executor.py:30,69,159`) and source category writes
(`connectors/m365.py:170`, `poller.py:359`). Drop direct model calls and content logs
(`classifiers/llm.py:79,105,109,123`), replacing decisions through Q207.
Do not import deployment manifests, credential settings, existing accounts or data.
**F4 Stack enforcement:** S `packages/harness/src/tools.mjs:1` already maps typed
tools through the runner socket with broker checks. Its MCP bridge carries no
capability (`packages/harness/src/mcp-server.mjs:1,34`). Reuse that path for all
harnesses, rather than giving each one mailbox credentials or a monitoring plugin.
S `packages/bus/src/broker.mjs:690,701,1601,1666` supplies human-approved grant
consumption and action authority. `packages/bus/src/credentials.mjs:142,358,379`
supplies scoped references and protected use. Mail kinds and grants do **not** exist:
S `packages/business/src/vocabulary.mjs:109,119` accepts Gitea, Vikunja and host kinds.
Mail requires reviewed extensions, not relabelling mail credentials as an existing kind.
**F5 Product constraints:** S `docs/prd/mosaic-stack.md:432,454,477,496` defines
chief-raised grants, exact human approval, one-time consumption and immediate local
revocation. Its `:507,542,582,607` covers decision evidence, thread binding and hard
waits. PRD “inbox” is the decision inbox, not email. Mail needs a separately approved
post-v1 PRD addendum. S `packages/plugins/src/kinds.mjs:9,32` has no mail-source kind.
Follow REQ-PLUG-1/2 (S PRD `:876,910`) for provider adapters.
**F6 Monitoring precedent:** S `tools/agent-watch/jarvis-email-watch-liveness.sh:8,26,53`
checks timer state, failed execution and staleness. Reuse the distinction between
alive, failed and unknown, not its legacy unit names or fleet wiring. SM
`docs/plans/2026-10-21_q198-system-manager.md:262,291,304,326` supplies protected mode,
independent probes and controlled resume. It is a design dependency, not shipped proof.
## Architecture and sources
**D1 One owner per instance.** Add an optional supervised `packages/mail/` service,
with a single leased owner per configured source. It owns connection pools, OAuth
refresh, scheduling, cursors and intake. A harness can stop, restart or change without
losing monitoring. Multiple accounts are isolated by instance/business/source ID.
“One service” does not mean one global credential, mailbox or shared authorization.
Default installation is disabled and opens no connection.
Use Node modules aligned with the Stack, adapting the Python design rather than
embedding both applications and their separate databases/authentication/UI. A Python
sidecar would reuse more syntax but duplicate operational boundaries. Harness-native
connectors would repeat connection state and fail Jason's cross-harness requirement.
**D2 Source contract.** Propose a reviewed `mail-source` plugin kind, list-selected,
with `descriptor()`, `scan({source,cursor,limits,signal})`,
`fetch({source,item,part,limits,signal})` and `health({source,signal})` exports.
`scan` returns bounded observations, continuation, terminal cursor and identity epoch.
Adapters receive protected credential-use callbacks, never agent-provided secret paths.
Core owns grants, leases, persistence, rule evaluation and audit. No adapter sends,
deletes, labels, moves or marks messages read. Use the existing channel kind only
where its dispatch contract fits presentation, not as a substitute polling interface.
| Source | Proposed implementation and boundary |
| --- | --- |
| Microsoft 365 | Graph read-only access, per-folder delta/continuations and immutable message IDs. Replace timestamp polling. Restrict application rights to named mailboxes and verify effective scope, including any additive Entra permissions. |
| IMAP | TLS with certificate verification, OAuth where supported, otherwise a separately approved service/app credential. Read-only selection and `BODY.PEEK`; UIDVALIDITY plus UID, never sequence number. IDLE is a wake-up hint with periodic reconciliation. |
| Other providers | Prefer a conforming IMAP source initially. Gmail API and JMAP are later adapters only when a named need justifies them. No new provider account is implied. |
| POP3 | Deferred optional row. Require stable UIDL, TLS and read-only retrieval. No `DELE`. Reject automatic activation without safe identity/reconciliation. |
Graph delta is folder scoped and cursors are opaque ([Microsoft delta documentation](https://learn.microsoft.com/en-us/graph/delta-query-messages)).
Immutable IDs survive moves within a mailbox, not archive-mailbox moves or reimports
([Microsoft ID documentation](https://learn.microsoft.com/en-us/graph/outlook-immutable-id)).
Mailbox-scoped Application RBAC and unscoped Entra grants are independent
([Microsoft RBAC documentation](https://learn.microsoft.com/en-us/exchange/permissions-exo/application-rbac)).
IMAP identity, read-only selection and PEEK semantics follow [RFC 9051](https://www.rfc-editor.org/rfc/rfc9051.html).
These are protocol references checked 2026-10-21, not evidence of installed support.
**D3 Addressing.** Version a route table mapping trusted delivery evidence to a stable
Stack user or role-instance ID, never a display name or a newly invented role.
Aliases on an approved ingress mailbox are the recommended inexpensive start.
Plus-addresses are acceptable only after fixtures and a later authorized provider test
prove the exact recipient survives delivery. They are not a security boundary.
Dedicated mailboxes provide stronger provider-side separation for confidential users
or agents, at the cost of account management and possible licenses.
Bind routes to source identity plus provider-controlled original-recipient evidence.
`To`/`Cc`, forwarded headers and arbitrary `Delivered-To` headers are insufficient
unless the enrolled delivery path establishes their provenance. Bcc or ambiguous
alias delivery goes to restricted operator review, never guessed routing. Unknown
addresses do not create agents. Multiple recipients produce independently authorized
deliveries without revealing other recipients' mail. Jason approves domains, names,
aliases, mailboxes and each provider-side change before provisioning. No SMTP/MX
server, catch-all domain or public webhook is proposed for the initial increment.
## Processing, data and retention
**D4 Intake pipeline:** approved source → bounded discovery → bounded parse → durable
observation/dedup → versioned deterministic rules → authorized delivery outbox →
Stack notification/work request. Provider cursor advancement and durable observations
commit together under the source lease/generation. Outbox delivery has a stable key
and a recipient-side unique receipt, so a crash after delivery but before acknowledgment
does not create a second task/message. Never promise exactly-once external effects.
Default candidates: poll every 60 seconds, at most 50 observations and 10 MiB fetched
per slice, fair scheduling between sources. Persist continuation when the slice fills.
Cap each source at 1,000 pending deliveries and 100 MiB of retained content initially.
Persist quota accounting across restarts and slices. Reaching a quota holds new intake
and raises an alert, while read-only health checks continue.
First activation establishes a from-now baseline without actions on old mail. Historical
import is a separate approved time/count window, previewed before activation. Cap
violations, disk-full and unavailable audit storage hold work without advancing past
unstaged mail. These limits are proposed settings to validate, not measured capacities.
Provider identity is scoped to business/source/account: Graph immutable ID, IMAP
folder/UIDVALIDITY/UID or POP3 UIDL. RFC Message-ID, thread ID and content similarity
are secondary correlation signals, never sole proof of uniqueness. Preserve distinct
messages with missing or reused Message-ID. An IMAP move, epoch reset, expired delta
cursor, archive move or reimport enters bounded reconciliation. Record aliases only
with sufficient identity evidence. Otherwise hold possible duplicate actions for review.
An edit is a new observation version, not automatic permission to rerun prior actions.
Preserve dedup tombstones beyond content expiry. Replay needs a new authorized intent,
parent link and current grants. A rule edit never retroactively executes preview work.
**D5 Parsing and rules.** Preserve provenance for normalized sender, recipients,
timestamp, subject, text, thread references and attachment descriptors. Bound MIME
depth to 10, parts to 100 and normalized text to 256 KiB. Decode safely, flag malformed
or truncated content and quarantine failures. Prefer text, sanitize HTML for display,
disable remote images/URLs and never execute active content. Rules use finite allowlisted
fields/operators, stable priority and first-match by default. Reject executable rules
and unbounded regex. Record rule/version and routing reason. Unmatched mail is held
for its authorized owner, not dropped or copied fleet-wide. Routing a user notification
does not launch an agent. Agent wake-up is a separate admission-controlled work request.
Optional classification and any mail-triggered decision go through the Stack's one
decision module under DECISIONS Q207. The mail service neither selects nor falls back
to a model locally. Core policy still determines permitted actions and human hard waits.
Use minimal, explicitly permitted content and an allowlisted result schema. A model
result is advisory data, never a grant. Missing model service leaves deterministic
intake working and model-dependent work held. Summaries/drafts run as ordinary governed
agent work, not an extra mail-service model client.
**D6 Attachments.** Initially enumerate metadata only. An authorized explicit fetch
quarantines bytes under an opaque ID, with a 10 MiB per-part and per-message total cap.
Never trust filenames as paths, unpack archives, execute macros or follow embedded
links. Content extraction requires isolated no-network processing, resource bounds,
scanner success and a separate read right. Failed, encrypted or unsupported files stay
quarantined. A clean scan does not make attachment instructions trustworthy.
**D7 Storage.** Proposed `<dataRoot>/mail/<business>/` contains encrypted content blobs,
SQLite observation/outbox state, encrypted cursors, local drafts and append-only event evidence.
No mail content enters git or images. Service-only directories are 0700, files 0600.
Use scoped keyed fingerprints for private dedup fields. Separate content keys from
provider slots. Keep tokens, delta URLs and authentication errors out of ordinary logs.
Default durable records hold opaque IDs, state, versions and counts, not bodies,
subjects, recipient lists or attachment names. Content access is explicit and audited.
Recommended content policy for an enabled pilot: encrypted bodies and local drafts
for 30 days, explicitly fetched attachments for 7 days, minimal metadata/tombstones for
365 days. Without Jason's approved content/retention policy, persist metadata only and
show that later content fetch requires the source online. Expired content returns an
explicit unavailable result. Define backup/key expiry and restore suppression together:
restoring an old backup must not resurrect expired content or redeliver old work.
Local pruning needs the Stack's `data.delete` approval path or an explicitly approved
future retention authority. Do not silently grant automatic deletion. Write receipts,
preserve legal holds and do not rewrite `<dataRoot>/runs/` or existing audit evidence.
## Access and security
**D8 Identical access contract.** Proposed tools are `mail_list(source?,cursor?,limit)`,
`mail_get(id,view)`, `mail_attachment_get(id,part)`, `mail_route(id,target,expect)`,
`mail_ack(id,expect)`, `mail_draft_create(id,text)` and `mail_health(source?)`.
All map to typed broker verbs via the existing tool socket/MCP bridge. Local CLI and
authenticated Console use the same handlers. List is paginated (default 20, max 100).
`ack` changes Stack-local state only. Drafts are local unsent records, not Graph drafts.
Health returns only authorized source metadata. No arbitrary URL, shell, source secret
path, provider query language or send tool is accepted.
Separate rights for metadata, body, attachment, draft and routing per business,
source, address binding and named principal. Recheck current role/grant on every read,
notification and action, including caches and derived summaries. Revoke immediately
fences new uses, invalidates fetch handles and cancels owned in-flight reads before
returning content. Agents receive only mail explicitly routed to their grant scope.
User mail requires that user's or explicitly delegated access. An email specialist is
not implicitly allowed to read every user's mail. Cross-business IDs return no data.
New mail access grants follow the existing chief → `credential.mint` → authenticated
human Approve chain, with exact target, rights and expiry bound once. Add the mail
grant method and action routing only through a Jason-approved PRD version and reviewed
vocabulary/role changes. Until then new actions remain hard waits. Within-role mail
access can become routine only inside those approved grants. Manual-mode actions still
need per-use approval. A hand-edited grant or approving email creates no authority.
**D9 Credentials and hostile input.** Distinguish a source service principal's
provider connection grant from an agent's mediated mail-access grant. Recommend
dedicated read-only ingress credentials per source/business, held by protected Stack
code and never shared with agents. This system-service ownership is a new approved
extension, not an exception silently taken to REQ-CRED-1. Configuration names only
an approved absolute slot path, for example
`<dataRoot>/credentials/mail/<business>/<source>/oauth`; no value or partial value is
read by a planner, returned through tools or embedded in a plan. Missing/expired/revoked
slots fail closed. Validate owned non-symlink files, rotation and revocation behavior.
Treat mail, headers, filenames, OCR and quoted replies as untrusted external data.
Sender addresses, even authenticated messages, cannot approve decisions, change policy,
mint grants or impersonate the CLI/Console human. Delimit model input and strip tool
authority, but rely on broker denial, sandboxing and least privilege for enforcement.
An email asking an agent to fetch secrets, forward documents or run commands must not
gain those rights. External action proposals use existing approval paths and provenance.
No automatic link fetches. Provider endpoints are enrolled allowlists with TLS and
redirect checks, not values selected from mail content. Logs use reason codes and IDs.
**D10 Sending.** Out of scope: SMTP submission, Graph send, forwarding, auto-replies,
DSNs generated by this service and emailed digests. Block them in adapter interfaces,
credentials and network policy. R21 stays intact even if a future separate sender role
is approved. Human export of an unsent draft is not authorization for Stack delivery.
## Monitoring and system-manager integration
**D11 Health:** report desired/actual connection state, last successful scan and durable
cursor commit, lag, backlog, lease owner, retry-after, credential status, quarantine,
outbox age and disk pressure. Never report “healthy” merely because the process lives.
Candidate stale threshold: three missed scan intervals. Alert once per incident, on
severity change and recovery through existing internal notifications/Console, not mail.
Show alert delivery failure locally. Alerts include IDs and reason codes, no content.
Register dependency scopes `mail-source/<business>/<source>` with Q198's manager.
Network/provider outages pause only affected intake and dependent fetch/dispatch.
Independent sources and authorized stored-mail reads can continue. Disk/audit failure
halts new intake/effects. Auth/config denial stays held for correction, never retries
under another credential. Malformed messages quarantine individually, not as outages.
Apply Q205–Q207 through protected mode, not the human kill switch: mechanical source
probes run independently at 60, 120, 180 seconds, increasing by 60 to a 600-second
ceiling, indefinitely, while respecting longer Retry-After. No JEV/model availability
gate and no model call for a mail health check. Mail health proves TLS/auth and scoped
source access without fetching message bodies. One lease owns retries, so adapter and
manager loops cannot multiply them. Disabled sources make no probes.
On recovery reconcile cursors/uncertain delivery receipts, pass one synthetic pipeline
canary, then resume eligible work in manager-controlled batches without approval per
session. Never send test mail or use a live message as a synthetic canary. Uncertain
external effects stay fenced. Human kill remains human-only to clear. These mail
extensions to SM D13–D15 need contract review and tests before operational reliance.
## Ordered build rows (all post-v1, not current queue edits)
Each row is a separately reviewable deliverable. Write failing synthetic cases first,
implement only that row, then run its named tests and affected existing suites. Commands
below name proposed tests. They are not tests run during this planning assignment.
Shared touch points include `packages/{bus,business,harness,plugins,cli,webui}/`.
No rollout uses an implementation candidate until normal review and re-pin gates pass.
| Row | Deliverable / files | Acceptance evidence |
| --- | --- | --- |
| E01 **post-v1** | PRD addendum and source/data schemas, `docs/prd/`, `packages/mail/schemas/`. Resolve Q1–Q6 before activating corresponding behavior. | Schema fixtures reject unknown fields, missing scopes and secret values. Disabled add-on makes zero connections and v1 startup works without it. Obtain Jason's policy approval, not implementation's implied consent. |
| E02 **post-v1** | Protected source slots and mail-access grants, `packages/bus/`, `packages/business/`. Build D8/D9 on existing grant executor. Depends E01. | `node --test packages/bus/tests/mail-grants.test.mjs`: deny non-chief raises, nonhuman approvals, altered targets, duplicate consumption, hand grants and revoked/in-flight access. Secret sentinel absent from all outputs. Existing grant/approval suites pass. |
| E03 **post-v1** | Durable intake ledger, leases, outbox and disabled supervisor, `packages/mail/src/{store,intake,service}.mjs`. Depends E02. | `node --test packages/mail/tests/intake.test.mjs`: crash at each transaction/outbox boundary, concurrent owners, disk-full, cap exhaustion and restart. No skipped cursor or duplicate recipient effect, no source calls while disabled. |
| E04 **post-v1** | Bounded MIME parsing and quarantine, `packages/mail/src/{parse,attachments}.mjs`. Depends E03. | `node --test packages/mail/tests/parse.test.mjs`: malformed MIME, oversized/deep parts, traversal filenames, remote HTML, archives and encrypted attachments. Limits enforced before expansion, zero execution/network, explicit truncation/quarantine. |
| E05 **post-v1** | M365 driver and first `mail-source` kind, `packages/plugins/src/kinds.mjs`, `packages/plugins/mail-source/m365/`. Depends E03/E04. | `node --test packages/mail/tests/m365.test.mjs`: fake Graph pagination/delta, immutable ID moves, cursor expiry, token expiry, 429, restarts and scoped-denial cases. Read-only request allowlist, no skipped/repeated delivery. Existing plugin tests pass. |
| E06 **post-v1** | IMAP driver, `packages/plugins/mail-source/imap/`. Depends E05 contract. | `node --test packages/mail/tests/imap.test.mjs`: fake TLS/IMAP server, certificate denial, IDLE disconnect, sequence renumbering, UIDVALIDITY reset, folder moves and missed wake-ups. No Seen/STORE/EXPUNGE, uncertain identities hold effects. |
| E07 **post-v1** | Address table, bounded rules and delivery, `packages/mail/src/{addresses,rules,route}.mjs`. Depends E03–E06. | `node --test packages/mail/tests/routing.test.mjs`: aliases, plus tags, Bcc, forged recipient headers, retired agents, multiple recipients, missing/reused Message-ID and duplicate provider pages. No cross-principal disclosure, no duplicate work, no authority from sender. |
| E08 **post-v1** | Broker verbs, MCP/typed tools and local drafts, `packages/bus/`, `packages/harness/`, `packages/mail/src/access.mjs`. Depends E02/E07. | `node --test packages/harness/tests/mail-tools.test.mjs`: identical schema/results/refusals through every supported harness adapter, read/attachment/draft rights separated, manual mode and expiry enforced, send impossible. Existing MCP/tools/approval suites pass. |
| E09 **post-v1** | Decision-module integration, `packages/mail/src/decisions.mjs`, central decision interface. Depends E08 and approved Q198/Q207 interface. | `node --test packages/mail/tests/decisions.test.mjs`: model outage leaves rules active, one central fallback path, schema-invalid suggestions held, no mail-local model client, hostile mail cannot approve/grant/spend/send. Minimal-context privacy and budget checks pass. |
| E10 **post-v1** | Health, alerts and protected-mode recovery, `packages/mail/src/health.mjs`, Q198 manager adapter. Depends E03/E08 and implemented/verified SM D13–D15. | `node --test packages/mail/tests/outage.test.mjs`: fake clock/provider verifies outage scope, full cooldown sequence, Retry-After, parallel probes, no model probe, no outage DLQ, restart continuity, canary/batched resume, uncertain delivery fencing and human kill persistence. |
| E11 **post-v1** | Authorized Console/CLI mail views, retention/export/prune/restore, `packages/{mail,cli,webui}/`. Depends E08/E10 and Q4 authority. | Focused `mail-retention` and `mail-ui` tests prove ACLs on lists/search/export, expired-content denial, exact delete approval, holds, backup-expiry restore suppression and no old-work replay. Decision inbox remains distinct, alerts survive mail outage. |
| E12 **post-v1** | Opt-in operator runbook and acceptance packet under `docs/`. Depends E01–E11. | Offline end-to-end synthetic message → parse → route → authorized tools → local draft, under every supported harness. Demonstrate revoke, outage recovery and disable/rollback preserving evidence. Live pilot is a separate Jason-authorized step after named address/account/scope/retention approvals. No external mail sent. |
| E13 **post-v1 optional** | POP3 adapter, `packages/plugins/mail-source/pop3/`, only for an approved named need. Depends E12. | Fake server tests UIDL absence/reuse, restart and TLS failure, retrieval caps, reconciliation and zero DELE. Unsupported identity refuses activation. Other adapters remain unaffected. |
## Questions for Jason (recommendations, not assumed approvals)
1. **Q1 Source and address pilot:** which provider, domain and existing or new mailbox
should receive agent/user mail? **Recommend** one separately approved read-only
ingress mailbox, aliases for named agent/user recipients, no catch-all or MX change.
Use dedicated mailboxes for confidential separation. Prove preserved delivery
identity before enabling alias/plus routing. New addresses/accounts wait for Jason.
2. **Q2 Grant ownership:** approve a protected source service principal plus separately
scoped per-agent/user mail-access grants? **Recommend yes**, through a post-v1 PRD
amendment and existing chief/human approval model. No agent gets provider secrets
or implicit access to personal mail. Enable only the explicitly requested sources.
3. **Q3 Automation:** may rules route mail and wake a granted agent without per-message
human approval? **Recommend yes** for reviewed within-role routes, subject to manager
admission and manual mode. Reading/drafting does not authorize requested external
effects. Any model-needed decision uses the one Q207 module.
4. **Q4 Content and deletion:** approve the 30-day body/draft, 7-day attachment and
365-day minimal-evidence policy, including encrypted backups and deletion authority?
**Recommend these pilot limits**, metadata-only until approved, with an exact
`data.delete` approval binding for pruning. Do not weaken the hard-waits list.
Jason must identify any business retention/hold requirements before live intake.
5. **Q5 Exposure and costs:** allow provider webhooks or paid mailbox infrastructure?
**Recommend no initially**. Use outbound Graph polling/IMAP, existing internal
notifications and no new exposure. Each future webhook, DNS/MX change or spend
gets its own explicit authorization.
6. **Q6 Sending:** should any future role send email? **Recommend keep sending out
of this add-on**. Retain R21 permanently for the specialist. Any later sender is a
separate design with exact-recipient approval, scoped credentials and effect evidence,
not a toggle that silently turns drafts into delivery.
## Planning receipt and handoff
Verified pinned commits, source-tree equality, cited code boundaries and ten requested
coverage areas. No implementation, dependency installation, legacy test execution,
mail connection or live-stack change occurred. Canonical `queue next della` returned
`nothing` after the linked-worktree refusal. No queue/MVP row changed. Vikunja 460 is
the supplied tracking task. Mos/Sage owns any required Gitea issue and future queue
enrollment because this seat has no Gitea API credential. Phase B review and phase C
synthesis remain with Mos. Della has not read Ludwig's plan. This branch push delivers
the planning artifact only, not approval to build or deploy it.
+399
View File
@@ -0,0 +1,399 @@
# Post-v1 email processing and monitoring plan
**Status:** phase C candidate, mos-1223, Vikunja 460. Ludwig reviews before Sage lands it.
**Goal:** Stack-owned mail intake, monitoring, authorized reading/routing and local drafting for every harness.
**Boundary:** all rows post-v1, none required for 2026-10-27. No PRD 1.10 or live change here.
Q210 ready rows go to non-v1 seats after handoff. No ready label authorizes live connections,
addresses/accounts, DNS/MX, exposure, spend or external mail. Original plans/reviews stay unchanged.
R21: **“The email specialist reads and drafts, never sends.”**
## Evidence and reuse
Citations are pinned, not claims about deployed behavior. Prefixes below identify the repository and commit
for every `file:line` citation. Both prior clones were inspected read-only. No prior application or test suite
was executed.
| Prefix | Pinned evidence |
| --- | --- |
| S | Stack `c3d88840c5b6ffa62d2a6a4aa4385d388234d777`, this worktree |
| MP | `mail-processor` `95b02989f4d4276dded00c90ffd74ebb7d03ae8b` |
| EP | `email-processor` `7a6492190cd376058e29a04fd0593e86bae1a8e5` |
| SM | `origin/huey/system-manager-design` at `2ee68cd69337bb933a8fabeb04bcd71835170171`, Q198 r6, superseding r5 `fd44f7c60` |
**F1 Existing mail code:** recursive comparison found identical `src/` trees in MP and EP, including identical
tests. MP contains EP plus three commits. Reuse once. Both implement normalized messages and a connector
boundary (`src/email_processor/connectors/base.py:8,30`), M365 pagination/rate-limit handling
(`connectors/m365.py:81,138`, under that same source directory), ordered AND/OR rules
(`src/email_processor/rule_engine.py:81,124`), and bounded preview/cold-start safety
(`src/email_processor/poller.py:101,122,152`). Port all 36 rule-engine and 15 predeployment safety test cases
as synthetic JS cases after dependency/license review. Remove subject logging (`rule_engine.py:147`). Neither
copy proves IMAP or POP3 support: connector selection handles only M365 (`src/email_processor/poller.py:48`).
**F2 Prior intent:** EP `docs/PRD.md:25,39` describes a single-organization M365 application, not the Stack
add-on. MP `docs/PRD.md:26,66,204,235` specifies generic Graph/IMAP/POP3 drivers, separated intake/execution,
fail-closed activation and atomic staging/checkpoints. Those are useful design requirements, not implemented
features. Deployment manifests target USC, not proof of a currently running service. Do not enroll an employer
tenant by default or change any existing service. Adopt the safety properties and conformance cases, not its
independent tenant/profile product, bootstrap authority or mandatory standalone web setup.
**F3 Drop unsafe coupling:** in both repos, `src/email_processor/poller.py:187-195,200,301` checks for
duplicates before effects and advances a timestamp to wall-clock now. `src/email_processor/models.py:266` has
indexes, not a unique mailbox/message key. Replace these with durable provider cursors, unique identities and
transactional delivery. Drop connector caches keyed just by mailbox name (`poller.py:48`), direct GLPI/ntfy
effects (`action_executor.py:30,69,159`) and source category writes (`connectors/m365.py:170`,
`poller.py:359`). Drop direct model calls and content logs (`classifiers/llm.py:79,105,109,123`), replacing
decisions through Q207. Do not import deployment manifests, credential settings, existing accounts or data.
Graph already selects `body` and normalizes HTML (`connectors/m365.py:27,129`), although `body_text` is empty.
**F4 Stack enforcement:** S `packages/harness/src/tools.mjs:1` already maps typed tools through the runner
socket with broker checks. Its MCP bridge carries no capability (`packages/harness/src/mcp-server.mjs:1,34`).
Reuse that path for all harnesses, rather than giving each one mailbox credentials or a monitoring plugin. S
`packages/bus/src/broker.mjs:690,701,1601,1666` supplies human-approved grant consumption and action
authority. `packages/bus/src/credentials.mjs:142,358,379` supplies scoped references and protected use. Mail
kinds and grants do **not** exist: S `packages/business/src/vocabulary.mjs:109,119` accepts Gitea, Vikunja and
host kinds. Mail requires reviewed extensions, not relabelling mail credentials as an existing kind.
**F5 Product constraints:** S `docs/prd/mosaic-stack.md:432,454,477,496` defines chief-raised grants, exact
human approval, one-time consumption and immediate local revocation. Its `:507,542,582,607` covers decision
evidence, thread binding and hard waits. PRD “inbox” is the decision inbox, not email. Mail needs a separately
approved post-v1 PRD addendum. S `packages/plugins/src/kinds.mjs:9,32` has no mail-source kind. Follow
REQ-PLUG-1/2 (S PRD `:876,910`) for provider adapters.
**F6 Monitoring precedent:** S `tools/agent-watch/jarvis-email-watch-liveness.sh:8,26,53` checks timer state,
failed execution and staleness. Reuse the distinction between alive, failed and unknown, not its legacy unit
names or fleet wiring. SM `docs/plans/2026-10-21_q198-system-manager.md:262,291,304,326` supplies protected
mode, independent probes and controlled resume. It is a design dependency, not shipped proof. S
`packages/discord/src/connector.mjs:1-12` and `journal.mjs:60,71` provide authorize → journal → engine →
deliver and intent/result reconciliation precedents. Reuse principles, not Discord nonce assumptions or its
old approval-button path for mail authorization.
**F7 Secret-scan evidence:** Ludwig reports a pattern-based scan of both prior trees and full histories with
no candidate credential value found (his plan `4a27b73ac`, `docs/plans/2026-10-21_email-plan-ludwig.md:35`).
Della did not independently repeat that scan. It proves neither absence of secrets nor that rotation is
unnecessary. Any later finding is reported by path and commit only, never by value or partial value.
## Architecture and sources
**D1 One owner per instance.** Add an optional supervised `packages/mail/` service, with a single leased owner
per configured source. It owns connection pools, OAuth refresh, scheduling, cursors and intake. A harness can
stop, restart or change without losing monitoring. Multiple accounts are isolated by instance/business/source
ID. “One service” does not mean one global credential, mailbox or shared authorization. Default installation
is disabled and opens no connection.
Use Node modules aligned with the Stack, adapting the Python design rather than embedding both applications
and their separate databases/authentication/UI. A Python sidecar would reuse more syntax but duplicate
operational boundaries. Harness-native connectors would repeat connection state and fail Jason's cross-harness
requirement.
**D2 Source contract.** Propose a reviewed `mail-source` plugin kind, list-selected, with `descriptor()`,
`scan({source,cursor,limits,signal})`, `fetch({source,item,part,limits,signal})` and `health({source,signal})`
exports. `scan` returns bounded observations, continuation, terminal cursor and identity epoch. Adapters
receive protected credential-use callbacks, never agent-provided secret paths. Core owns grants, leases,
persistence, rule evaluation and audit. No adapter sends, deletes, labels, moves or marks messages read. Use
the existing channel kind only where its dispatch contract fits presentation, not as a substitute polling
interface.
| Source | Proposed implementation and boundary |
| --- | --- |
| Microsoft 365 | Graph `Mail.Read`, read-only access, per-folder delta/continuations and immutable message IDs. Replace timestamp polling. Restrict application rights to named mailboxes and verify effective scope, including any additive Entra permissions. |
| IMAP | TLS with certificate verification, OAuth where supported, otherwise a separately approved service/app credential. Read-only selection and `BODY.PEEK`; UIDVALIDITY plus UID, never sequence number. IDLE is a wake-up hint with periodic reconciliation. |
| Other providers | Prefer a conforming IMAP source initially. Gmail API and JMAP are later adapters only when a named need justifies them. No new provider account is implied. |
| POP3 | No build row until Jason names a POP-only need. A future adapter requires stable UIDL, TLS, read-only retrieval and safe identity/reconciliation. No `DELE`. |
Graph delta is folder scoped and cursors are opaque ([Microsoft delta
documentation](https://learn.microsoft.com/en-us/graph/delta-query-messages)). Immutable IDs survive moves
within a mailbox, not archive-mailbox moves or reimports ([Microsoft ID
documentation](https://learn.microsoft.com/en-us/graph/outlook-immutable-id)). Mailbox-scoped Application RBAC
and unscoped Entra grants are independent ([Microsoft RBAC
documentation](https://learn.microsoft.com/en-us/exchange/permissions-exo/application-rbac)). IMAP identity,
read-only selection and PEEK semantics follow [RFC 9051](https://www.rfc-editor.org/rfc/rfc9051.html). These
are protocol references checked 2026-10-21, not evidence of installed support.
**D3 Addressing.** Version a route table mapping trusted delivery evidence to a stable Stack user or
role-instance ID, never a display name or a newly invented role. The org file holds this human-approved table
through its versioned audited write path. No agent reroutes messages or widens its own route. A route grants
no read right. The first source follows Q1’s provider. Offline adapters need not wait for that choice. Review
maintained exact-pinned IMAP/MIME dependencies in their owning rows, initially Ludwig’s
`imapflow`/`mailparser` candidates. Do not write either protocol/parser in-house. Aliases on an approved
ingress mailbox are the recommended inexpensive start. Plus-addresses are acceptable only after fixtures and a
later authorized provider test prove the exact recipient survives delivery. They are not a security boundary.
Dedicated mailboxes provide stronger provider-side separation for confidential users or agents, at the cost of
account management and possible licenses.
Bind routes to source identity plus provider-controlled original-recipient evidence. `To`/`Cc`, forwarded
headers and arbitrary `Delivered-To` headers are insufficient unless the enrolled delivery path establishes
their provenance. Bcc or ambiguous alias delivery goes to restricted operator review, never guessed routing.
Unknown addresses do not create agents. Multiple recipients produce independently authorized deliveries
without revealing other recipients' mail. Jason approves domains, names, aliases, mailboxes and each
provider-side change before provisioning. No SMTP/MX server, catch-all domain or public webhook is proposed
for the initial increment.
## Processing, data and retention
**D4 Intake pipeline:** approved source → bounded discovery → bounded parse → durable observation/dedup →
versioned deterministic rules → authorized delivery outbox → Stack notification/work request. Provider cursor
advancement and durable observations commit together under the source lease/generation. Outbox delivery has a
stable key and a recipient-side unique receipt, so a crash after delivery but before acknowledgment does not
create a second task/message. Never promise exactly-once external effects.
Default candidates: poll every 60 seconds, at most 50 observations and 10 MiB fetched per slice, fair
scheduling between sources. Persist continuation when the slice fills. Cap each source at 1,000 pending
deliveries and 100 MiB of retained content initially. Persist quota accounting across restarts and slices.
Reaching a quota holds new intake and raises an alert, while read-only health checks continue. First
activation establishes a from-now baseline without actions on old mail. Historical import is a separate
approved time/count window, previewed before activation. Cap violations, disk-full and unavailable audit
storage hold work without advancing past unstaged mail. These limits are proposed settings to validate, not
measured capacities.
Provider identity is scoped to business/source/account: Graph immutable ID, IMAP folder/UIDVALIDITY/UID or
POP3 UIDL. RFC Message-ID, thread ID and content similarity are secondary correlation signals, never sole
proof of uniqueness. Preserve distinct messages with missing or reused Message-ID. An IMAP move, epoch reset,
expired delta cursor, archive move or reimport enters bounded reconciliation. Record aliases only with
sufficient identity evidence. Otherwise hold possible duplicate actions for review. An edit is a new
observation version, not automatic permission to rerun prior actions. Preserve dedup tombstones beyond content
expiry. Replay needs a new authorized intent, parent link and current grants. A rule edit never retroactively
executes preview work.
**D5 Parsing and rules.** Preserve provenance for normalized sender, recipients, timestamp, subject, text,
thread references and attachment descriptors. Bound MIME depth to 10, parts to 100 and normalized text to 256
KiB. Decode safely, flag malformed or truncated content and quarantine failures. Prefer text, sanitize HTML
for display, disable remote images/URLs and never execute active content. Rules use finite allowlisted
fields/operators, stable priority and first-match by default. Reject executable rules and unbounded regex.
Record rule/version and routing reason. Unmatched mail is held for its authorized owner, not dropped or copied
fleet-wide. Routing a user notification does not launch an agent. Agent wake-up is a separate Q198-admitted
work request. Wake requires an approved route, current access grant, sender allowlist and trusted aligned
DMARC authentication. Strip/ignore sender-supplied Authentication-Results. Ambiguous forwarded/list mail or
failed authentication goes to restricted review.
Optional classification and any mail-triggered decision go through the Stack's one decision module under
DECISIONS Q207. The mail service neither selects nor falls back to a model locally. Core policy still
determines permitted actions and human hard waits. Use a reviewed mail decision schema inside SM D12’s 4 KiB
request limit (`:503-512`). Initial input is metadata only: opaque eligible route IDs, work/capability/error
enums and bounded numeric state. Sender domain/auth fields or any new mail-derived field need explicit schema
approval and redaction tests; no free-form rule summaries or body. If metadata cannot decide, hold/unknown
rather than invent confidence. Body-based summaries and drafts are separate governed work, not mail-local
routing decisions. A model result is advisory data, never a grant. Missing model service leaves deterministic
intake working and model-dependent work held. Summaries/drafts run as ordinary governed agent work, not an
extra mail-service model client.
**D6 Attachments.** Initially enumerate metadata only. An authorized explicit fetch quarantines bytes under an
opaque ID, with a 10 MiB per-part and per-message total cap. Raw quarantine fetch needs no scanner. It never
implies permission to expose extracted content. Read-only files are not execution prevention.
Scanner/extraction is gated E16. Never trust filenames as paths, unpack archives, execute macros or follow
embedded links. Content extraction requires isolated no-network processing, resource bounds, scanner success
and a separate read right. Failed, encrypted or unsupported files stay quarantined. A clean scan does not make
attachment instructions trustworthy.
**D7 Storage.** Proposed `<dataRoot>/mail/<business>/` contains encrypted content blobs, SQLite
observation/outbox state, encrypted cursors, local drafts and append-only event evidence. No mail content
enters git or images. Service-only directories are 0700, files 0600. Use scoped keyed fingerprints for private
dedup fields. Content keys live at `<configDir>/secrets/mosaic-stack/mail-content/<business>/<key-id>`,
outside dataRoot and its backup set, separate from provider slots, with 0600/no-symlink checks.
Protected key expiry/retirement metadata also stays outside the mail-backup restore domain. Keep tokens, delta URLs and authentication errors out
of ordinary logs. Default durable records hold opaque IDs, state, versions and counts, not bodies, subjects,
recipient lists or attachment names. Content access is explicit and audited.
Recommended content policy for an enabled pilot: encrypted bodies and local drafts for 30 days, explicitly
fetched attachments for 7 days, minimal metadata/tombstones for 365 days. Without Jason's approved
content/retention policy, persist metadata only and show that later content fetch requires the source online.
Expired content returns an explicit unavailable result. Define backup/key expiry and restore suppression
together: restoring an old backup must not resurrect expired content or redeliver old work. Use tested
backup-copy deletion or crypto-erasure with expiry of every relevant key copy/escrow, plus restore
suppression. Encryption or one key deletion alone proves neither. Local pruning needs the Stack's
`data.delete` approval path or an explicitly approved future retention authority. Do not silently grant
automatic deletion. Write receipts, preserve legal holds and do not rewrite `<dataRoot>/runs/` or existing
audit evidence.
## Access and security
**D8 Identical access contract.** Proposed tools are `mail_list(source?,cursor?,limit)`, `mail_get(id,view)`,
`mail_thread(id,cursor?,limit?)`, `mail_attachment_get(id,part)`, `mail_ack(id,expect)`,
`mail_draft_create(id,text)` and `mail_health(source?)`. Thread views recheck every message and omit
unauthorized members/recipients. No reroute tool. All map to typed broker verbs via the existing tool
socket/MCP bridge. Local CLI and authenticated Console use the same handlers. List is paginated (default 20,
max 100). `ack` changes Stack-local state only. Drafts are local unsent records, not Graph drafts. Health
returns only authorized source metadata. No arbitrary URL, shell, source secret path, provider query language
or send tool is accepted.
Routing is human/system-only. Separate metadata, body, attachment and draft rights per business/source and
named principal. Recheck current role/grant on every read, notification and action, including caches and
derived summaries. Revoke immediately fences new uses, invalidates fetch handles and cancels owned in-flight
reads before returning content. Agents receive only mail explicitly routed to their grant scope. User mail
requires that user's or explicitly delegated access. An email specialist is not implicitly allowed to read
every user's mail. Cross-business IDs return no data.
New mediated access uses proposed `mail.access`, distinct from provider credential creation through
`credential.mint`. The chief raises it to the authenticated human Approve path, with exact
principal/target/rights/expiry bound once. Add its access grant schema and action routing only through a
Jason-approved PRD version and reviewed vocabulary/role changes. Until then new actions remain hard waits.
Grant creation stays human-only. Read/draft actions become routine only inside approved grants. Manual mode needs
per-use approval. A hand-edited grant or approving email creates no authority.
**D9 Credentials and hostile input.** Distinguish a source service principal's provider connection grant from
an agent's mediated mail-access grant. Recommend dedicated read-only ingress credentials per source/business,
held by protected Stack code and never shared with agents. This system-service ownership is a new approved
extension, not an exception silently taken to REQ-CRED-1. Configuration names only an approved absolute slot
under `<configDir>/secrets/mosaic-stack/mail/<business>/<source>/`. Proposed service-principal schema: `{kind:
"mail-service", business, source, provider, mailbox, scopes, slotPath, rotateBy, approvalId, revision}`.
Identity comes from approved configuration, not an agent argument. It grants source reads only, not human
input, grant issuance or general bus writes. Reject wildcard/unknown fields, expired approval, changed source
identity and slots inside repo/dataRoot. This extends existing schemas explicitly (S
`packages/business/src/credentials.mjs:95-102`, `packages/webui/src/sso.mjs:33`). No value or partial value is
read by a planner, returned through tools or embedded in a plan. Missing/expired/revoked slots fail closed.
Validate owned non-symlink files, rotation and revocation behavior.
Treat mail, headers, filenames, OCR and quoted replies as untrusted external data. Sender addresses, even
authenticated messages, cannot approve decisions, change policy, mint grants or impersonate the CLI/Console
human. Delimit model input and strip tool authority, but rely on broker denial, sandboxing and least privilege
for enforcement. An email asking an agent to fetch secrets, forward documents or run commands must not gain
those rights. External action proposals use existing approval paths and provenance. No automatic link fetches.
Provider endpoints are enrolled allowlists with TLS and redirect checks, not values selected from mail
content. Logs use reason codes and IDs. Mail tool results carry `untrusted: true` plus a notice modeled on S
`packages/bus/src/broker.mjs:109`. Sanitize hidden HTML, comments and control-character tricks for display
without treating sanitization as authorization. Test body, subject, display-name, filename, hidden-text and
spoofed-internal-sender injection. Mail-woken work uses a fresh isolated profile, intersected with its
existing grants: mail reads/draft, scoped fleet messages and decision.raise only, no git/credential/
external-message tools. Broker enforcement matches the tool list. Decisions and onward messages retain
`source: mail`; downstream seats cannot launder mail into authority. A powerful existing thread is never
resumed as the restricted mail turn.
**D9a Service delivery:** add a protected business-scoped delivery principal/API that accepts only approved
recipients and stable mail receipt keys, with fixed actor `mail`. S `packages/bus/src/principal.mjs:8` has no
mail principal today. Do not impersonate a user/agent or call an unauthenticated general bus write. Journal
delivery acceptance and recipient receipts atomically in the bus through this API, with grant/revoke
checks. The separate local outbox retries the same receipt key after a crash, not a distributed transaction.
**D10 Sending.** Out of scope: SMTP submission, Graph send, forwarding, auto-replies, DSNs generated by this
service and emailed digests. Block them in adapter interfaces, credentials and network policy. R21 stays
intact even if a future separate sender role is approved. Human export of an unsent draft is not authorization
for Stack delivery.
## Monitoring and system-manager integration
**D11 Health:** report desired/actual connection state, last successful scan and durable cursor commit, lag,
backlog, lease owner, retry-after, credential status, quarantine, outbox age and disk pressure. Never report
“healthy” merely because the process lives. Candidate stale threshold: three missed scan intervals. Alert once
per incident, on severity change and recovery through existing internal notifications/Console, not mail. Show
alert delivery failure locally. Alerts include IDs and reason codes, no content.
Register dependency scopes `mail-source/<business>/<source>` with Q198's manager. Network/provider outages
pause only affected intake and dependent fetch/dispatch. Independent sources and authorized stored-mail reads
can continue. Disk/audit failure halts new intake/effects. Auth/config denial stays held for correction, never
retries under another credential. Permit at most one refresh/retry of a rejected authentication attempt, then
stop and alert to prevent account lockout. Missing grants/config and explicit scope denials get no retry.
Malformed messages quarantine individually.
Apply Q205–Q207 through protected mode, not the human kill switch: mechanical source probes run independently
at 60, 120, 180 seconds, increasing by 60 to a 600-second ceiling, indefinitely, while respecting longer
Retry-After. No JEV/model availability gate and no model call for a mail health check. Mail health proves
TLS/auth and scoped source/folder access without bodies: IMAP login/NOOP alone and Graph directory `GET
/users/{id}` do not prove it. The latter needs directory permissions, not Mail.Read ([Microsoft user
API](https://learn.microsoft.com/en-us/graph/api/user-get?view=graph-rest-1.0)). Use minimal scoped mailbox
operations and test denied folders/mailboxes. Retention or provider deletion can lose source mail during
outages; detect/report gaps, never promise losslessness. One lease owns retries, so adapter and manager loops
cannot multiply them. Disabled sources make no probes.
On recovery reconcile cursors/uncertain delivery receipts, pass one synthetic pipeline canary, then resume
eligible work without approval per session in manager-controlled batches at
most five with 30-second healthy intervals, and current capacity/admission checks (SM `:326-339`). After three
consecutive failed automatic exits, persist `recovery-escalated`; probes continue, work does not. Only the
authorized `system.recovery.release` path with incident/revision and diagnosis clears that latch, never the
kill switch (SM `:341-358`). Never send test mail or use a live message as a synthetic canary. Uncertain
external effects stay fenced. Human kill remains human-only to clear. These mail extensions to SM D13–D15 need
contract review and tests before operational reliance.
## Ordered build rows (all post-v1)
READY means no Jason decision blocks the stated offline deliverable. It does not mean its code prerequisites
have landed. Sage queues it now and assigns an available non-v1 seat when prerequisites/review gates pass,
without a scheduled start or calendar wait. WAIT Qn identifies the positive authority needed before that row's
governed integration. Unanswered questions never block independent READY work. All rows remain disabled by
default, use synthetic mail/fake providers and run named tests plus affected existing suites. No ready row
enables a real source or weakens current grants/hard waits. Q3/Q9/Q10 propose keeping restrictive defaults,
not gates on code that preserves them. Q7 is a technical dependency review, not a new permission gate. Q11
affects neither build nor pilot because no old repository/service is altered. Queue enrollment/landings belong
to Sage after Ludwig approves this plan, with the v1 path and protected files preserved.
| Row / gate | Deliverable and dependencies | Acceptance tests (proposed filenames, not run here) |
| --- | --- | --- |
| E01 **post-v1 READY**; Jason: none for draft | PRD addendum draft, source/data/access/service and external content-key location/lifecycle schemas in `docs/prd/`, `packages/mail/schemas/`. Core mail-source registration/policy activation awaits approved addendum/Q4; content policy awaits E13/Q6. | `packages/mail/tests/schema.test.mjs`: unknown fields, wildcards, secret values, incomplete scopes and mismatched principal/source rejected; key location excludes dataRoot/backups, mode/symlink checks, disabled schema compatible with v1. Every requirement maps to a row. |
| E02 **post-v1 WAIT Q4** | Protected slot reader, source-service enrollment/credential lifecycle and separate `mail.access` grant action in `packages/{bus,business}/`. After E01 and approved PRD. No real credential enrollment in this build row. | `packages/bus/tests/mail-grants.test.mjs`, `packages/bus/tests/mail-principal.test.mjs`: non-chief/nonhuman approval, modified targets, expiry, duplicate consume, hand grants, failed issuance undo, manual mode, immediate revoke/in-flight fencing and secret sentinel leakage. Reject repo/dataRoot slots; existing grant tests pass. |
| E03 **post-v1 READY**; Jason: none | Durable scoped ledger, encrypted state, leases, cursor/continuation, outbox and quota accounting in `packages/mail/src/{store,intake,service}.mjs`. After E01. No dependency on live grants. | `packages/mail/tests/intake.test.mjs`, `packages/mail/tests/store.test.mjs`: crash every staging/delivery boundary, concurrent owners, missing/reused Message-ID, source-scoped identity collisions, 1,000/100-MiB quotas, disk/audit failure, restart and zero-connection disabled startup. Missing/expired key fails closed; restored backup with expired key returns unavailable, never plaintext. No skips or duplicate recipient effects. |
| E04 **post-v1 READY**; Jason: none, Q7 advisory | Bounded MIME parsing/metadata quarantine in `packages/mail/src/{parse,attachments}.mjs`. After E01; independently of grants. Review license/advisories and exact-pin maintained MIME dependency. | `packages/mail/tests/parse.test.mjs`, `packages/mail/tests/parser-corpus.test.mjs`: multipart, bad charset, missing IDs, S/MIME, 30-MiB input, depth/part/text limits, traversal, hidden HTML, comments, bidi/zero-width display tricks, archive and encrypted attachment quarantine. Fuzz corpus; no expansion/execution/network, no content logs. |
| E05 **post-v1 READY** offline; core registration **WAIT Q4** | Graph descriptor/module in `packages/plugins/mail-source/m365/` against test-local registry `packages/mail/tests/fixtures/mail-source-registry.mjs`. After E01/E03/E04. `packages/plugins/src/kinds.mjs` stays unchanged until E01 addendum approval/Q4; core entry lands together with or after that approved addendum, with its first production module. Q1 selects pilot, not offline work. | `packages/mail/tests/m365.test.mjs`, `packages/mail/tests/source-contract.test.mjs`: fake Graph delta/paging, immutable moves, token/cursor expiry, 401 single refresh, 429/5xx scheduling, restart, denied mailbox, additive-scope check and alias/plus/Bcc provenance. No source writes; test-local registry works with unchanged core registry before approval. Existing plugin suite passes. |
| E06 **post-v1 READY**; Jason: none, Q7 advisory | IMAP module `packages/plugins/mail-source/imap/`. After E05's test-local shared contract; no core registration before approved E01/Q4. Review/license/exact-pin maintained IMAP dependency. | `packages/mail/tests/imap.test.mjs`: local fake TLS server, certificate denial, selected-folder scope denial, BODY.PEEK/no Seen or STORE/EXPUNGE, IDLE drop/poll fallback, renumbering, UIDVALIDITY/move reconciliation and alias/plus/Bcc/forged-header provenance. Ambiguity holds effects. |
| E07 **post-v1 READY**; Jason: none for pure evaluation | Address/rule evaluator `packages/mail/src/{addresses,rules,route}.mjs`. After E01/E03/E04; no org-file activation or delivery privilege. | `packages/mail/tests/rules.test.mjs`, `packages/mail/tests/routing.test.mjs`: port all 36 prior rule cases; bounded operators, rule version/order, previews never execute, history import window, recipient ambiguity, multiple recipients, retired agents, repeated identical mail and no content logs. |
| E08 **post-v1 WAIT Q4/Q5** | Approved org route writer and narrow service delivery API/principal in `packages/{bus,business}/`, `packages/mail/src/delivery.mjs`. After E02/E03/E07. No agent route writer/reroute. | `packages/bus/tests/mail-delivery.test.mjs`, `packages/bus/tests/mail-routes.test.mjs`: exact human approval and versioned changes, fixed actor/business, recipient receipt idempotency, spoofed principal refused, route addition grants no read, revoke before dispatch fences content, no human-input impersonation. |
| E09 **post-v1 WAIT Q4** | Mediated typed/MCP tools, per-message thread view and local drafts in `packages/{bus,harness}/`, `packages/mail/src/access.mjs`. After E02/E08. | `packages/harness/tests/mail-tools.test.mjs`, `packages/mail/tests/access.test.mjs`: same schema/results/refusals on all supported harnesses, separate metadata/body/attachment/draft rights, page caps, thread filtering, cross-business ID denial, revoke/cache races, manual mode, local ack/draft and absence of send/reroute tools. |
| E10 **post-v1 WAIT Q5** | Restricted mail-triggered launch profile, sender-auth gate and origin propagation in `packages/{mail,harness,bus}/`. After E08/E09 and Q198 admission interface. | `packages/mail/tests/mail-turn.test.mjs`, `packages/mail/tests/injection.test.mjs`: fresh context/grant intersection, no git/credential/external tools at broker and harness, body/subject/display-name/filename injection, forged auth headers, aligned DMARC/allowlist, forwarded ambiguity, no resume of powerful thread, no authority laundering through fleet messages/decisions. |
| E11 **post-v1 WAIT Q4** (mail decision schema approval) | `packages/mail/src/decisions.mjs` using the one approved Q207 decision API; no local model client. After E07/E09 and implemented Q198 decision module. | `packages/mail/tests/decisions.test.mjs`: 4-KiB closed schema, no body/free text, extra metadata denied until approved, out-of-enum/missing/uncalibrated confidence held, central fallback only, unavailable model leaves deterministic intake working; useful metadata-only outcomes or explicit unknown. |
| E12 **post-v1 READY**; Jason: none for synthetic recovery | Health/manager adapter `packages/mail/src/health.mjs`. After E03/E08 and implemented/verified SM D13-D15; fixtures can precede integration. | `packages/mail/tests/health.test.mjs`, `packages/mail/tests/outage.test.mjs`: mailbox/folder scope proof, full 60..600 cooldown and Retry-After, independent probes/one retry owner, bad-auth single retry then stop, no model probes or outage DLQ, synthetic canary, five-item/30-second batches, three-exit latch/release, human kill, alert-once/recovery and source gap reporting. |
| E13 **post-v1 WAIT Q4/Q6** | Authorized CLI/Console mail/draft/digest views and content/export/retention handlers in `packages/{mail,cli,webui}/`. After E09/E12. No live purge; keep `data.delete` hard wait. | `packages/mail/tests/retention.test.mjs`, `packages/cli/tests/mail.test.mjs`, `packages/webui/tests/mail.test.mjs`: per-user ACL on every item/export, separate decision inbox, exact deletion approval/holds/receipts, expiry of backup content and all relevant keys, restore suppression/tombstones, local draft ownership and metadata-only digest. |
| E14 **post-v1 READY**; Jason: none for offline proof | Operator runbook/acceptance packet in `docs/`; offline end-to-end harness test. After E01-E13. | `packages/mail/tests/predeploy-safety.test.mjs`, `packages/mail/tests/end-to-end.test.mjs`: port all 15 safety-gate cases; synthetic intake → route → authorized tools → local draft across supported adapters, zero source connections while disabled, from-now baseline, bounded preview, revoke/outage/restart and disable/rollback with evidence preserved. |
| E15 **post-v1 WAIT Q1/Q4/Q5/Q6**, plus Q2 if personal mail | Explicit separately authorized live pilot: enrolled agent ingress source, approved grant/route/content policy and reviewed re-pin. After E14. No v1 gate or external test mail. | `packages/mail/tests/pilot-reconcile.test.mjs` validates a redacted identity-level acceptance manifest; authorized operator supplies live receipts for delivered/held/missing identities, recipient proof, health, restart/revoke and bounded catch-up. Counts alone prove neither loss nor duplication. Complete on coverage/evidence, not a mandatory week or scheduled wait. |
| E16 **post-v1 OPTIONAL WAIT Q8**, plus Q4 access | Isolated scanner/content extractor only if Jason chooses attachment content support, `packages/mail/src/extract.mjs`. After E04/E09, separate reviewed dependency/resource boundary. | `packages/mail/tests/extract.test.mjs`: no-network/resource isolation, scan failure/unavailable/encrypted/unsupported hold, execution/archive expansion prohibited, hostile extracted text marked untrusted, access/revoke checked before release. No scanner implies no extracted-content feature. |
Run proposed Node files with `node --test <filename>`; owning reviewers select affected existing suites and
require green evidence before commit/landing. Pure store/parser/rule rows can proceed independently of
approval work. Later adapters Gmail API/JMAP/POP3 require a named need and separate rows, not a committed
expansion of this add-on.
## Questions for Jason (merged list, recommendations are not approvals)
1. **Q1 Pilot provider/domain/addresses:** which controlled domain, provider and agent ingress mailbox,
aliases or plus-tags? Recommend one approved non-USC agent mailbox, aliases after recipient proof, no
catch-all/MX change. Dedicated mailbox only for approved isolation. Its protocol pilots first; build both
adapters offline meanwhile.
2. **Q2 Personal mail scope:** which existing user mailbox, if any, may be read? Recommend none initially.
Later enrollment names user/mailbox and exact consent or delegation, separately from agent ingress.
3. **Q3 Source mutation:** mark read, flag, label, move or save provider drafts? Recommend no, read-only
sources and Stack-local drafts/acknowledgments.
4. **Q4 Service/access grants:** approve protected source-service credential ownership, distinct
per-agent/user `mail.access` approval, mail-source kind and closed mail decision schema? Recommend yes through reviewed
post-v1 PRD/vocabulary changes, chief/human approval, exact targets/rights/expiry/revoke. New access action
stays a hard wait until the approved PRD explicitly routes it. Provider credential creation remains
distinct.
5. **Q5 Unattended wake:** which senders/routes may wake granted agents without per-message approval?
Recommend allowlist plus trusted aligned DMARC, approved route, restricted fresh profile and Q198
admission. Manual mode overrides this. Ambiguous/failing mail quarantines. Neither identity nor email
approves external effects.
6. **Q6 Content/retention/deletion:** what may be retained, for how long, with what holds? Recommend encrypted
30-day bodies/drafts, 7-day explicitly fetched attachments, 365-day minimal evidence/tombstones,
metadata-only until approved. Keep exact `data.delete` authorization, backup/key expiry and restore
suppression.
7. **Q7 Dependencies:** maintained IMAP/MIME packages after license/security review and exact pins rather than
custom clients/parsers? Recommend yes, investigate Ludwig's imapflow/mailparser candidates in owning rows.
Routine technical review unless an existing repository policy adds an approval gate; no calendar wait for
package review.
8. **Q8 Attachments:** allow bytes/extraction and require scanning? Recommend metadata first, explicit capped
raw fetch into quarantine, no scanner for opaque quarantine. Extracted content waits for isolated
no-network processing, resource limits and a scanner. Do not enable arbitrary execution or automatic
archive expansion.
9. **Q9 Exposure/spend:** new listener/webhook, DNS/MX, paid infrastructure or model spend? Recommend none
initially. Outbound monitoring/internal alerts suffice. Every later exposure, address/account change or
spend needs its own authorization.
10. **Q10 Sending:** should this add-on send/forward/reply? Recommend no. R21 remains permanent for the
specialist. A future separate sender needs its own approved plan and exact-recipient human approval
through `message.external`.
11. **Q11 Prior repositories/service:** is the configured USC deployment still running, and should either
repo/service be archived or retired? Recommend no operational change here. Preserve both histories, use MP
as consolidated design reference. Any archival/retirement is a separate authorized task after actual-state
verification.
## Verification and handoff
Sources: Della 191dff32f, Ludwig 4a27b73ac, reviews 9182134e5/143415779, Mos's routing ruling.
Verify citations, coverage, row gates/tests and unchanged original blobs. No product suite,
live mail, secret-file read, install or service change here. After Ludwig approval, send Sage
this pin for refactor landing and READY-row queueing on non-v1 seats, copying Mos.
Resolve review changes without a calendar wait. Jason-gated work waits on its numbered decision.
@@ -0,0 +1,272 @@
# della-0002: phase B review of Ludwig's email plan
2026-10-21. Mos assignment mos-1220, Vikunja 460. Planning review only.
**Recommendation:** neither proposal is ready for implementation approval. Preserve
Ludwig's ingress/turn-isolation ideas and Della's grant/durability/retention controls.
Mos chooses the base in phase C. Neither original plan was edited for this review.
## Evidence pins
Every `file:line` below resolves at its named commit. Abbreviations:
| Ref | Repository/commit and document |
| --- | --- |
| L | Stack `4a27b73ac`, `docs/plans/2026-10-21_email-plan-ludwig.md`, read all 218 lines |
| D | Stack `191dff32f`, `docs/plans/2026-10-21_email-plan-della.md`, 341 lines |
| S | Stack `c3d88840c`, the implementation and PRD 1.10 base for both plans |
| MP | `mail-processor` `95b0298` |
| EP | `email-processor` `7a64921` |
| SM | Stack `2ee68cd69`, `docs/plans/2026-10-21_q198-system-manager.md` r6 |
MP contains EP plus three commits. `src/` and `tests/` compare identical. There are
36 rule test definitions and 15 predeployment safety test definitions. This confirms
L `:17,28,33`, not that any tests pass today. No prior app or suite was executed.
## 1. Wrong or unsupported claims and material gaps
**F1 (factual): Graph already fetches body content.** L `:25` says it never fetches
a body. MP and EP `src/email_processor/connectors/m365.py:27` explicitly select
`body`; `:118,129` stores HTML body content and `:135` retains the provider object.
`body_text=""` is a normalization gap, not absence of body retrieval. Correct before
using that claim to estimate migration scope or privacy exposure.
**F2 (high): role plus route is not the complete grant model.** L `:139,145,147`
makes those the access conditions, but its rows `:185-195` omit mail grant creation,
exact human approval, expiry, failed-issuance undo, revoke races and manual mode.
S `docs/prd/mosaic-stack.md:432,454,477,496,565,607` requires those controls and
keeps new actions hard waits until an approved PRD says otherwise. S
`packages/bus/src/broker.mjs:690,701` consumes approved grants once. Add explicit
source-service enrollment and per-principal mail-access grant work and tests.
Routing changes must never grant body access merely by adding a seat to a route.
Use a new approved mail-access action for mediated rights, not `credential.mint`
unless an operation actually creates a provider credential. Both use authenticated
human decision records, with new action routing governed by REQ-WAIT-3.
**F3 (high): dedup conflates content, identity and authorization.** L `:121` gives
Graph an apparently unscoped immutable-ID unique key and makes `(source_id,sha256)`
unique. L `:123` merges across sources by Message-ID plus body hash. Distinct deliveries
can have identical content or reused IDs, and two businesses must not share a mail
record/visibility decision. MP `docs/PRD.md:300,306,310,313` explicitly requires scoped
identity, keyed fingerprints and move/epoch reconciliation. Retain source-scoped
observations and recipient-specific delivery receipts. Correlation may link records
only inside an authorized scope, never collapse them or widen access by itself.
Test forged/reused Message-ID, identical repeated mail and cross-business collisions.
**F4 (high): durable mail storage does not prove durable delivery.** L `:118-127`
advances the cursor after a row commit, then routes and delivers. It does not define a
durable work state/outbox, recipient idempotency receipt, source lease or atomic
ownership claim. Crash after bus acceptance and before local acknowledgment can
duplicate work. A unique mail row alone cannot prevent that. MP `docs/PRD.md:334,343,349`
separates staging, concurrency and effects. S `packages/bus/schema.sql:70,83` has unique
bus message IDs and delivery history but no automatic mail-source idempotency binding.
Add transactional pending work, leased intake and stable per-recipient effect keys.
**F5 (high): mail health probes do not establish mailbox health.** L `:173` proposes
Graph `GET /users/{id}`, which checks a directory user, not access to the mailbox.
Its application permission is `User.Read.All`, not the proposed `Mail.Read`
([Microsoft Get user documentation](https://learn.microsoft.com/en-us/graph/api/user-get?view=graph-rest-1.0)).
Use an approved minimal mailbox-scoped read with no bodies. Likewise IMAP login/NOOP
does not prove access to the selected folder. Both checks need enrolled source identity,
scope and capability validation, with no new privilege merely for probing.
L `:173` also resumes after one successful probe without stating the synthetic canary,
effect reconciliation, batch limits and observation intervals required by SM `:326-339`.
Its mention of three failed exits at L `:176` needs those exit stages to exist. Add the
persisted escalation latch/release behavior from SM `:341-358`. “Mail is not lost ...
because the server keeps it” is unsupported: retention, deletion, cursor expiry and
provider failure can remove mail. Report detected gaps, never promise losslessness.
**F6 (high): retention receipts are not deletion authority.** L `:131,186` proposes
`mail prune` with receipts/holds but does not bind it to the human `data.delete` approval
path. S `docs/prd/mosaic-stack.md:582-605` makes deletion a hard wait. Retain receipts
and holds, add exact deletion authorization, and test backup restore, content expiry,
dedup tombstone survival and non-redelivery. Choosing a retention period does not
silently route deletion within-role.
**F7 (medium): Q198 metadata allowlist is mis-cited and not automatically reusable.**
L `:66` points to SM `:420` for the 4 KiB restriction, but D12 is SM `:503-512`.
L `:126` adds sender domain, authentication results, subject length and rule summary.
Those fields are not explicitly in D12's closed allowlist of work/resource/queue/error/
capability/opaque-route fields. Require an approved, tested mail decision schema.
Metadata-only is a useful default, not proof of compliance or semantic classification
quality. Show deterministic or unknown routing when permitted metadata is insufficient.
Do not send a free-form rule summary that can contain user content.
**F8 (medium): the mechanical mail principal is new authority work.** L `:92,127`
assigns bus actor `mail`, but S `packages/bus/src/principal.mjs:8-11` verifies only
human/tester/user transport identities, and `packages/bus/src/broker.mjs:2965-2985`
checks session action authority for message sends. The design needs a narrowly scoped
trusted service delivery API/principal, not a spoofed agent session or human input.
Specify business identity, fixed actor provenance, recipient checks and allowed verbs.
This is a missing implementation contract, not evidence that service delivery is impossible.
**F9 (medium): recipient evidence needs a Graph gate too.** L `:108` correctly rejects
`To` as envelope proof, then suggests Graph recipient fields plus plus-tag without
showing they preserve the original alias/Bcc recipient. MP and EP
`src/email_processor/connectors/m365.py:115-126` only normalize visible To/Cc fields.
L M4 tests IMAP, while M5 `:189` has no recipient-provenance case. Test alias/plus/Bcc
preservation for every enrolled source and reject sender-supplied delivery headers.
When evidence is absent, quarantine rather than infer from `To` or a guessed tag.
**F10 (factual/qualification): narrow the evidence claims.** L `:19,54,209,218` treats
deployment configuration as current USC deployment evidence. It establishes intended
targeting, not that anything is currently running. No live check is authorized here.
L `:35` qualifies its secret scan, then concludes “Nothing to rotate.” A pattern scan
cannot establish that conclusion. Say no candidate found by the reported scan and
make no credential-validity/rotation claim. I have not repeated a full-history scan.
L `:68` says no mail code anywhere, but S
`tools/agent-watch/jarvis-email-watch-liveness.sh:2,18` is mail-watcher liveness code.
Say no integrated Stack mail intake/access service instead. L `:28,43` calls the rule
engine pure: MP `src/email_processor/rule_engine.py:147` logs the subject. Port rule
semantics, remove content logging, and do not describe the existing function as pure.
## 2. Ludwig strengths to graft into either base
- **G1 Restricted mail-triggered work:** L `:155` proposes mail-turn authority,
`source: mail` decision provenance and no git/credential/external-message tools.
D has least privilege but no explicit restricted mail-triggered launch profile.
Adopt a fresh isolated profile whose rights are the intersection with existing
grants. Do not resume a powerful ongoing thread or let fleet messaging launder
mail-origin requests into authority elsewhere. Broker enforcement must match tools.
- **G2 Trusted sender evidence before wake:** L `:127,158,213` adds provider-origin
Authentication-Results, allowlists and aligned authentication. D lacks an explicit
sender/authentication gate. Adopt it for unattended wake. A DMARC pass authenticates
a domain, not a human decision or permission. Define forwarded/list-mail handling as
quarantine/manual review initially. Strip forged auth headers at the trust boundary.
- **G3 Read-only thread tool:** L `:141` explicitly offers `mail.thread`. D has no
equivalent. Add a paginated view restricted per message, not an entire provider thread
that might contain recipients or messages the caller cannot see.
- **G4 Dependency investigation and parser corpus:** L `:88,186,202,215` names the
IMAP/MIME dependency work and concrete hostile fixtures. Carry that explicit package
review, exact pins and case migration into the row that first needs each dependency.
This review does not endorse an unverified package version or install anything.
- **G5 Explicit future live acceptance:** L M12 `:196` gives the eventual pilot a
visible row. D E12 leaves it a separately authorized step without a build row.
Preserve an explicit gated post-v1 pilot row, after synthetic evidence and Jason's
source/access/content approvals. Replace a fixed week/server-count comparison with
identity-level reconciliation, crash/outage/revoke cases and a defined coverage target.
Q210 favors immediate authorized progress, not a mandatory calendar wait.
## 3. Della strengths missing or materially weaker in Ludwig
- **K1 Grant lifecycle:** D `:212-235,290` separates source credentials from mediated
access, enforces human grant approval, expiry, revoke races and manual mode (F2).
- **K2 Crash-safe work:** D `:131-156,291` defines leases, cursor/outbox atomicity,
recipient receipts, bounded reconciliation and separate replay generations (F3/F4).
- **K3 Data minimization:** D `:176-199` uses metadata-only baseline, explicit fetch,
encrypted scoped storage/cursors, keyed fingerprints, backup expiry, holds and
deletion authority. L's always-raw plaintext store does not provide these controls.
- **K4 Resource bounds:** D `:138-146,158-164` names slice, retained-byte, pending-work,
MIME-depth/part/text bounds and holds under audit/disk failure. L names a raw-size cap
but leaves per-run limits unspecified and parser expansion/cumulative quotas uncovered.
- **K5 Manager contract:** D `:260-277,298` includes one retry owner, source/business
scope, synthetic canary, uncertain-effect fencing, manager batches and human-kill
persistence. Keep these and add Ludwig's explicit repeated-exit escalation.
- **K6 Security acceptance:** D E02/E08/E09/E11 `:290,296,297,299` tests hand grants,
stale approval, manual mode, revoke/in-flight races, cross-business access and backup
restore. L's cross-seat test and reduced tool list do not exercise these boundaries.
## 4. Disagreements and recommended resolution
| Ref | Difference | Side and reason |
| --- | --- | --- |
| D1 | Graph first in D E05 vs IMAP first in L M4 | Choose the first approved pilot source, not a protocol mandate. Prefer IMAP for a personally controlled IMAP mailbox. Prefer Graph if the approved mailbox is M365. Both remain post-v1 adapters. Reusing design does not require retaining USC infrastructure. |
| D2 | D aliases first vs L plus-addressing first | Take D's requirement for provider-verified original-recipient evidence. Choose aliases or plus-tags only after that proof and Jason's address ruling. Neither is an access boundary. |
| D3 | D metadata-only/explicit content vs L stores every raw message/attachment | Take D's default and scoped encryption. Raw retention is an explicit optional approved policy, not unconditional intake. Keep raw-fidelity fixtures to test parsers without retaining every live message. |
| D4 | D 30-day bodies/drafts, 7-day attachments vs L 90 days for all content | Recommend D's shorter pilot limits, 365-day minimal evidence in both. Jason decides business retention/holds. Add quotas and backup expiry rather than relying on retention days alone. |
| D5 | D scanner before isolated extraction vs L no scanner initially | Start with metadata only. Fetched bytes stay quarantined. Require isolated extraction and scanning before model-visible extracted content, or explicitly limit the first release to opaque retrieval without extraction. A read-only workspace bind does not stop code from reading/executing bytes. |
| D6 | D minimal explicitly permitted content vs L metadata-only model classification | Take L's metadata-only baseline, corrected by F7. Both need an approved mail decision schema and useful-result fixtures. A later content field needs explicit approval, not an alternate decision client. |
| D7 | D optional POP3 row vs L no POP3 build | Take L's deferral until Jason names a POP-only need. Preserve the compatibility boundary and safe UIDL/no-DELE requirements as a documented future condition, not a committed implementation row. |
| D8 | D ordinary governed agent work vs L restricted mail-turn authority | Take L's explicit restriction with G1's fresh-context and propagation conditions. Preserve a human-approved route for later promotion to ordinary work. |
| D9 | L's instance-secret slot vs D's dataRoot slot example | Take L's config-directory placement, with an explicit new service-principal schema. D `:233` is wrong against S `packages/business/src/credentials.mjs:95-102`, which forbids token files in dataRoot. Correct that example in phase C, not the validator. S `packages/bus/src/credentials.mjs:216-239` also constrains loaded locations. |
Neither interface spelling wins on safety. In phase C choose one naming scheme and
declare exact schemas once. Add descriptor/capability discovery to L's source exports.
Keep broker-mediated tools identical across harnesses, local-only drafts and no send.
Neither plan's later live row is authorization to connect now.
## 5. Merged Jason questions (no duplicate decisions)
These replace, rather than append to, both original question lists. Origin labels
refer to the original question numbers, not the new numbers below.
1. **Q1 Pilot provider/domain/addresses:** which controlled domain, provider and agent
ingress mailbox should be enrolled, and aliases or plus-tags? **Recommend** one
approved non-USC agent mailbox, no catch-all/MX change, with proven recipient
preservation. Build its protocol first. Dedicated mailboxes only for an approved
isolation need. (L Q1/Q2, D Q1.)
2. **Q2 Personal mail scope:** which existing user mailbox, if any, may be read?
**Recommend none initially**. Later enrollment names the user/mailbox and exact
consent/delegation, separately from agent ingress. (L Q8, D Q1/Q2 personal scope.)
3. **Q3 Source mutation:** should intake mark read, flag, label, move or save drafts
into the provider? **Recommend no**, read-only source access and local drafts.
(L Q3 and provider-draft portion of Q4.)
4. **Q4 Service/access grants:** approve a protected mail-service credential owner and
distinct per-agent/user access grants under the chief/human approval lifecycle?
**Recommend yes**, via reviewed post-v1 PRD/vocabulary changes, a new mail-access
hard-wait action distinct from provider credential creation, and exact targets,
rights, expiry and immediate local revoke. (D Q2.)
5. **Q5 Unattended wake:** which senders/routes may wake agents without per-message
approval? **Recommend** explicit sender allowlist plus trusted aligned authentication,
approved route/access grant, restricted mail-turn profile and manager admission.
Manual mode overrides it. Authentication failures/ambiguity quarantine for review.
None of this approves external effects or user decisions. (L Q5, D Q3.)
6. **Q6 Content/retention/deletion:** what content may be retained, for how long, and
what holds apply? **Recommend** encrypted 30-day body/draft, 7-day explicitly fetched
attachments, 365-day minimal evidence/tombstones, metadata-only until approved.
Preserve exact `data.delete` authorization, backup expiry and restore suppression.
(L Q6, D Q4.)
7. **Q7 Dependencies:** accept maintained IMAP/MIME dependencies after review and exact
version pins rather than custom protocol/parser implementations? **Recommend yes**,
assess Ludwig's named candidates during their owning rows. This is routine technical
review unless repository policy adds a dependency approval gate. (L Q7.)
8. **Q8 Attachment handling:** may attachment bytes be fetched/extracted, and is a
scanner required? **Recommend metadata-only first**, explicit quarantined fetch,
isolated no-network extraction plus scanning before exposing extracted content.
Do not enable arbitrary attachment execution or automatic archive expansion. (L Q9.)
9. **Q9 New exposure/spend:** authorize any new listener/webhook, DNS/MX change, paid
infrastructure or model spend? **Recommend none initially**. Outbound monitoring
and existing internal notifications suffice. Each later change has its own approval.
(D Q5, overlapping listener boundary in L Q2.)
10. **Q10 Sending:** should this add-on ever send/forward/reply? **Recommend no**.
R21 remains for the specialist. A future separate sender needs its own approved
plan and exact-recipient human approval through `message.external`. (L Q4, D Q6.)
11. **Q11 Prior repositories/service:** archive the older repo or change any existing
USC service? **Recommend no operational change in this assignment**. Keep both
histories. Treat MP as the consolidated design reference. Any archival or service
retirement is a separate authorized task after verifying actual deployment state,
which this review did not inspect. (L Q10.)
## Cross-review reconciliation received before publication
Ludwig's `ludwig-0002` arrived during finalization. Read all 102 lines at Stack
`143415779:docs/plans/2026-10-21_email-review-by-ludwig.md` (LR). The original plans
remain unchanged. These are phase C recommendations, not changes to their text.
- **C1 Agreed corrections:** LR `:12-19` identifies D's duplicate-check line nit,
invalid dataRoot credential slot and overbroad `credential.mint` wording. Correct
them in phase C. This review's F2 and Q4 now explicitly distinguish access rights
from provider credential issuance. LR `:62` recommends no agent reroute. Adopt that
initial tool boundary: human-approved address rules route intake, and agents cannot
widen readership by rerouting a message. Keep any later reroute/delegation design
separate, with exact access authorization and no content laundering through bus text.
- **C2 Shared graft:** LR `:26-39,63-72` agrees with most durability, grant, retention,
quota, provider-order and attachment controls above. Its secret-scan result may be
included only as Ludwig's attributed, pattern-based finding, not my independent scan
and not proof of absence or that no rotation could be needed (F10).
- **C3 Keep qualifications:** LR `:65` says key deletion is the only way expiry reaches
backups. That is too absolute: a design may delete backup copies or use crypto-erasure,
but crypto-erasure works only if all relevant key copies/escrows expire too. Specify
the actual restoration/expiry controls. LR `:67` must not imply aliases avoid delivery
provenance proof. Both aliases and plus-addressing require that proof (F9/D2).
## Review receipt
Read Ludwig's complete pinned plan, compared both proposals and checked the cited
implementation/PRD/manager passages. Confirmed repository ancestry and equal source/test
trees. Public Graph documentation was checked for F5 on 2026-10-21. No live mailbox,
secret file, service, implementation, dependency or MVP/queue change occurred. Canonical
`queue next della` returned `nothing`. Static checks cover review structure, cited
line existence, question-source coverage and unchanged original plan blob. No product
suite or live validation was run. Commit/push this review on `della/email-plan`, then
deliver della-0002 immediately to Mos and Ludwig under Q210. Phase C remains Mos's call.
@@ -0,0 +1,89 @@
# Post-v1 row briefs: Q198 S1 and the READY email rows
Sage, 2026-10-21. These rows are post-v1. MVP rows (Vikunja 414 and 416) and re-pin windows keep first claim
on Sage's gate slot (mos-1221, mos-1225). The owner may build and run static checks while the slot is busy.
Tests still run only on a CLEAR. Owners come from seats that v1 doesn't need. Sage names the owner and the
reviewer when a row starts.
Every row here has the same boundaries:
- No test or run touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units, a slot file, .53 or .54.
- No credential, live mailbox, DNS, external mail, paid call or re-pin. Network is off in tests.
- Gate TMPDIR comes from `mktemp -d /mnt/storage/scratch/tmp/gXXXXXX`, never /tmp.
- New dependencies are exact-pinned, and BUILD.md records their license and advisory review.
- Design notes go in `agents/<seat>/work/queue-<row>/BUILD.md`.
## Brief: Q198 S1 pressure parser and pure rules evaluator
Source: `docs/plans/2026-10-21_q198-system-manager.md`, section 8 row S1, with the signals and thresholds in
section 4 (D4). Huey designed it, and Scrooge approved r6 and r6.1 (mos-1222, Vikunja 452).
Build a parser for the pressure inputs D4 names (`/proc/loadavg`, `MemAvailable`, cgroup memory current, max
and events, CPU, memory and I/O PSI, effective CPU count and memory limits from cpuset and cgroup as well as
the host). Also build a pure evaluator that takes a sequence of timestamped samples and returns hold or release
with a reason. Parsers take file text as input, and tests feed fixture text. Nothing reads `/proc` or
`/sys` in a test.
The evaluator implements the D4 table exactly:
- The hold thresholds, with "for 3 samples" where the table says so.
- The release thresholds. Release needs all applicable low thresholds held for 30 seconds.
- `health-unknown` hold when a required signal is missing or older than 6 seconds.
- The critical-pressure row.
It refuses contradictory settings. Provider scope (429, quota) belongs to S3 and S5, not here.
Not in this row: a live sampler, any process or timer, the bus, the Console, admission records or
enforcement. Those are S2 and S3.
Tests: synthetic load, memory, PSI and missing-signal cases. Each threshold is tested on both sides of its
edge. Hysteresis uses a fake clock. Effective CPU and memory limit cases cover cgroup v2 `max`, cpuset lists
and a host-only fallback. No builds and no real pressure generation.
## Gate: Q198 S1
The reviewer approves the manifest. On Sage's slot, every touched test file passes whole, along with the
suite of the package that holds the module. Two mutants must be killed: one that moves a hold threshold
across its edge, and one that releases before the 30 seconds pass.
## Brief: E01 mail PRD addendum draft and schemas
Source: `docs/plans/2026-10-21_email-plan.md`, "Ordered build rows", row E01. Della wrote the plan, and Ludwig
approved it (ludwig-0005, Vikunja 460, mos-1223). The deliverable and the acceptance tests are the ones in
that row.
The PRD addendum is a draft. Its wording goes to Mos first, then Jason. It doesn't change
`docs/prd/mosaic-stack.md` until Jason approves it. The schemas live in `packages/mail/schemas/`. Core
mail-source registration (`packages/plugins/src/kinds.mjs`) stays unchanged (WAIT Q4).
## Brief: E03 mail ledger, state and quotas
Source: email plan row E03, after E01. Deliverable and acceptance tests as written there. No dependency on
live grants.
## Brief: E04 bounded MIME parsing and quarantine
Source: email plan row E04, after E01. Deliverable and acceptance tests as written there. The MIME
dependency gets the Q7 license and advisory review in BUILD.md.
## Brief: E05 Graph source module against a test-local registry
Source: email plan row E05, after E01, E03 and E04. Offline only. `packages/plugins/src/kinds.mjs` stays
unchanged until the E01 addendum is approved and Q4 is answered. The existing plugin suite passes.
## Brief: E06 IMAP source module
Source: email plan row E06, after E05's test-local shared contract. No core registration. The IMAP
dependency gets the Q7 review.
## Brief: E07 address and rule evaluator
Source: email plan row E07, after E01, E03 and E04. Pure evaluation only, with no org-file activation and no
delivery privilege.
## Gate: email rows
The reviewer approves the manifest. On Sage's slot, the row's named tests pass whole, along with the mail
package suite and any existing suite whose files the row touches. The reviewer names the mutants from the
row's acceptance tests at manifest approval.
E12 and E14 are READY in the plan, but their prerequisites include WAIT rows (E08, E13) and Q198 D13-D15
(S5). Sage queues them once those rows exist, so the queue never marks them startable early.
@@ -0,0 +1,629 @@
# Q198: mechanical system manager
Build a rules-first manager for admission, bounded retries, recovery and host health.
System stop and health requests bypass the work queue, without bypassing authorization.
Normal and background work wait under pressure, then resume gradually and fairly.
An interrupt requests cancellation. Capacity stays occupied until the owner proves exit.
Outages enter system-owned protected mode, then probe and resume automatically.
Unknown external effects require reconciliation, never automatic replay.
Reuse the bus, controllers, launch registry, run evidence and Console.
Optional decision models rank already-permitted routes. Recommend Jev when enabled.
Model calls have a timeout, a durable spend cap and a strict metadata allowlist.
No live trial, key installation or runtime change is authorized by this design.
MVP work (Vikunja 414 and 416) keeps first claim on Sage’s gate.
Brief: mos-1183, DECISIONS Q198/Q199, Vikunja **452**. Owner: Huey. Design only.
Revision **r6.1**, mos-1222, applies scrooge-0004 N1–N3 to approved r6 `2ee68cd69`.
Q22 is resolved. New implementation choices are Huey’s readings.
All defaults below are proposals, not measured safe operating limits or new authority.
## 1. Existing pieces and reuse
Code citations throughout this document mean **commit `6c75887b0`**, even when a later branch
changes the line. Inspect with `git show 6c75887b0:PATH | nl -ba`. The gate candidate
is explicitly cited at `d2de56bd2` instead. Plans establish intent, not running behavior.
| Ref | Evidence on the pinned tree | Manager boundary |
| --- | --- | --- |
| F1 Bus | WAL SQLite, event kinds and launch events: `packages/bus/schema.sql:1-30`. Broker records launch identity and checks repeat records: `packages/bus/src/broker.mjs:533-586`. | Keep the bus as authority for managed identity, claims and permissions. Add reviewed schema migrations for request records/events, not a second task bus. |
| F2 Queue | Development row states and privileged seats: `packages/queue/src/queue.mjs:13-18`. Canonical checkout/branch restrictions: `packages/queue/src/store.mjs:61-91`. | Keep Sage’s development queue intact. Runtime scheduling needs a separate request type and state machine, not automatic moves of development rows. |
| F3 Runs | Run document reader: `packages/runs/src/runs.mjs:5-6,32-71`. Retry creates a new run from the snapshots with `retriedFrom`: `scripts/mosaic-task.mjs:528-580`. | Reference original evidence and produce a fresh attempt. The retry command is a replay mechanism, not proof that replay is safe. |
| F4 Harness | Runner signals its child group with TERM then KILL: `packages/harness/src/runner.mjs:280-303`. Usage begin/end surrounds a turn, including failed turns: `packages/harness/src/runner.mjs:433-444`. | Admission belongs before turn dispatch. Preserve usage collection on failure. Do not add another harness execution loop. |
| F5 Conversation | Interrupt fences, clears and aborts through the controller, then requires settled state/proofs: `packages/conversation/src/controller.mjs:1267-1495`. Force-stop confirmation shape: `packages/conversation/src/controller.mjs:100-111`. | Request the existing controller operation with its existing capability and confirmation requirements. A scheduling policy cannot synthesize human confirmation. |
| F6 Cohort/shim | Scope identity validation, TERM, freeze, enumeration and cgroup kill: `packages/conversation/src/cohort.mjs:152-200`. Process-group fallback returns unavailable: `packages/conversation/src/cohort.mjs:156-171`. Shim implements those operations: `packages/conversation/src/shim.mjs:130-167`. | Reuse stop evidence. Never free capacity on signal delivery alone. Preserve uncertain states and quarantine. |
| F7 Seat | Launch/run paths and append log: `packages/seat/src/session.mjs:43-49,107-139`. Registry liveness checks PID start time and stop verifies it: `packages/seat/src/session.mjs:230-269`. | Register ownership before launch and use instance/run identity. Runner exit alone does not prove all external work ended. |
| F8 Board/Console | Board rescans and has reply/seen writes: `packages/control-board/src/serve.mjs:230-264`. Console already exposes usage, limits, instances and routes: `packages/webui/src/serve.mjs:60-111`. | Put manager health and request views in the Console. Reuse attention presentation concepts, without creating new Board writer endpoints. |
| F9 Limits/usage | Launch and post-turn limits: `packages/bus/src/limits.mjs:1-5`. Usage rows are append-only token counts with known/unknown status, no money column: `packages/bus/schema.sql:339-365`. | Limits remain hard constraints. Decision-model USD accounting needs a reviewed extension, not pretending existing token rows contain cost. |
| F10 Routes | Launch fallback runs only after primary preflight fails: `packages/cli/src/launcher.mjs:256-295`. | Preserve that behavior. Runtime model/provider rerouting is new, separately reviewed work. |
**F11 Kill switch and lifecycle.** Row 191’s brief requires a persistent, human-only,
per-instance stop covering launches and running work (`docs/plans/2026-10-20_kill-switch.md:12-23,37-47`).
Row 194’s brief requires per-agent vetoes, RBAC and kill-switch checks
(`docs/plans/2026-10-20_agent-lifecycle.md:27-35`). At this base, launch revoke/restore
are human-only broker operations (`packages/bus/src/broker.mjs:3189-3203`). Treat the
briefs as integration contracts, not evidence those whole features landed. The manager
must defer to their final reviewed implementations, including the brief’s credential
revocation requirement (`docs/plans/2026-10-20_agent-lifecycle.md:41-45`).
**F12 Gate lanes are a separate owner.** On **`d2de56bd2`**, grants, private roots,
port bands, quarantined receipts and image locking are described at
`scripts/testing/README.md:3-9,21-49,59-71`. Implementation scans unmanaged test,
Docker and mutant command shapes, not arbitrary heavy work
(`scripts/testing/lane-state.mjs:48-76`, especially `:64-67`). The scan contains no
load measurement. Reuse its lease/status/recovery interface after adoption, never
replace its locks. The manager’s grant for capacity cannot grant Sage’s test slot.
**F13 Failure context.** The brief reports proof 4 failed K12 during paired gates.
The pinned K12 is the fork/freeze/enumeration test
(`packages/conversation/tests/cohort.test.mjs:263-276`). This does not establish host
saturation or prove load caused the defect. Vikunja 450 fixes K12 and 451 adds the
separate gate load refusal. Neither waits for this manager or becomes a retry workaround.
**F14 CLIProxyAPI.** Jason’s target includes built-in CLIProxyAPI with WebUI
(`/mnt/storage/src/jason-master-restart/TARGET.md:24`, owner requirement, not a Stack
implementation claim). The public [upstream configuration](https://github.com/router-for-me/CLIProxyAPI/blob/main/config.example.yaml)
(viewed 2026-10-21, mutable upstream) has routing retries, credential cooldown and
stream bootstrap retries. It does not establish the installed version’s configuration.
Keep proxy credential selection in the proxy. Stack owns host admission and request
identity. Before enabling runtime retries, verify the deployed proxy contract using
stand-ins and coordinate retry budgets. Do not read its management key or consume
management usage feeds. Do not assume its chat-compatible endpoint supports Decisions.
## 2. Ownership and durable requests
**D1 Placement.** Implement a mechanical host supervisor component, outside agent
turns, with a short asynchronous scheduler loop. The bus remains the sole writer of
its runtime request journal through a reviewed migration. No broker transaction spans
a model/network call. The supervisor reserves host capacity and calls existing owners
for launch, interrupt and stop. It cannot change roles, secrets, lifecycle vetoes or
kill-switch state. The emergency stop path must work without the scheduler, model,
provider or a healthy work backlog. Bound work-handler concurrency and keep control
handling outside that pool. This is software scheduling, not hardware/real-time priority.
**D2 Record.** Persist immutable request ID, caller/business/instance, operation,
content reference plus digest (content stays at its authorized source), policy revision,
class, enqueue time/deadline, resource estimate, allowed routes and idempotency key.
Append transitions: `held → ready → leased → running → succeeded|failed|cancelled`,
with branches to `retry-wait`, `outage-paused`, `dead-letter`, or `uncertain`. Record every attempt,
route, reason code, owner identity, heartbeat and evidence references. Materialized
state is rebuildable. Re-check permission, kill state and policy at dispatch/replay.
A duplicate key with the same digest returns the original request. A changed digest
refuses. Terminal receipts and existing runs are never rewritten.
Use one dispatcher lease per instance with generation fencing. A launch intent is
durable before spawn. After a crash or expired lease, reconcile registry/cohort and
receipts before replacement. An expired heartbeat is not evidence of death. Ambiguous
spawn/effect outcomes enter `uncertain` and continue to reserve capacity. A killed
instance remains held after restart until the authorized clear and health checks.
**D3 Scope.** Host pressure includes unmanaged work, even when the manager cannot stop
it. Owned resource envelopes are per instance. Multiple instances must not each assume
the whole host is free: use statically partitioned host budgets initially. Shared
cross-UID arbitration needs a separately approved host-service boundary. No process
name scan grants cancellation authority over another user or instance. Configuration
extends the instance’s one system config through the normal reviewed schema/bootstrap
path, never a second configuration file or automatic rewrite of an existing config.
Decision spending is separately scoped per exclusive key, not per host or shared
account. When JEV is enabled, D11 requires its dedicated key per instance. Other
endpoints use their isolated credential/billing contracts in D11/D12. Shared keys are unsupported
until a shared ledger and its authority boundary receive separate review.
## 3. Classes and interrupt semantics
| Ref/class | Requests | Admission and preemption |
| --- | --- | --- |
| P0 safety | Authorized kill switch, explicit stop/cancel, ownership fencing | Bypass backlog and load hold. Local mechanical path, no model. Deduplicate per target. Authorization is checked before accepting priority. |
| P1 system | Lightweight health reads, lease reconciliation, automatic outage recovery under Q205/Q206 | Reserved control capacity. Health/reconciliation can run under pressure. Resume needs capacity, current policy and a clear human kill switch, without a new approval per session. Never auto-clear a kill. |
| P2 normal | User/agent turns, ordinary task dispatch | FIFO within tenant, fair rotation across tenants. May request cooperative yield from background work. User text saying “urgent” does not confer system authority. |
| P3 background | Builds, indexing, maintenance, optional evaluations, granted test gates | Lowest resource admission priority. A granted gate retains Sage’s independent exclusivity and evidence rules. |
DECISIONS Q205/Q206 supersede the old P1 per-session recovery approval question.
Huey’s reading: the configured recovery policy supplies standing authority to resume
eligible paused work. Current permissions, hard capacity and the human kill switch still
apply. Health/reconciliation retain reserved capacity. Protected mode can clear itself,
but cannot clear a human kill or grant new authority.
| Work | What interrupt means | Hard-stop boundary |
| --- | --- | --- |
| Agent turn | Fence new dispatch, ask its controller to abort, reconcile queued input and effects (F5). | Escalate only through an already-authorized force stop/kill path. Uncertain proofs retain the reservation (F6). No automatic reissue of the prompt. |
| Test gate | Ask its gate owner to cancel at a step boundary, record interrupted attempt, run owned cleanup. | A safety stop may terminate an owned cohort. Incomplete cleanup quarantines the lane. No green receipt, automatic lane unlock or retry-to-green. |
| Build | Cancel a registered subprocess/cohort at a declared safe point. Discard incomplete disposable output. | Publication, image tagging, package installation or release activation may have effects. No load-driven KILL there. Safety stop can still terminate, with reconciliation required. |
| Long tool call | Propagate cancellation only where the tool supports it. A transport timeout records unknown outcome. | Remote writes cannot be undone by killing the local caller. Reconcile provider operation ID/status. No blind reroute, retry or claim of rollback. |
Priority never overrides a permission refusal or a hard resource limit. No SIGSTOP
pausing by default: it can retain locks and memory while blocking the urgent work.
Reserve control-plane headroom through concurrency limits and separately bounded
process resources. Validate responsiveness under pressure before claiming an SLA.
## 4. Admission, cooldown and fairness
**D4 Proposed initial signals**, sampled every 2 seconds. Use effective CPU count and
memory limits (cpuset/cgroup as well as host), `/proc/loadavg`, `MemAvailable`, cgroup
memory current/max/events, swap activity, and CPU/memory/I/O PSI. Observe known work
reservations, disk headroom, provider concurrency and response metadata (429,
`Retry-After`, reset time, timeouts, 5xx). Do not scrape command lines or environments
into telemetry. A missing/stale required signal (>6 seconds) holds new heavy work
with `health-unknown`, while stop and bounded local diagnostics remain available.
| Signal | Hold new heavy work | Release condition |
| --- | --- | --- |
| One-minute load / effective CPUs | ≥0.80 for 3 samples | ≤0.55 |
| Available effective memory | <15% or <1 GiB, whichever demands more free memory | >25% and >2 GiB |
| PSI avg10, percentages | CPU some ≥20, memory full ≥2, or I/O full ≥10, for 3 samples | CPU some <10, memory full <0.5, I/O full <3 |
| Critical pressure | New OOM event, memory <5%, or free manager-volume space <1 GiB | Reconciliation plus healthy thresholds, no automatic kill-clear |
| Provider scope | 429 or exhausted declared quota | Retry-After/reset elapsed and one successful permitted probe |
Release only after **all applicable** low thresholds hold for 30 seconds. An admission
uses both measurements and outstanding reservations, recorded atomically before
launch. A request larger than its configured envelope refuses or waits for an
explicitly supported exclusive envelope, never an unbounded resource guess. Start with one heavy dispatch per instance and one release every 5 seconds,
then measure before raising concurrency. Provider cooldown blocks only affected
provider/account/model scopes, not all host work. An open provider circuit waits at
least 30 seconds (or longer Retry-After), permits one half-open probe, and reopens on
failure. Never shorten an upstream wait to fit a local retry delay. A declared outage
uses D13–D15’s Q206 probe schedule instead of this short transient-failure circuit.
These thresholds do not replace the gate-specific load-under-4 rule in mos-1182 or
Vikunja 451. Adapters must satisfy both controls. Tune from observed queue wait and
pressure, with reviewed config, not model output. Reject contradictory settings.
**D5 Held work.** Return an accepted receipt and visible hold reason/next evaluation,
not a blocking connection. Default queue bound: 1,000 outstanding requests per instance,
100 per caller, envelope ≤16 KiB. Excess receives an explicit refusal with retry advice.
P0 uses a separately bounded, target-deduplicated control path. Journal failure refuses
new work and alerts, without disabling the existing emergency stop path.
Normal requests expire after 30 minutes waiting, background after 24 hours, unless an
explicit authorized deadline differs. Outage-paused time is excluded under D6.
Show expiry and retain its receipt. Once healthy,
weighted service is 4 normal : 1 background among eligible requests, FIFO per tenant.
After 10 minutes, background ages into normal scheduling rank, never into system
priority and never through pressure holds. After control bursts, service eligible work.
Rate-limit/coalesce health reads. Continuous real emergencies can still prevent progress:
alert on oldest wait >10 minutes rather than promise starvation freedom under overload.
## 5. Retries, dead letters and routes
**D6 Retry ownership.** One logical request gets at most **3 total manager attempts**,
10 minutes of retry time and its effective deadline, whichever ends first. Retry only
transient failures known to precede effects, or operations with verified idempotency.
Use full jitter `0..min(60s, 2s × 2^(attempt−1))`, with upstream Retry-After as a minimum.
Re-enter admission on every attempt. Restarts preserve counters/deadlines. Proxy/internal
retries must fit a declared per-attempt budget. If that budget is unknown, do not layer
automatic manager retries over the proxy. Max one route change within the three attempts.
**Outage exception (Q205/Q206).** Work paused by an outage keeps its remaining three-attempt
allowance and remaining ten-minute retry window. Neither resets nor advances during the
pause, including the outage-causing failure once classified as an outage. It never enters
the DLQ because of that outage. Huey’s implementation reading: correlate a candidate
outage for at most 30 seconds, then settle each failure as either an evidenced D13
outage or a request-local failure. A lone bad request cannot freeze itself indefinitely.
Resume continues the same logical request, not D7 replay. After a successful test session,
request-local failures in resumed work consume that request’s D6 attempts/time normally.
Persist a separate count of request-local failures across distinct resume episodes.
A request failing in two separate resumes goes to D7 with both episodes’ evidence,
even if its three-attempt allowance is not exhausted. Correlated outage failures do
not count toward that two-resume rule. These classification/counting details are Huey’s
reading. They prevent one defective request from repeatedly claiming an outage exemption.
Huey’s reading of deadlines: freeze queue-age expiry and caller-set execution-deadline
budgets during the union of outage-paused intervals, never counting overlapping service
outages twice. Keep the original timestamp and publish the effective deadline extended
by paused time. This deliberately interprets a caller-set deadline as an execution budget,
not an immutable wall-clock cutoff. Explain that in the acceptance receipt. Cancellation
still takes effect during a pause. Journal pause intervals and counters across restarts.
An interrupted effect remains uncertain until reconciled, regardless of the clock.
Huey’s reading: configure a maximum outage-pause age, default **24 hours of cumulative
paused wall time per request**, through D3. On reaching it, make the request terminal
`expired`, never DLQ. Show pause age, limit and effective deadline in the Console.
Expiry prevents dispatch but never releases capacity still held by an unproven stop.
This maximum bounds the deadline extension above and does not authorize effect replay.
D6 covers managed work requests only. Existing notifier delivery backoff
(`packages/cli/src/notifier.mjs:33-34,257-315`) and Discord gateway reconnect backoff
(`packages/discord/src/gateway.mjs:40,120-133`) stay out of scope and keep their own
owners. Do not wrap either loop in manager retries or count reconnects as work attempts.
Any later consolidation needs its own design and acceptance tests.
| Outcome | Action |
| --- | --- |
| Mechanically classified outage (D13) | Outage-paused, counters/clocks frozen, service probes under D14. No outage DLQ. |
| Isolated connection refusal before send, explicit 429/503 with no accepted work, safe read failure | Bounded retry/cooldown when adapter proves eligibility and D13 does not classify an outage. |
| Invalid schema, policy/capability denial, kill active, 401/403, missing configuration | Refuse or hold for authorized correction. No repeated calls or alternate route to bypass denial. |
| Partial stream, lost acknowledgment, tool write timeout, incomplete stop proof | `uncertain`. Reconcile first. Cancellation does not establish absence of effects. |
| Non-outage retry budget/deadline exhausted, deterministic request failure | Dead-letter with evidence and actionable reason. Non-outage admission expiry is an expired terminal item. |
| Explicit user cancellation | Terminal cancelled. Never retry automatically. |
**D7 DLQ.** Only requests that fail on their own use this path, not outage-paused work.
Validation/permission refusals remain refusals under D6, with their request evidence.
A durable request state and append-only disposition history, not copied
prompts or modified runs. Show request/attempt IDs, class, wait/route history, redacted
error code, idempotency/effect status and links to authorized evidence in Console
“System → Requests → Dead letters”. Propose equivalent `mosaic system requests
show|list` and `mosaic system replay REQUEST --expect-revision REV` verbs. These are
proposed interfaces, not available commands. Mos sees permitted items and recommends
replay. Jason or an explicitly delegated role can replay after cause correction and
reconciliation. Replay creates a new request linked to its parent, with a fresh bounded
budget and current policy. No mass replay, unattended replay chains or resurrected
cancelled work. Unknown effects require explicit reconciliation evidence before replay.
D15’s automatic batch continuation of outage-paused work is not a replay chain and
requires no person to release each request. An outage never sends those requests here.
**D8 Rerouting.** Candidate routes come only from the user’s configured allowlist,
filtered for model capability, tool protocol, context, privacy/region, account budget
and role constraints. Prefer same model/provider recovery, then a configured equivalent
provider, then a pre-approved alternative model. Never downgrade required capabilities
or migrate a live transcript silently. Mid-turn failure is reconciled before a fresh
attempt. Record requested and effective route and reason. New provider consent/spend
is separate from enabling the decision hook. F10’s preflight fallback remains distinct.
## 5a. Protected mode and outage recovery
**D13 Protected mode (Huey’s reading of Q205/Q206).** The supervisor owns a durable
`normal → protected → checking → canary → batch-resume → normal` state per affected
service scope, with a host/network incident able to hold all dependent scopes. This is
separate from the human kill switch. D1 remains unchanged: the manager never sets or
clears kill-switch state. Detection/status tracking are mechanical, without an LLM.
Proposed entry thresholds, all Huey’s readings: three consecutive failed network checks
at the 2-second sample cadence before protected mode enter a network incident.
Once protected, active checks follow D14’s schedule. For a provider/account scope,
enter after at least five endpoint/dispatch results in 30 seconds with ≥80% transient
network/429/5xx failures across at least two distinct requests, or three consecutive
failed endpoint checks. One explicit account-wide quota/reset response can enter its
scope immediately. Isolated validation/permission failures and model output/tool errors
do not count. Request-local failures after a successful test session use D6, never
D13 by themselves. Re-entry based on resumed requests requires the correlated failure
threshold across at least two distinct requests, not one bad request repeated. Until wider
failure scope is proven, hold only that route/account. A missing required observation
holds work as unknown rather than guessing recovery. Persist incident and classification
before advancing affected request outcomes. These settings use D3’s reviewed config path.
Fence affected new dispatch. Pause uses only F5’s cooperative interrupt at safe points.
F6 supplies stop proof, never the pause action. Do not invoke force-stop for a pause.
Pause means a proven quiescent checkpoint/turn boundary, not SIGSTOP or a presumed
rollback. Keep reservations until stop/idle proof arrives. If proof is unavailable,
record uncertain ownership and retain capacity. Do not force-stop a write or manufacture
human force-stop confirmation to obtain a checkpoint. Reconcile interrupted effects
mechanically by operation ID/receipt when possible. Ambiguity stays fenced. Unaffected
services can continue under admission. No person releases each paused session.
**D14 Probes.** Following Q206 and Mos’s recorded reading, each service check first
performs mechanical network and endpoint checks. Only after the endpoint answers may a
one-turn headless probe run through a minimal harness config. A check of a dead link
makes no model call. Huey’s reading: use a fixed synthetic input, no tools, user content,
workspace effects or inherited worker environment, and separately authorized probe
credentials. For JEV, the probe harness invokes the typed decision adapter, not a chat
request. Probe success means the endpoint plus the configured minimal harness/model
path succeeded, not merely an HTTP connection. One successful complete probe marks that
service online. Missing credentials, exhausted applicable quota/budget or unknown
billing classification keep the headless check deferred/unverified. Subscription-only
probes need verified usage/quota accounting, not an invented per-call USD price. Paid
probe charges use D11’s separate probe budget. No synthetic success or unauthorized spend.
Under Q207 and Mos’s recorded reading, **every service probes in parallel** on its own
schedule. JEV is not a gate for another service. Each has a durable probe schedule:
wait 60 seconds, then after each failure wait 120, 180, 240, 300, 360, 420, 480, 540,
600 seconds, then 600 seconds repeatedly. There is **no attempt limit**. Wait at least
Retry-After or the usage-limit reset before probing, even if longer than 600 seconds.
The ceiling bounds this schedule, not a provider-mandated wait. Unknown reset time
keeps the scope held and visible rather than guessing a usable quota. Service probes
are separate from D10 routing calls and never retry a paused work request.
Huey’s reading: permit one in-flight probe per service, with a 5-second mechanical-check
deadline and a 30-second headless-probe deadline. Hung probes fail with owned cleanup
and uncertain outcomes retain their reservations. These deadlines are independent of
D10’s endpoint-specific decision timeouts. Scheduling retries indefinitely does not
override D11: when applicable quota or probe funds are unavailable, keep mechanical
checks running on schedule and defer headless probes until authorized capacity returns. A human
kill prevents headless probes/agent launches, while local read-only health tracking
continues. The kill can be cleared only through its existing human path.
Huey’s reading for optional endpoints: mark disabled/unconfigured JEV or a fallback as
not configured and make no call to it. Other services keep their independent schedules.
A failed or budget-deferred decision endpoint never blocks their probes.
**D15 Resume (Huey’s reading of Q205/Q206 and Mos’s recorded reading).** After service
online proof and D4’s healthy release thresholds, reconcile paused owners/effects and
run **one test session** on an eligible online route. Use disposable synthetic work,
current policy and bounded capacity. No request with uncertain effects is used as a
test. On success resume eligible paused work in batches of at most five, with one
30-second healthy observation interval between batches and D4’s stricter dispatch
rate/reservations still enforced. A batch needs no new approval per session. A failed
test session returns to protected mode with its evidence, without relabelling an
otherwise healthy provider as offline. After the test succeeds, “batch fails” means
D13 proves a correlated outage across distinct requests, or independent mechanical
network/endpoint evidence establishes an outage. Only that returns the scope to
protected mode. Individual resumed requests failing validation, tools or model output
consume D6 budgets and follow the two-resume/D7 rule, while healthy batch members
continue. A failed request alone never counts as a failed automatic exit.
Huey’s proposed escalation count: after **three consecutive failed automatic exits**
(canary or batch stages) in the same incident, alert Mos/Jason and hold production
resume for diagnosis. Continue mechanical monitoring and budget-permitted probes
without an attempt limit. A fully successful exit resets the exit-failure count. This
is the repeated-exit boundary where a person is needed, not a prompt for each session
or each failed probe. Existing policy, missing credentials and unresolved external
effects remain fail-closed constraints, never reasons to improvise authority.
Huey’s reading of release after three failed exits: persist `recovery-escalated` on the
incident. Later successful probes update service health but **do not** restart work
automatically while that latch is set. Jason, or a role already explicitly delegated
recovery authority, releases it through proposed human write `system.recovery.release`
(scope, expected incident ID and revision), also exposed as Console “System → Recovery →
Release after diagnosis”. The authenticated/scoped writer checks authority, current
incident/revision and diagnosis evidence. It clears only this escalation latch, resets
its exit counter and restarts the one-test-session stage, never bulk dispatch. It does
not clear a human kill, permissions, unresolved effects or resource holds. This design
adds no role grant. Without a delegated role, Jason is the releaser. S5 owns the new
broker/Console write and review, separate from the kill-switch clear.
## 6. Monitoring and Console
**D9 Sampler ownership.** S2’s implementer owns the live sampler inside the mechanical
supervisor process proposed in D1, running in monitor-only mode before admission lands.
That process samples every 2 seconds and supplies timestamped local snapshots through
the Console’s authenticated bus read transport via the proposed `system.health` read
verb. S2 includes the broker handler and its scoped authorization review. The Console
does not read `/proc` itself. Process death/stale samples show unknown. Request/provider fields show unavailable
until their adapters land. Starting this process and updating the live Console require
an approved re-pin window after MVP. No sampler is started by this design work.
**Measurements.** Pressure samples and freshness, active/reserved work, ownership
uncertainty, queue depth/oldest wait by class, hold time/reason, admission/turn latency,
retry counts, DLQ growth, route failures, provider cooldown/reset, stop proof state,
manager heartbeat and decision-model timeouts/spend/unknown charges. Huey’s reading:
also expose protected-mode scope, last/next probe, online proof, paused request counts,
canary/batch progress, pause age/limit and the escalation latch/release evidence. Store bounded
aggregate samples for 7 days, without request bodies. Request receipts/evidence follow
the instance’s retention policy, with the existing write-once run invariant preserved.
Console “System” shows healthy/holding/unknown, last sample time, kill state, capacity,
queue counts and oldest wait, plus provider and decision-model status. Drill down to
Requests, Dead letters and evidence. Reuse Console read transport, auth and scoped
human writes (F8). No new public listener. Show stale/unknown explicitly, not green.
Alert immediately on OOM, uncertain ownership/stop, journal failure, dead letters and
unknown decision charge. Huey’s reading: show protected-mode entry/recovery and alert
on D15’s repeated-exit boundary. Warn on pressure hold >60 seconds, control heartbeat stale
>6 seconds, or oldest eligible wait >10 minutes. Deduplicate by reason/target and emit
one recovery notification. Use Console attention/trail first. External notification
channels require their own configured authorization. A lightweight supervisor watchdog
reports manager death through an independent health path, not the dead manager itself.
## 7. Optional decision-model hook
**D10 Interface.** `decide({version, snapshotId, policyRevision, deadline,
allowedActions, state}, signal) → {actionId, confidence?, usage:{cost,currency},
providerRequestId}`. `allowedActions` contains opaque IDs for already-eligible routes
or “use rules”. Adapters normalize provider types. **Every routing decision goes through
this one module. A session’s own decision call carries no routing authority, and no
session output enters route selection.**
The module also chooses its own decision endpoint. Provider-neutral requests and results
keep endpoint wire formats out of sessions and the scheduler. With model assistance
disabled, the module uses built-in rules without network or key.
When enabled, the module tries **JEV → one configured smaller fallback model → built-in
rules**. The smaller model may be Haiku, Luna or another on the user’s allowlist and
runs through a minimal harness config owned by this module. Rules last is **Mos’s
reading of Q207**, so a full outage does not require a reachable model. Huey’s reading:
one attempt per eligible model endpoint, no same-endpoint retries or unbounded chain.
Use only configured, online, calibrated and budget/quota-eligible endpoints. An
uncalibrated model never decides: the rules adapter supplies the decision in its place.
Enabling paid assistance is a user setting, recommended in setup with its price/cap
shown. Pin **`typesafe/jev-1.14`** as the recommended initial model, never a moving
“latest” alias. Its [OpenRouter guide](https://openrouter.ai/docs/guides/community/jev)
documents **Choice** (select an option), **Noul** (probability of yes) and **Score**
(ordered scale) primitives with probabilities, and
`POST https://openrouter.ai/api/alpha/decisions`. The [model page](https://openrouter.ai/typesafe/jev-1.14)
lists $0.042 per million input tokens, free output and 262,144 context tokens
(verified public documentation 2026-10-21, not a live call or price guarantee).
Use choice for route selection. Do not send images or use the large context window.
Other decision models need their own adapter contract and calibrated confidence tests.
**Endpoint contracts (Huey’s reading of Q207).** Each model has its own adapter,
output validation, timeout and confidence calibration. **500 ms is JEV’s candidate**.
A smaller model through a headless harness needs longer: propose **10 seconds end to
end**, including harness startup, as that fallback’s candidate. S6 uses delayed
stand-ins only to measure latency/timeout/fallback rates and records each selected
timeout and calibration. No real call or benchmark is authorized. Stand-ins measure
local behavior, not provider performance. Use at most one in-flight module decision
per instance, bounded by the caller deadline and these endpoint budgets. A fallback
never inherits JEV’s confidence threshold without its own calibration.
A dispatcher that cannot wait takes the module’s rules adapter immediately instead of
queueing behind a model call. A JEV call still running after its 500 ms timeout does
not count as the instance’s one in-flight decision while the fallback runs. Its spend
reservation is kept until settled, and its late answer cannot enter route selection.
Timeout/network failure advances to the next eligible endpoint within the deadline.
Invalid/stale answers, missing/non-finite/out-of-range or uncalibrated/low confidence
cannot choose a route. Use rules if no eligible calibrated model supplies a valid
answer. An exhausted caller deadline selects rules without waiting for another model.
Discard late answers for routing, retain their usage reservations, and bound owned
harness cleanup. The hook cannot authorize work, choose P0/P1 status, change thresholds,
clear a kill, replay effects or choose outside the eligible set. Recheck eligibility
at the final mechanical dispatch check. Never call models for safety actions.
Huey’s reading of Q205–Q207: the module may advise on resume order and route choice
among services mechanically marked online, within policy/capacity/budget. It cannot
mark a service online, authorize resume, clear a human kill, replay effects or perform
safety actions. Its built-in rules remain inside the module when models are unavailable.
Stop/effect proof requirements remain unchanged.
**D11 Cost scope.** Huey’s reading of DECISIONS Q204 Q20b: daily and monthly USD caps
are user settings, with defaults of **$0.10/day and $1/month per dedicated decision key**.
They live in the instance’s one system config through D3’s reviewed schema/bootstrap
path. Both configured caps are enforced by the owning instance’s durable ledger.
No spending starts until Jason enables paid decisions and installs the key.
**Huey’s reading of billing for Q206/Q207:** classify each adapter/credential as
subscription/quota-only or metered USD before enablement, with no automatic conversion
to a paid API. Fallback decisions using metered models share the configured D11 decision
caps with JEV. A subscription-only fallback (for example an authorized Claude OAuth or
ChatGPT-plan route) consumes its provider usage/quota limits, not a fabricated USD price.
Only a verified subscription-only contract establishes no incremental API charge.
Huey’s reading: reserve a bounded usage allowance before a subscription call, then
reconcile the provider’s measured usage. Unknown quota accounting holds that route.
Unknown billing/quota sources defer that endpoint and use the next eligible adapter.
No subscription purchase, paid overage or new model spend is authorized by fallback.
Recovery headless probes and the synthetic resume test have a **separate paid-probe
budget**, proposed by Huey: user-set daily/monthly USD caps, **both $0 by default**
(paid probes disabled). Setup shows maximum probe frequency, estimated unit cost and
combined decision/probe budgets before explicit enablement. Subscription/quota-only
probes consume usage limits, not USD caps, and wait for Retry-After/reset when exhausted.
Their success can establish service health without a per-call dollar price. All paid
probe providers share one instance probe ledger and require provider-side key ceilings.
Reserve and settle actual paid probe charges, timeouts and unknown outcomes as below,
separately from the decision ledger, so probing cannot silently drain the decision cap.
No paid probe runs until its cap and credential are explicitly enabled. All model
fallbacks also require explicit configuration/enablement before consuming quota or money.
No unknown charge is zero and no outage grants fresh spend authority. For enabled JEV,
each instance must use a separately provisioned key used only for its decisions, with a
non-secret provider key ID bound to the instance in the approved setup record. Exclusive
ownership is a setup attestation, not something the manager can prove mechanically.
Reject enablement without that attestation. The provider-side key limit is the real
spending ceiling. Do not reuse a worker key or share the decision key across instances
or other clients. Shared-key operation is held
until a shared ledger exists. These are not account-wide caps: N enabled instances
with N keys permit N times the defaults. Setup shows that combined authorized budget.
Use integer currency units/decimal parsing, never binary-float threshold comparisons.
For metered USD requests, before send durably reserve an upper bound from a pinned
price ceiling and a bounded
serialized request (≤4 KiB, verified worst-case tokens and all billable fields). Count
concurrent reservations in both caps. If the adapter cannot bound cost, use rules.
For metered USD adapters, reconcile once per namespaced provider request ID using
**`usage.cost`** in USD (or the fallback adapter’s verified equivalent), as documented in
the [Jev guide](https://openrouter.ai/docs/guides/community/jev). Keep reservations for
timeouts/crashes until settled. Missing/invalid cost, price above ceiling, or a charge
above reservation disables paid decisions and alerts. Never interpret absent cost as
zero. A provider-enforced limit bound to the dedicated key is required for **every
metered USD enablement**, including trials, in addition to local daily/monthly caps.
Subscription-only routes instead require verified provider quota enforcement and usage
accounting, as classified above, with no implicit paid-overage permission. If the
provider cannot enforce the approved key budget, keep paid decisions disabled. Local
accounting alone cannot guarantee a hard ceiling against upstream misbilling.
Day/month windows use UTC. No cap rollover drops an unresolved reservation. Configure caps before enabling.
**D12 Data and key.** The **4 KiB** serialized decision request limit and the following
metadata allowlist apply to **every endpoint**, including headless smaller-model
fallbacks. Do not attach session history or extra harness context to a fallback.
Send only enum work type/class, numeric resource estimates,
coarse queue age/pressure, redacted error category, capability tags and opaque eligible
route IDs. No credentials, auth headers, environment, command lines, paths, transcript,
raw errors, filenames or user prompt. If a judgment requires user content, first try
local metadata extraction. Any later minimal content field needs explicit approval
and a tested schema/redaction rule. Log sanitized decision inputs/output IDs and cost,
never wire headers. Model output is untrusted data.
Proposed slot reference only: `<instance-config-directory>/secrets/openrouter-decision.env`,
name **`MOSAIC_DECISION_OPENROUTER_KEY`**. This is not an existing slot claim. The
ordinary `OPENROUTER_API_KEY` is in `ENV_ALLOW` and `sessionEnv` copies allowed variables
(`packages/seat/src/session.mjs:83-99`), so it must never hold the decision key. Jason
places the dedicated value only after live-trial approval. Runtime injects it only into
the decision adapter, never the shared launcher environment, model state or worker.
Do not add the distinct name to the worker allowlist or alias its value to an allowed
provider variable. S6 must prove `sessionEnv` excludes the distinct variable using a
synthetic canary. No management key access. A direct Decisions
adapter may be needed unless a stand-in proves the approved proxy supports this API.
Huey’s reading of Q207 credential isolation: the fallback harness has its own configured
credential reference, injected only into the decision module’s isolated minimal harness,
never agent sessions or the shared launcher environment. Do not reuse worker credentials
or copy worker HOME/session config. A needed credential stays a slot reference in this
design. Apply the same canary/redaction checks to each fallback credential as to the
decision key. No agent endpoint credentials or route-selection tools are added.
## 8. Proposed queue rows, smallest first
Sage assigns actual row IDs, Gitea issues, owners, reviewers and gate grants. These
are proposals under Vikunja 452, not edits to either queue file. No implementation is
authorized here. Vikunja 450/451 and MVP 414/416 retain their existing order/authority.
| Ref/order | Slice and dependency | Acceptance evidence | Before 10-27? |
| --- | --- | --- | --- |
| S1 | Pressure parser and pure rules evaluator, no live enforcement | Synthetic load/memory/PSI/missing-signal cases, exact hold/release thresholds, fake-clock hysteresis, effective CPU/memory limit cases. No builds or real pressure generation. | Candidate could land if Sage has spare review/gate capacity. Never displace MVP. |
| S2 | Live sampler in the mechanical supervisor process (monitor-only), plus read-only Console System status, after S1. S2 implementer owns process lifecycle and snapshot transport (D9), including the new `system.health` bus read verb and broker handler. Scope and review include broker authorization. | Fixture sampler feeds timestamped Console responses for healthy/held/stale/unknown. Process death, unavailable adapters and auth scoping tested. No admission writer, secrets or command-line fields. | After MVP. Live sampler/Console activation needs an approved re-pin window. |
| S3 | Request journal/admission, after S1 and final kill/lifecycle contracts | Migration/rollback compatibility, duplicate-key conflict, crash before/after spawn, two dispatchers, generation fencing, queue limits/expiry, provider cooldown, fairness under sustained traffic, kill-at-launch race, instance isolation. | After MVP preferred. Schema and dispatch integration need a full owned gate. |
| S4 | Cooperative interrupts and capacity ownership, after S3 and adopted gate lanes | Agent abort proof vs uncertain, test-step cancellation/quarantine, owned build cleanup, PID reuse, remote tool unknown outcome, emergency request during full backlog. No gate grant synthesized. | After MVP. |
| S5 | Retry/DLQ/replay and permitted reroutes, plus protected-mode journal, mechanical outage detection and automatic resume policy (D13/D15), after S3/S4 | Fake-clock failure spikes/scopes, restart during pause, frozen counters/deadlines, outage never DLQ, isolated request failures still bounded, Retry-After, unknown proxy budget, partial-stream/write no replay, authorized replay lineage, incompatible-route refusal, canary then batches, request-local failures consume D6 budget, one bad request cannot re-enter protected mode, correlated distinct-request re-entry, two separate failed resumes go to D7, batch-failure definition, three-failed-exit latch, authorized revision-bound release via broker/Console without kill clear, later probes do not auto-release, maximum pause age expires without DLQ/capacity release, human kill unchanged. No real probes. | After MVP. |
| S6 | Decision hook, per-exclusive-key USD ledger and setup, after S5 | Stand-in typed responses, late/invalid/missing/low-confidence answers, unapproved action, snapshot race and redaction canaries. Measure timeout rates and choose the timeout from stand-in results. Verify `sessionEnv` drops `MOSAIC_DECISION_OPENROUTER_KEY`. Test exclusive key binding, reject shared keys/missing provider limit, independent-key budget totals, concurrent reservations, missing/duplicate cost, crash and day rollover. D14 probe adapters: endpoint-before-headless, 60-step-to-600 schedule with no attempt limit, all service schedules parallel without a JEV gate, one success online, reset wait, disabled endpoints, subscription/quota-only success without USD, separate paid-probe caps and deferral, restart and hung-probe cleanup. Prove only the decision module selects endpoints/routes: a session’s own decision call carries no routing authority and no session output enters route selection (test both directly), immediate rules for dispatchers that cannot wait, timed-out JEV not occupying the fallback slot while its spend reservation remains, JEV → allowed smaller model → internal rules, per-model confidence/timeout calibration, 4 KiB metadata-only input at every endpoint and isolated fallback-credential canaries. Network disabled by default. | Fixture implementation may proceed later. No paid trial before explicit approval. |
Each slice gets independent review and tests that kill a mutation of its central
invariant. Sage chooses package/full-suite gates appropriate to the final diff.
Do not run them alongside an MVP gate without a grant. Before production activation,
measure emergency control responsiveness and recovery with disposable cohorts under
bounded synthetic pressure. No load experiment on the shared development host.
## 9. Questions for Jason (recommendations, not design blockers)
- **Q1 Initial authority:** should the manager ever force-stop background work just
for load? **Recommend no.** Cooperative yield for load, hard stop only through an
already-authorized safety/explicit stop. Broader authority needs a separate decision.
**Approved**, DECISIONS Q204. Jason, verbatim: “Q20a: no”
- **Q2 Trial spend:** accept $0.10/day and $1/month per exclusive instance key, with a
provider-side key ceiling for every enablement? **Recommend yes**, with the combined
account budget shown and rules staying default until Jason enables it and installs
the slot. Q199 provides readiness, not approval to spend now.
**Approved**, DECISIONS Q204. Jason, verbatim: “Q20b: yes, tunable in the settings.”
Huey’s reading: D11’s daily/monthly caps become user settings with the stated defaults.
The provider-side key limit remains required for every enablement and is the real ceiling.
Nothing spends until Jason enables paid decisions and installs the key.
- **Q3 Reroute consent:** may setup authorize a named alternative model/provider list?
**Recommend yes**, per role and privacy requirement. Empty list means hold/fail
visibly instead of silently changing providers or model quality.
**Approved**, DECISIONS Q204. Jason, verbatim: “Q20c: yes”
- **Q4 System recovery priority: superseded by DECISIONS Q205 and Q206.**
Jason, verbatim (Q205): “If a catastrophe does occur, it makes sense to automatically
throw the kill switch, pause agent runs, etc.”
Huey’s reading, following Mos’s Q205 reading: protected mode does the automatic
pausing instead. The kill switch stays human-only. Jason’s Q21 reply in Q206 asked
only about cool-down, DLQ and retry. D1 remains unchanged.
Jason, verbatim (Q205): “It does not make sense to require the user to release each
back into operation. I would rather equip the system with the tools necessary to
detect failure, enter protected mode, monitor for cool-down periods, check for
system operational status, resume operations.”
Jason, verbatim (Q206):
> Mosaic Stack mechanical tooling detects the outage and tracks the status without LLM need
> Continual mechanical attempt for JEV begins with a cooldown of 60, 120, 180 seconds with a ceiling of 10 minutes
> Once JEV is back online, the same cooldown starts for the other harnesses/providers in parallel
> Necessary services are marked back online when found available
> System adapts based on available services, providers, harnesses, usage limits, and usage budgets
Q207 supersedes only the JEV-first order quoted above. D14 now probes all services
independently in parallel.
Huey’s reading, following Mos’s labelled Q205/Q206 reading: D13–D15 automate
protected-mode recovery without per-session approval. Entry thresholds, checkpoint
handling, deadline freezing/max pause age, probe deadlines/budgets and disabled-endpoint behavior,
batch sizing and the three-failed-exit escalation count are proposed readings, not
Jason’s verbatim requirements. D1’s human kill-switch boundary remains unchanged.
- **Q22 Resolved by DECISIONS Q207.** Jason, verbatim:
> Q22: That makes sense, somewhat. If the agents are allowed to directly bypass the decision-making module of Mosaic Stack, they will default to bypassing it all the time. Once service is returned, they will not go back to using the system properly until their context is cleared. We would be better suited to have the requirement for decisions to always flow through the same place on the system and have that module decide the endpoint to use for decisions. If JEV is unavailable it can default to a Haiku, Luna, or some other smaller model to make the decisions from within a minimal harness. Context for the decision-maker would be kept minimal to avoid bloat and unnecessary slowdowns. I'd rather have the fallback occur at the decision module rather than at each agent session.
Mos’s recorded reading: service probes proceed independently in parallel and the
decision module tries JEV, then a configured smaller model, then its built-in rules.
**Rules last is Mos’s reading**, not Jason’s verbatim endpoint order. Huey’s readings:
the 10-second fallback timeout candidate, per-model calibration, billing split and
isolated fallback credentials in D10–D12. Session calls/output carry no routing authority.
## Verification and handoff
Revision evidence: r3 `a36952564` named the health verb/broker scope, clarified ownership
attestation and added Q4. r4 `1e8bccff1` recorded Q204 and tunable cap settings. r5
`fd44f7c60` added Q205/Q206 protected mode, probes and resume, with D1 unchanged. r6
records Q207, central fallback, independent probes and scrooge-0003 S-F1–S-F7 fixes.
Huey’s new thresholds, billing defaults, release/lifetime rules and timeout choices
remain labelled readings for review, not implementation or live-test evidence.
r2 addresses Scrooge findings 1–8: distinct key isolation, exclusive-key cap scope,
sampler/process ownership and post-MVP re-pin, P1 interpretation, missing-confidence
fallback and measured timeout selection, verified Noul spelling, retry scope exclusions,
and a citation for the lifecycle brief’s revocation requirement.
This document was checked against the pinned source and public documentation only.
No build, suite, live bus write, service action, proxy inspection or paid call was
performed. The canonical `scripts/mosaic queue next huey` returned `nothing`.
The initial linked-worktree refusal was diagnosed, not bypassed. The direct mos-1183
assignment governs this branch. Gitea issue creation and implementation-row entry
remain with Sage: this brief forbids credential-file reads and queue writes.
+9
View File
@@ -47,3 +47,12 @@ What this piece deliberately leaves alone, and where that work goes.
Who says yes, and on what evidence: the command to run, the output to
expect, or the comment to read.
### Handoffs
Every seat hands off directly (DECISIONS Q210, mos-1221). When the owner
pushes a build or a new round, or the reviewer posts a verdict, that seat
messages the next seat's T3 thread and Sage's with
`mcp__t3__t3_send_message`. Claude and Codex seats both have it. Copy
Mos's thread only when Mos has a step. Nobody waits on a date or a
schedule when the next step is ready.
+33 -14
View File
@@ -270,20 +270,6 @@ at every gate. Started 2026-09-12 during the control board MVP.
(`:1538`, `:1689`, `:1691`). For a later row that edits
`stack-release.sh`. Webby N1 on row 238 r1 (REVIEW-r1.md, 433a95292,
#1744). (2026-10-21)
- **Gate runs mount the host's pi auth file.** `compose.yaml:43` mounts
`${PI_AUTH_FILE:-/home/jwoltje/.pi/agent/auth.json}` read-only into
the agent container. No suite sets `PI_AUTH_FILE`, so the
`test-task.sh` cases that call `scripts/run-task.sh`, and
`test-release.sh` through `release.sh` `health_check` (`:41-54`), run
Compose with the host file. Row 197's lane `cleanEnv`
(`scripts/testing/gate-lane.mjs:109`) keeps the default too: it neither
sets nor passes `PI_AUTH_FILE`. The mount is read-only, and the legacy
gate already worked this way before row 197. `test-auth.sh` uses its
own fixtures but calls `auth.sh`, not Compose. The fix: a fixture
`PI_AUTH_FILE` for the Compose-reaching cases, set by the suites or by
the lane's `cleanEnv`, and a test that no gate step mounts the host
default. It needs its own row. Webby N4 on the row 197 proof driver
(bba870ee9), traced by Herb (herb-0051). (2026-10-21)
- **Lane image prep inspects a base that BuildKit never tagged.**
(N7 of the row 197 proof.) After the Compose build,
`scripts/testing/lane-image.mjs:41` (`prepareImage`) runs
@@ -360,6 +346,32 @@ at every gate. Started 2026-09-12 during the control board MVP.
do nothing yet. The row that adds the Console library routes decides
whether the tester listener gets `library.sync`. Duckworth's view is
that it shouldn't, because sync is a human write. (2026-10-21)
- **T27 sweep test leaves two `sleep 60` children running.**
(Darkwing row 251 slot, darkwing-0113 and darkwing-0117; Dewey on
r2, comment 30168.) The sweep test spawns two detached shells,
`planted` at `host-run.test.mjs:528` and `decoy` at `:536`, each
running `sleep 60; :` with the run dir as `$0`. `sweepRunDir` kills
the planted shell by pid (`host-run.mjs:238`), and `t.after` kills
only the decoy shell. Neither kill reaches the `sleep` child, so each
one lives for up to 60 s after the test and trips any busy check that
follows. The fix is to kill the process group in both `t.after`
hooks: `process.kill(-pid, 'SIGKILL')`. Each shell is spawned
detached, so it leads its own group. `exec sleep 60` doesn't work.
The sweep matches the run dir in `/proc/<pid>/cmdline`
(`host-run.mjs:236`), and exec drops the `$0` that carries it, so the
planted process would survive and the "planted process killed" assert
would fail. This binds the next row that touches
`packages/bus/tests/host-run.test.mjs`. (2026-10-21)
- **Real-model suite cases run outside the gate.** Row 244 moved
`test-task.sh` fork recall (M11) and user recall (M16) behind
`MOSAIC_LIVE_PROVIDER=1`, and `test-release.sh` activations to the mock
adapter. Before any re-pin whose tree changes `packages/harness` or the
session fork code (`scripts/mosaic-task.mjs` session and
`sessionForkFrom` handling, the pi adapter), an operator runs
`MOSAIC_LIVE_PROVIDER=1 bash scripts/test-task.sh` with the real pi
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)
## Queue
@@ -425,3 +437,10 @@ Moved to `docs/plans/QUEUE.md` on 2026-09-13. This file holds only gaps.
at a time (Filbert F2; Darkwing and Filbert T1 on #1522;
flows.test.mjs).
The frozen-cgroup test (Filbert F1) stays open above.
- Gate runs mount the host's pi auth file (Webby N4 on the row 197 proof
driver, 059577886): closed by row 244 (#1757), landing e69260a3c. The
suites and the lane's `cleanEnv` export a 0600 fixture `PI_AUTH_FILE`,
`guardDocker` refuses a Compose run without one, and
`packages/cli/tests/gate-lane-auth.test.mjs` proves no gate step mounts
the host default. The real-model cases this moved off the gate are open
above.
+6 -6
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 2976 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
Generated from `docs/plans/queue.json` revision 2994 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|---|---|---|---|---|---|---|---|---|
@@ -232,7 +232,7 @@ Generated from `docs/plans/queue.json` revision 2976 by `queue render`. Do not e
| 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. |
| 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 | in-progress | — | 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) | — |
| 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). |
| 198 | retire the control board writer on 7331 (unauthenticated loopback writer, Q175; mos-1008) | bushroot; reviewers webby | #1696 | in-progress | 192 done, 197 done | Webby approves INVENTORY.md then the manifest; control-board, webui and cli suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_retire-control-board.md` § Brief: retire the control board writer on 7331 | Order with row 197 (Sage, mos-1040 N1): 197 lands first. Row 198 rebases onto 197's landing before its manifest review; shared tests are packages/webui/tests/serve.test.mjs:100 and preview.test.mjs:208. |
| 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. |
@@ -280,17 +280,17 @@ Generated from `docs/plans/queue.json` revision 2976 by `queue render`. Do not e
| 241 | Row 237 follow-up: one decision lands once, a two-ticket broker land test and three mutants on the landing guards (megavolt-0069, sage-1651, P19 441) | megavolt; reviewers duckworth | #1747 | done | 237 done | Duckworth approves the manifest. The bus, seat and cli suites and every scripts/test-*.sh are green on Sage's gate, and the three mutants are killed. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: row 237 follow-up, one decision lands once, with tests at the broker | Landed 5d4a61776 on refactor, pushed (base e65a3ab4d). Blobs from 3e3ed303e on 2 paths; patch-id a1649f80 equals git diff f5753c0e8..3e3ed303e on those paths; ls-tree modes equal; manifest e8266864 verifies on the landed files. Sage slot on bbf3b1ee8: M1-M3 killed; gate 15 packages 2797/0, 10 script suites 429/0. Duckworth approve 29870 rev 2628. |
| 242 | Row 237 follow-up N1: a retry after publish-timeout pushes once; hold the decision or kill and wait for the push, a slow-push retry test and a mutant (duckworth-0069, P19 440) | megavolt; reviewers duckworth | #1748 | done | 237 done | Duckworth approves the manifest. The bus and cli suites and every scripts/test-*.sh are green on Sage's gate, and the mutant is killed. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: row 237 follow-up N1, a retry after publish-timeout pushes once | Landed 5fb736b92 on refactor, pushed (base 96bac588f). Blobs from 635c10924 on 3 paths; patch-id ceb741ee equals git diff 8fede5fc6..635c10924 on those paths; ls-tree modes equal; manifest 1fbfab6c verifies on the landed files. Megavolt M1/M2 killed on Sage's slot; Sage gate 15 packages 2826/0, 10 script suites 429/0. Duckworth approve 29889 rev 2639. |
| 243 | process.test.mjs startup token refusal sets its file mode (chmod), umask sweep of packages/*/tests | morgana; reviewers webby | #1754 | done | 197 done | Webby approves the manifest. The test file passes whole under umask 022 and under umask 077 on Sage's slot. (sage) | `docs/plans/2026-10-21_gate-env-rows.md` § The startup token refusal test sets its file mode | LANDED 6096b051b on 9a8c8860d, pushed. Patch-id f9d687dd1d41 equals a36523713..6581f750e; 5 paths, blobs and modes equal the candidate; no drift on those paths since a36523713. Gate: Webby r1 APPROVE rev 2939 (c30111); slot singles (morgana-0041): candidate 4 files whole green at umask 022 and 077, base 077 red only on the predicted tests. |
| 244 | Gate runs use a fixture PI_AUTH_FILE (DEFERRED 059577886, Webby N4) | morgana; reviewers webby | #1757 | briefed | 197 done | Webby approves the manifest. The new test and every touched file are green on Sage's slot, along with test-task.sh and test-release.sh. (sage) | `docs/plans/2026-10-21_gate-env-rows.md` § Gate runs use a fixture pi auth file | — |
| 244 | Gate runs use a fixture PI_AUTH_FILE (DEFERRED 059577886, Webby N4) | morgana; reviewers webby | #1757 | in-review, round 1, request posted | 197 done | Webby approves the manifest. The new test and every touched file are green on Sage's slot, along with test-task.sh and test-release.sh. (sage) | `docs/plans/2026-10-21_gate-env-rows.md` § Gate runs use a fixture pi auth file | — |
| 245 | row 195 follow-up: Duckworth's N1-N3 on scripts/case1-accept.sh (root never runs an unchecked --node; the header lists what root runs; F6 refuses .. after the claude pin dir) | darkwing; reviewers duckworth | #1693; closes none | done | — | Duckworth approves the manifest. The cli suite and every scripts/test-*.sh are green on Sage's gate, and a mutant for each of N1 and N3 is killed. (sage) | `docs/plans/2026-10-20_hub-own-uid.md` § Row 195 follow-up: Duckworth's N1-N3 on the Case 1 acceptance script | LANDED 6d5545d44 on 856c1826b, pushed. Patch-id 36a621ac3966 equals 62ddc96cc..ef8b4439e. Gate: Duckworth r1 APPROVE rev 2849; N1 mutants 13-15 and N3 mutant 16 killed (sage-1961, darkwing-0107); Sage gate 12:24-12:35Z: case1-accept alone 23/0, cli 681/0, scripts/test-*.sh 429/0. Lands before row 246 as the brief asks. #1693 stays open for 246. |
| 246 | row 195 window: the Case 1 acceptance run on 10.1.1.53 (carries row 195's done condition; needs a Jason or Mos go) | darkwing; reviewers duckworth | #1693 | queued | 226 done, 245 done | The window's acceptance run passes on .53, Duckworth reads its output, and Mos confirms. (sage) | `docs/plans/2026-10-20_hub-own-uid.md` § Row 195 window: the Case 1 acceptance run on 10.1.1.53 | — |
| 247 | Case 2 C2-20b persona import follow-up: printed keys, the dry run fence check and the mkdir race | gosalyn; reviewers webby | #1749 | done | 221 done | Webby approves the manifest. The test file runs green on its own, and the three mutants are killed on Sage's slot. The cli suite and every scripts/test-*.sh are green on Sage's gate. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-20b persona import follow-up: printed keys, the dry run fence check and the mkdir race | LANDED 856c1826b on f8276c5eb, pushed. Patch-id 373ab2cc4c50 equals 6d43618a3's. Gate: Webby r1 APPROVE rev 2852; test file alone 14/0 and M1-M4 killed (sage-1971); Sage gate 12:10-12:21Z: file 14/0, cli 680/0, scripts/test-*.sh 429/0. M1/M2 at :248 not :237 (gosalyn-0111). |
| 248 | row 235 follow-up: the grant-executor hold-add and hold-remove stubs stop when their test dir is gone (found by the 197 r8 proof) | sage; reviewers webby | #1750 | done | — | Webby approves the commit. On Sage's gate the whole grant-executor.test.mjs passes three times with no stub left, the bus suite is green, and a mutant that drops the dir check leaves a stub. (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row b follow-up: the held stubs end with their test | LANDED 8a04f4aa4 on refactor, pushed. Temp-index landing of candidate 4d99c178b's one path onto bc579f7a8. No drift on the path since 540239b6b. Patch-id 84974d6b08d6 matches the candidate's. Gate per the brief: Webby APPROVE r1 rev 2883, Sage's gate 13:05-13:07Z (file 3x 37/0 no stub, bus 596/0, mutant left 2). |
| 249 | row 235 follow-up: the grant undo records hostLineLeft when install never returned, since the remote add can outlive an abort or timeout (mos-1166) | gizmoduck; reviewers duckworth | #1751 | done | — | Duckworth approves the manifest. On Sage's gate the bus suite and the whole touched test files are green, and a mutant that drops the new catch branch is killed. (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row b follow-up: the undo can't prove the remote add has ended | LANDED 9de5a57ab on refactor, pushed with 7b9737953 (DEFERRED N1). Range-diff: 8a04f4aa4..05595794b vs 8ca064158..9de5a57ab, same 3 paths, patch-id 99551b122e66 on both. Gate sage-2026 14:53Z: grant-executor whole 39/0, bus 598/0, no stub left, C1 (drop :1025) killed by G8 and both G23. Duckworth approve c30070. |
| 250 | K12 (cohort.test.mjs) reads cgroup.events after cgroup.kill under load: print the shim's reason, then fix the cause at the source (Vikunja #450) | gizmoduck; reviewers filbert | #1755 | done | 197 done | reviewer approves the manifest; conversation and control-board suites whole on Sage's slot (sage) | `docs/plans/2026-10-21_k12-cohort-events.md` § Brief: K12 reads cgroup.events after cgroup.kill under load | LANDED 1b6158ce2 on refactor 19:42Z: candidate c1a858975 on 3b0aef995, manifest eca0b8e2, Filbert APPROVE rev 2957 (#1755 c30130). patch-id 29786caa = candidate; blob diff empty; tree 59f1e4781 = merge-tree. Gate on Sage slot sage-2109 (gizmoduck-0150): P1/P2/M1 as predicted, conversation whole 3x 183/183 plus concurrent pair, control-board 124/124. No drift on paths since base. |
| 251 | T27 (host-run.test.mjs:453) lists no identities right after ssh-add -t 1: print timing and agent state, then fix the cause at the source (Vikunja #453) | darkwing; reviewers dewey | #1756 | in-review, round 1, request posted | 197 done | reviewer approves the manifest; bus suite whole on Sage's slot (sage) | `docs/plans/2026-10-21_t27-ssh-add-lifetime.md` § Brief: T27 lists no identities right after ssh-add -t 1 | mos-1185 item 2. If proof 6 fails outside 197's paths in a pair wave, this and row 250 go ahead of 197 (item 4). |
| 251 | T27 (host-run.test.mjs:453) lists no identities right after ssh-add -t 1: print timing and agent state, then fix the cause at the source (Vikunja #453) | darkwing; reviewers dewey | #1756 | in-review, round 2, request posted | 197 done | reviewer approves the manifest; bus suite whole on Sage's slot (sage) | `docs/plans/2026-10-21_t27-ssh-add-lifetime.md` § Brief: T27 lists no identities right after ssh-add -t 1 | mos-1185 item 2. If proof 6 fails outside 197's paths in a pair wave, this and row 250 go ahead of 197 (item 4). |
| 252 | Console inbox Approve and Deny for grant requests (#435, J2) | gander; reviewers webby | — | briefed | — | Webby approves DESIGN.md then manifest; webui, bus, cli suites and every test-*.sh green on Sage's gate; DESIGN mutants killed; REQ-WEB-2 PRD amendment landed first (sage) | `docs/plans/2026-10-21_console-inbox-approve.md` § Gate | mos-1193. Chained on row 196's candidate (Q177): design starts now on Gander's row 196 build; code rebases onto 196's landing. Vikunja #435. |
| 253 | F27: case2-unit.sh and case1-accept.sh move to --tester-socket and refuse --tester-port with stack-release's migration wording; no .53/.54 copy with 196 until this lands (mos-1198 item 4) | gander; reviewers duckworth | #1758 | queued | 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 | — | queued | 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 | Dewey's deferrals from 222 approval c30138. Chained after 196 (human-cli channel proof). Issue #1759. |
| 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 | briefed | 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 | briefed | 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 | Dewey's deferrals from 222 approval c30138. Chained after 196 (human-cli channel proof). Issue #1759. |
| 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. |
<!-- mosaic-queue:end -->
+608 -20
View File
@@ -1,7 +1,7 @@
{
"version": 1,
"canonicalRoot": "/mnt/storage/src/mosaic-stack",
"revision": 2976,
"revision": 2994,
"rows": [
{
"id": 1,
@@ -20514,7 +20514,7 @@
"closes": [
1694
],
"state": "in-progress",
"state": "done",
"previousState": null,
"required": false,
"requiredSince": null,
@@ -20584,18 +20584,69 @@
"candidate": "d9fefc49ab1affb02d68b35b658772da4e82c724255611a7fe1d59b48b62006c"
}
]
},
{
"n": 2,
"op": "gander-196-manifest-r6",
"by": "gander",
"at": "2026-10-21T20:51:08.099Z",
"issue": 1694,
"candidate": {
"kind": "manifest",
"digest": "537d30aef70b6cd19ab7283ce2a702d9ac55bb179c60d6a55f50b45a0e5b149d",
"text": "0ad63c4f7a6cdd5b2587a0969fe7a95539cf1e4601f51fb33cc4df94a718b2b5 docs/TOOLS.md\n10c996d323ac46d45548f7748d40b246de93f8c5eacae9f951c6eeb5dcc62697 packages/bus/README.md\n60720461a7994011a9a30594715eb2c992be699cd61c656e77cbd609ad05328e packages/bus/fixtures/peer-client.mjs\n0c6d96abe4607cba0b02b0638a2fc217ff8135bac9a47761a0ef015fbe9d3079 packages/bus/src/human.mjs\nd5d98a9133a1b449acc48143fd559d890a470c6ea210b30b9b70e491e6cd855a packages/bus/src/interpreter.mjs\n29c88655e8b9aed8db604573c9213fd9f3c40ef35ff04f4eda8fbe8f1b8a32d7 packages/bus/src/peercred.mjs\nd1536a8f472fa5b3773adb10555529ed049ad63b647c0b44c00ce5f25ce09f85 packages/bus/src/peercred.py\n32ef39d80b523bdd586c29620d7a752c309c8e1aa70befc1414125468f3411dd packages/bus/src/principal.mjs\n190a4f56c44131f8a2325bc9c4a822556de432d1afd2e75ffe3168a171320bed packages/bus/src/runtime.mjs\n7be306c830aa98763b34b329995b40e9df443483fe20fcd69100a3acb6311bd6 packages/bus/src/server.mjs\ne99133c3c957a1e856e404343721add2430e4a0d8c75452517aef04942af56ab packages/bus/tests/channel-proof.test.mjs\nd19779b97e80ed3d62f58d0e798df46c23f45fa02b14fb4ac7a2ff4690a734e2 packages/bus/tests/interpreter.test.mjs\na39af877a2d9143cc8d0584af0d603ca7ab651db8787e5816579762f17295887 packages/bus/tests/peer-transport.test.mjs\n2c4c0bed41e546ce94535d8c4547b1cf5552a181183525dd1d52e2af1d64ccb6 packages/bus/tests/peercred.test.mjs\ncefbc0688d823c69196d94bb71467884acd639070721286d7e8e4e986c8240c1 packages/bus/tests/principal.test.mjs\n95c7acdb47f753d1e129adaa90b5937318d8d006ca47be9d92729fdae013776f packages/cli/README.md\n4bd7bdf0898844684ebf9444b110d2c5b6fa874b2681183e54f1f62f97823192 packages/cli/src/tester-http.mjs\na7cb20db2941b3b3abb09bcaa55eac92a1d420cf4ec79c6f83e4bb3235e04a2b packages/cli/tests/stack-release-fixture.mjs\n414d39e37d8e332761a6120ab728f67538e36082bf1f609eec7747bebdb434d4 packages/cli/tests/stack-release.test.mjs\nfbaa36e64129aee324341364bde831c379c098b97932cceadb7fcaa7909d4b20 packages/cli/tests/tester-http.test.mjs\n04b7c1165c4fa73137a72e8373d70fd7d96a5897b5511170e6d41908a7667e96 packages/webui/README.md\n934ab2f9b6bde72aafe7814758550c283eb25a4f3a23b284021b13718d67178f packages/webui/src/bus.mjs\n63dc2af0ff0c8f7c624268a78ffeede1593182a38d657a53e597338edf09226e packages/webui/src/cli.mjs\nda624f188c69e016ff82e1d1dcae1c579e68a57f0f25d773bb5d8bca56b80ee1 packages/webui/src/serve.mjs\n1c1fe0c795adac97f28502564a75f26d5666d9be5ae51169f7dd5bb7741fad16 packages/webui/src/tester-socket.mjs\n2f3ea92cbe1ca115301ee1e3b8fc55a206842606237446b700881ee57a1b18cb packages/webui/tests/browser.mjs\nf505b5fc635aa699556c237a92401b495943e45e25ebfadab945d64477e3e86e packages/webui/tests/library-cli.test.mjs\nedef361c4654ec010809c12c87c00197ec6e6b8337863068c4ffa144e291e300 packages/webui/tests/proof-cli.test.mjs\n5f34d699a1bce6f46f286def1a93fdd4d863d06bec6839677fae1041be7153dd packages/webui/tests/proof.test.mjs\n6457debc3d233a24643be00e4876e1129db664da2fbf5cc7168f3e371c46f313 packages/webui/tests/service.test.mjs\n3c1d2481eff2271c1c83f7ec2e9d616ac22600d032af23afc47f919c58f1b9ba packages/webui/tests/sso.test.mjs\n9215ff92df774ba1e83819b87f758b53c8f3d19c2824c916490165914f1cb9e8 packages/webui/tests/tester-browser-fixture.mjs\n89ffc5cbed1630a523080e8561d3678c1f781fb657199a5cd6cdb8189f082c73 packages/webui/tests/tester-browser.test.mjs\n23219434713fbd283a1e5cb1a373ed9d19d0963e171636dbf39bed3d60cd1316 packages/webui/tests/tester-cli.test.mjs\n7d4f3a59d21675807b1bfb92d40b7378134df450590bc6d42fa4103ab1343348 packages/webui/tests/tester-fixture.mjs\n7f0fc2daead11048cb86e0a74b6a223314785b1740a2927461d012b876a69f18 packages/webui/tests/tester-listener.test.mjs\nc01a942da1ad66a08a4e7c92d3c7e64f517530acfe5517f1e9895767d5ea3c0b packages/webui/tests/tester-socket.test.mjs\nb916a2793311d21e3ce7117e9b5e0768e6e33c841fa3aa39fb7ffc9c5974acd2 scripts/stack-release.sh\n6e7069cca9f7aef84c17f15611f2d7e0d09083a363c23da8dbd14d3ed29f0f40 scripts/webui-service.sh\n"
},
"request": "comment",
"attempts": [
{
"op": "gander-196-manifest-r6",
"by": "gander",
"at": "2026-10-21T20:51:08.099Z",
"state": "failed",
"comment": null,
"transport": {
"outcome": "failed",
"status": null,
"comment": null,
"detail": "pre-send: the credential or account check failed"
},
"resolutions": []
},
{
"op": "sage-196-review-r2b",
"by": "sage",
"at": "2026-10-21T20:53:25.208Z",
"state": "posted",
"comment": 30155,
"transport": {
"outcome": "posted",
"status": 201,
"comment": 30155,
"detail": "created"
},
"resolutions": []
}
],
"duplicateRisk": false,
"receipts": [
{
"reviewer": "duckworth",
"op": "duckworth-0098-record",
"at": "2026-10-21T20:54:59.089Z",
"verdict": "approve",
"comment": 30156,
"candidate": "537d30aef70b6cd19ab7283ce2a702d9ac55bb179c60d6a55f50b45a0e5b149d"
}
]
}
]
},
"claim": {
"seat": "gander",
"op": "gander-196-start"
},
"note": null,
"claim": null,
"note": "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.",
"blockedReason": null,
"createdAt": "2026-10-20T19:01:13.621Z",
"updatedAt": "2026-10-21T20:17:46.980Z",
"updatedBy": "gander"
"updatedAt": "2026-10-21T20:58:33.437Z",
"updatedBy": "sage"
},
{
"id": 197,
@@ -25085,7 +25136,7 @@
"closes": [
1757
],
"state": "briefed",
"state": "in-review",
"previousState": null,
"required": false,
"requiredSince": null,
@@ -25105,12 +25156,63 @@
"reviewers": [
"webby"
],
"review": null,
"claim": null,
"review": {
"rounds": [
{
"n": 1,
"op": "morgana-244-inreview-r1",
"by": "morgana",
"at": "2026-10-21T20:49:44.772Z",
"issue": 1757,
"candidate": {
"kind": "manifest",
"digest": "6e7e3e3c290fe5034cc8766b6dc7eeb3725a99f330837a357c66381d18be1b60",
"text": "87e5deae3021116da0e78ce9c47bc2899dbcbc4d7207b8b293400d9b1549807c agents/morgana/work/queue-244/BUILD.md\n75d83e2cd7bc7173a0b8175600df819fb2bdf522d3930b265a78d4fcd31186d1 packages/cli/tests/gate-lane-auth.test.mjs\n5063d9458b2bf61ae85e8470f713f326a2ae1e19e6dd85f71ba531782b312fa8 packages/cli/tests/gate-lane-image.test.mjs\nbe706dff832257bd4781abeabd19faa4ac16edf9a4af0c96457e33cf3bb6deb3 scripts/test-release.sh\n10c4abb70bf9b83e617089b06a5436cc7fa6b866a176458b22128c3a15163f94 scripts/test-task.sh\nb488c7f275721e3e7b93d02a91345c0f5af99be0a5aed2e3f171d46ceca24521 scripts/testing/README.md\n8c5b08b136eda3964d8729ef646ff09f028c8348eded3bb6f79fb3fdd101bee7 scripts/testing/gate-lane.mjs\n10e87fcc9bce0cead8ff27af27cc6e0910820cd15da8e449cb5c07e379392e75 scripts/testing/lane-docker.mjs\n"
},
"request": "comment",
"attempts": [
{
"op": "morgana-244-inreview-r1",
"by": "morgana",
"at": "2026-10-21T20:49:44.772Z",
"state": "failed",
"comment": null,
"transport": {
"outcome": "failed",
"status": null,
"comment": null,
"detail": "pre-send: the credential or account check failed"
},
"resolutions": []
},
{
"op": "sage-244-review-r1b",
"by": "sage",
"at": "2026-10-21T20:52:11.784Z",
"state": "posted",
"comment": 30154,
"transport": {
"outcome": "posted",
"status": 201,
"comment": 30154,
"detail": "created"
},
"resolutions": []
}
],
"duplicateRisk": false,
"receipts": []
}
]
},
"claim": {
"seat": "morgana",
"op": "morgana-244-start"
},
"note": null,
"blockedReason": null,
"createdAt": "2026-10-21T09:15:58.616Z",
"updatedAt": "2026-10-21T19:33:20.763Z",
"updatedAt": "2026-10-21T20:52:44.248Z",
"updatedBy": "sage"
},
{
@@ -25709,6 +25811,41 @@
],
"duplicateRisk": false,
"receipts": []
},
{
"n": 2,
"op": "darkwing-251-inreview-r2",
"by": "darkwing",
"at": "2026-10-21T21:10:40.372Z",
"issue": 1756,
"candidate": {
"kind": "manifest",
"digest": "f2bdd68461976b682df8ae0d7cf0e01d64eebf90162eafb1e3a505956c8ce278",
"text": "e7d64f42fcf125d3a170cc00d749a5c6d0102f2130bd7c4bbe7f8c0ce8aef51c agents/darkwing/work/queue-251/BUILD.md\n280132c727bb3f9f392f6e95a6ff199efe947c4b40c1e8896640f75498cd6044 packages/bus/tests/host-run.test.mjs\n"
},
"request": "comment",
"attempts": [
{
"op": "darkwing-251-inreview-r2",
"by": "darkwing",
"at": "2026-10-21T21:10:40.372Z",
"state": "posted",
"comment": 30164,
"transport": null,
"resolutions": [
{
"verb": "resolve",
"op": "darkwing-251-resolve-r2",
"by": "darkwing",
"at": "2026-10-21T21:12:19.309Z",
"comment": 30164,
"reason": null
}
]
}
],
"duplicateRisk": false,
"receipts": []
}
]
},
@@ -25719,7 +25856,7 @@
"note": "mos-1185 item 2. If proof 6 fails outside 197's paths in a pair wave, this and row 250 go ahead of 197 (item 4).",
"blockedReason": null,
"createdAt": "2026-10-21T17:13:07.643Z",
"updatedAt": "2026-10-21T19:18:59.931Z",
"updatedAt": "2026-10-21T21:12:19.309Z",
"updatedBy": "darkwing"
},
{
@@ -25761,7 +25898,7 @@
"closes": [
1758
],
"state": "queued",
"state": "briefed",
"previousState": null,
"required": false,
"requiredSince": null,
@@ -25786,16 +25923,20 @@
"note": null,
"blockedReason": null,
"createdAt": "2026-10-21T19:47:54.542Z",
"updatedAt": "2026-10-21T19:47:54.542Z",
"updatedAt": "2026-10-21T21:11:12.638Z",
"updatedBy": "sage"
},
{
"id": 254,
"piece": "Library sync follow-up: client timeout, STATUS codes, README (222 deferrals, #1759)",
"owner": "megavolt",
"issues": [],
"closes": [],
"state": "queued",
"issues": [
1759
],
"closes": [
1759
],
"state": "briefed",
"previousState": null,
"required": false,
"requiredSince": null,
@@ -25820,7 +25961,7 @@
"note": "Dewey's deferrals from 222 approval c30138. Chained after 196 (human-cli channel proof). Issue #1759.",
"blockedReason": null,
"createdAt": "2026-10-21T20:17:15.497Z",
"updatedAt": "2026-10-21T20:17:15.497Z",
"updatedAt": "2026-10-21T21:11:45.908Z",
"updatedBy": "sage"
},
{
@@ -104262,6 +104403,453 @@
"receipt": "ok sage-r458-add rev 2976 row 255 none→queued"
},
"viewSha": "9b2b48147dff353bc92e8f47eb3d9bc0f248b2b382d7148d1591f1c8437cce04"
},
{
"rev": 2977,
"op": "morgana-244-start",
"verb": "move",
"args": {
"id": 244,
"to": "in-progress",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "morgana",
"at": "2026-10-21T20:49:10.163Z",
"semantics": 2,
"result": {
"row": 244,
"from": "briefed",
"to": "in-progress",
"round": null,
"issue": null,
"candidate": null,
"receipt": "ok morgana-244-start rev 2977 row 244 briefed→in-progress"
},
"viewSha": "1923cb487ec169ba5eeb58cdaba354b3a0f061136fddd3b99fc67706af18a64a"
},
{
"rev": 2978,
"op": "morgana-244-inreview-r1",
"verb": "move",
"args": {
"id": 244,
"to": "in-review",
"reason": null,
"candidate": "/mnt/storage/scratch/morgana-r244/candidate-manifest.sha256",
"evidence": null,
"issue": null
},
"by": "morgana",
"at": "2026-10-21T20:49:44.772Z",
"semantics": 2,
"result": {
"row": 244,
"from": "in-progress",
"to": "in-review",
"round": 1,
"issue": 1757,
"candidate": {
"kind": "manifest",
"digest": "6e7e3e3c290fe5034cc8766b6dc7eeb3725a99f330837a357c66381d18be1b60",
"text": "87e5deae3021116da0e78ce9c47bc2899dbcbc4d7207b8b293400d9b1549807c agents/morgana/work/queue-244/BUILD.md\n75d83e2cd7bc7173a0b8175600df819fb2bdf522d3930b265a78d4fcd31186d1 packages/cli/tests/gate-lane-auth.test.mjs\n5063d9458b2bf61ae85e8470f713f326a2ae1e19e6dd85f71ba531782b312fa8 packages/cli/tests/gate-lane-image.test.mjs\nbe706dff832257bd4781abeabd19faa4ac16edf9a4af0c96457e33cf3bb6deb3 scripts/test-release.sh\n10c4abb70bf9b83e617089b06a5436cc7fa6b866a176458b22128c3a15163f94 scripts/test-task.sh\nb488c7f275721e3e7b93d02a91345c0f5af99be0a5aed2e3f171d46ceca24521 scripts/testing/README.md\n8c5b08b136eda3964d8729ef646ff09f028c8348eded3bb6f79fb3fdd101bee7 scripts/testing/gate-lane.mjs\n10e87fcc9bce0cead8ff27af27cc6e0910820cd15da8e449cb5c07e379392e75 scripts/testing/lane-docker.mjs\n"
},
"receipt": "ok morgana-244-inreview-r1 rev 2978 row 244 in-progress→in-review round 1 on #1757; request morgana-244-inreview-r1 requesting"
},
"viewSha": "ffc26c20562fd80286b5d36f796d235f13c89fbc1b828b88187c1350de4abb0f"
},
{
"rev": 2979,
"op": "morgana-244-inreview-r1.outcome",
"verb": "review-outcome",
"args": {
"id": 244,
"attempt": "morgana-244-inreview-r1",
"outcome": "failed",
"status": null,
"comment": null,
"detail": "pre-send: the credential or account check failed"
},
"by": "morgana",
"at": "2026-10-21T20:50:16.649Z",
"semantics": 2,
"result": {
"row": 244,
"round": 1,
"attempt": "morgana-244-inreview-r1",
"state": "failed",
"receipt": "ok morgana-244-inreview-r1.outcome rev 2979 row 244 round 1 request morgana-244-inreview-r1 failed"
},
"viewSha": "39f8cf4a5854d75ba63b0706d8bffd312fe51821ce712a3f7d1c6aa787fecff2"
},
{
"rev": 2980,
"op": "gander-196-manifest-r6",
"verb": "move",
"args": {
"id": 196,
"to": "in-review",
"reason": null,
"candidate": "/home/jwoltje/.t3/worktrees/mosaic-stack/gander-row196/agents/gander/work/queue-196/CANDIDATE.sha256",
"evidence": null,
"issue": null
},
"by": "gander",
"at": "2026-10-21T20:51:08.099Z",
"semantics": 2,
"result": {
"row": 196,
"from": "in-progress",
"to": "in-review",
"round": 2,
"issue": 1694,
"candidate": {
"kind": "manifest",
"digest": "537d30aef70b6cd19ab7283ce2a702d9ac55bb179c60d6a55f50b45a0e5b149d",
"text": "0ad63c4f7a6cdd5b2587a0969fe7a95539cf1e4601f51fb33cc4df94a718b2b5 docs/TOOLS.md\n10c996d323ac46d45548f7748d40b246de93f8c5eacae9f951c6eeb5dcc62697 packages/bus/README.md\n60720461a7994011a9a30594715eb2c992be699cd61c656e77cbd609ad05328e packages/bus/fixtures/peer-client.mjs\n0c6d96abe4607cba0b02b0638a2fc217ff8135bac9a47761a0ef015fbe9d3079 packages/bus/src/human.mjs\nd5d98a9133a1b449acc48143fd559d890a470c6ea210b30b9b70e491e6cd855a packages/bus/src/interpreter.mjs\n29c88655e8b9aed8db604573c9213fd9f3c40ef35ff04f4eda8fbe8f1b8a32d7 packages/bus/src/peercred.mjs\nd1536a8f472fa5b3773adb10555529ed049ad63b647c0b44c00ce5f25ce09f85 packages/bus/src/peercred.py\n32ef39d80b523bdd586c29620d7a752c309c8e1aa70befc1414125468f3411dd packages/bus/src/principal.mjs\n190a4f56c44131f8a2325bc9c4a822556de432d1afd2e75ffe3168a171320bed packages/bus/src/runtime.mjs\n7be306c830aa98763b34b329995b40e9df443483fe20fcd69100a3acb6311bd6 packages/bus/src/server.mjs\ne99133c3c957a1e856e404343721add2430e4a0d8c75452517aef04942af56ab packages/bus/tests/channel-proof.test.mjs\nd19779b97e80ed3d62f58d0e798df46c23f45fa02b14fb4ac7a2ff4690a734e2 packages/bus/tests/interpreter.test.mjs\na39af877a2d9143cc8d0584af0d603ca7ab651db8787e5816579762f17295887 packages/bus/tests/peer-transport.test.mjs\n2c4c0bed41e546ce94535d8c4547b1cf5552a181183525dd1d52e2af1d64ccb6 packages/bus/tests/peercred.test.mjs\ncefbc0688d823c69196d94bb71467884acd639070721286d7e8e4e986c8240c1 packages/bus/tests/principal.test.mjs\n95c7acdb47f753d1e129adaa90b5937318d8d006ca47be9d92729fdae013776f packages/cli/README.md\n4bd7bdf0898844684ebf9444b110d2c5b6fa874b2681183e54f1f62f97823192 packages/cli/src/tester-http.mjs\na7cb20db2941b3b3abb09bcaa55eac92a1d420cf4ec79c6f83e4bb3235e04a2b packages/cli/tests/stack-release-fixture.mjs\n414d39e37d8e332761a6120ab728f67538e36082bf1f609eec7747bebdb434d4 packages/cli/tests/stack-release.test.mjs\nfbaa36e64129aee324341364bde831c379c098b97932cceadb7fcaa7909d4b20 packages/cli/tests/tester-http.test.mjs\n04b7c1165c4fa73137a72e8373d70fd7d96a5897b5511170e6d41908a7667e96 packages/webui/README.md\n934ab2f9b6bde72aafe7814758550c283eb25a4f3a23b284021b13718d67178f packages/webui/src/bus.mjs\n63dc2af0ff0c8f7c624268a78ffeede1593182a38d657a53e597338edf09226e packages/webui/src/cli.mjs\nda624f188c69e016ff82e1d1dcae1c579e68a57f0f25d773bb5d8bca56b80ee1 packages/webui/src/serve.mjs\n1c1fe0c795adac97f28502564a75f26d5666d9be5ae51169f7dd5bb7741fad16 packages/webui/src/tester-socket.mjs\n2f3ea92cbe1ca115301ee1e3b8fc55a206842606237446b700881ee57a1b18cb packages/webui/tests/browser.mjs\nf505b5fc635aa699556c237a92401b495943e45e25ebfadab945d64477e3e86e packages/webui/tests/library-cli.test.mjs\nedef361c4654ec010809c12c87c00197ec6e6b8337863068c4ffa144e291e300 packages/webui/tests/proof-cli.test.mjs\n5f34d699a1bce6f46f286def1a93fdd4d863d06bec6839677fae1041be7153dd packages/webui/tests/proof.test.mjs\n6457debc3d233a24643be00e4876e1129db664da2fbf5cc7168f3e371c46f313 packages/webui/tests/service.test.mjs\n3c1d2481eff2271c1c83f7ec2e9d616ac22600d032af23afc47f919c58f1b9ba packages/webui/tests/sso.test.mjs\n9215ff92df774ba1e83819b87f758b53c8f3d19c2824c916490165914f1cb9e8 packages/webui/tests/tester-browser-fixture.mjs\n89ffc5cbed1630a523080e8561d3678c1f781fb657199a5cd6cdb8189f082c73 packages/webui/tests/tester-browser.test.mjs\n23219434713fbd283a1e5cb1a373ed9d19d0963e171636dbf39bed3d60cd1316 packages/webui/tests/tester-cli.test.mjs\n7d4f3a59d21675807b1bfb92d40b7378134df450590bc6d42fa4103ab1343348 packages/webui/tests/tester-fixture.mjs\n7f0fc2daead11048cb86e0a74b6a223314785b1740a2927461d012b876a69f18 packages/webui/tests/tester-listener.test.mjs\nc01a942da1ad66a08a4e7c92d3c7e64f517530acfe5517f1e9895767d5ea3c0b packages/webui/tests/tester-socket.test.mjs\nb916a2793311d21e3ce7117e9b5e0768e6e33c841fa3aa39fb7ffc9c5974acd2 scripts/stack-release.sh\n6e7069cca9f7aef84c17f15611f2d7e0d09083a363c23da8dbd14d3ed29f0f40 scripts/webui-service.sh\n"
},
"receipt": "ok gander-196-manifest-r6 rev 2980 row 196 in-progress→in-review round 2 on #1694; request gander-196-manifest-r6 requesting"
},
"viewSha": "9f6ae73cc3c59e22f71ac340a7d09c5c9c4de226e3bf98f3bf45f33851a020a5"
},
{
"rev": 2981,
"op": "gander-196-manifest-r6.outcome",
"verb": "review-outcome",
"args": {
"id": 196,
"attempt": "gander-196-manifest-r6",
"outcome": "failed",
"status": null,
"comment": null,
"detail": "pre-send: the credential or account check failed"
},
"by": "gander",
"at": "2026-10-21T20:51:39.942Z",
"semantics": 2,
"result": {
"row": 196,
"round": 2,
"attempt": "gander-196-manifest-r6",
"state": "failed",
"receipt": "ok gander-196-manifest-r6.outcome rev 2981 row 196 round 2 request gander-196-manifest-r6 failed"
},
"viewSha": "f79bfeade3f582818a8ff4e8cd34ee5f9a8f54828d1c95d8bd62d227b34338e6"
},
{
"rev": 2982,
"op": "sage-244-review-r1b",
"verb": "review-request",
"args": {
"id": 244
},
"by": "sage",
"at": "2026-10-21T20:52:11.784Z",
"semantics": 2,
"result": {
"row": 244,
"round": 1,
"attempt": "sage-244-review-r1b",
"state": "requesting",
"receipt": "ok sage-244-review-r1b rev 2982 row 244 round 1 request sage-244-review-r1b requesting"
},
"viewSha": "88925213d32cb6b355fe6c54a293ff16f473a174aadb46b4aa1f196fabe36e1f"
},
{
"rev": 2983,
"op": "sage-244-review-r1b.outcome",
"verb": "review-outcome",
"args": {
"id": 244,
"attempt": "sage-244-review-r1b",
"outcome": "posted",
"status": 201,
"comment": 30154,
"detail": "created"
},
"by": "sage",
"at": "2026-10-21T20:52:44.248Z",
"semantics": 2,
"result": {
"row": 244,
"round": 1,
"attempt": "sage-244-review-r1b",
"state": "posted",
"receipt": "ok sage-244-review-r1b.outcome rev 2983 row 244 round 1 request sage-244-review-r1b posted comment 30154"
},
"viewSha": "4b3da809621219ffbff95bff6adf31f71440a496f04d5a3e506cf4bfb75eb444"
},
{
"rev": 2984,
"op": "sage-196-review-r2b",
"verb": "review-request",
"args": {
"id": 196
},
"by": "sage",
"at": "2026-10-21T20:53:25.208Z",
"semantics": 2,
"result": {
"row": 196,
"round": 2,
"attempt": "sage-196-review-r2b",
"state": "requesting",
"receipt": "ok sage-196-review-r2b rev 2984 row 196 round 2 request sage-196-review-r2b requesting"
},
"viewSha": "c1d882f65b6ed95b007c78b8a532a80a731e34dcf70ce5b3c85eafb3832a6efd"
},
{
"rev": 2985,
"op": "sage-196-review-r2b.outcome",
"verb": "review-outcome",
"args": {
"id": 196,
"attempt": "sage-196-review-r2b",
"outcome": "posted",
"status": 201,
"comment": 30155,
"detail": "created"
},
"by": "sage",
"at": "2026-10-21T20:53:57.304Z",
"semantics": 2,
"result": {
"row": 196,
"round": 2,
"attempt": "sage-196-review-r2b",
"state": "posted",
"receipt": "ok sage-196-review-r2b.outcome rev 2985 row 196 round 2 request sage-196-review-r2b posted comment 30155"
},
"viewSha": "cd92dcbc6f2c8a57380369e9c57dd09cf6db128545ff11e77fa246d80bfe614a"
},
{
"rev": 2986,
"op": "duckworth-0098-record",
"verb": "review-record",
"args": {
"id": 196,
"verdict": "approve",
"comment": 30156,
"candidate": "537d30aef70b6cd19ab7283ce2a702d9ac55bb179c60d6a55f50b45a0e5b149d"
},
"by": "duckworth",
"at": "2026-10-21T20:54:59.089Z",
"semantics": 2,
"result": {
"row": 196,
"round": 2,
"verdict": "approve",
"receipt": "ok duckworth-0098-record rev 2986 row 196 round 2 approve by duckworth (comment 30156)"
},
"viewSha": "204d0c2d64d27c6e3480f866cea0c2aa3657851fc56dd2ee2b3ecdd3cdb0ed1b"
},
{
"rev": 2987,
"op": "sage-196-landing-note",
"verb": "note",
"args": {
"id": 196,
"text": "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."
},
"by": "sage",
"at": "2026-10-21T20:58:02.224Z",
"semantics": 2,
"result": {
"row": 196,
"field": "note",
"receipt": "ok sage-196-landing-note rev 2987 row 196 note"
},
"viewSha": "f6ee00477e8cb4bfea13eea5590c16b713eec9c485cc3c82da79cbbd53740473"
},
{
"rev": 2988,
"op": "sage-196-done",
"verb": "move",
"args": {
"id": 196,
"to": "done",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "sage",
"at": "2026-10-21T20:58:33.437Z",
"semantics": 2,
"result": {
"row": 196,
"from": "in-review",
"to": "done",
"round": 2,
"issue": null,
"candidate": null,
"receipt": "ok sage-196-done rev 2988 row 196 in-review→done round 2"
},
"viewSha": "785cd1b3d64c9fe685cef50753c9a04b5718ecc097d7199dfb9bc2cf27ae4361"
},
{
"rev": 2989,
"op": "sage-254-issue",
"verb": "set",
"args": {
"id": 254,
"field": "issues",
"value": [
1759
],
"reason": null
},
"by": "sage",
"at": "2026-10-21T21:09:30.787Z",
"semantics": 2,
"result": {
"row": 254,
"field": "issues",
"from": [],
"to": [
1759
],
"closes": {
"from": [],
"to": [
1759
],
"narrowed": false
},
"receipt": "ok sage-254-issue rev 2989 row 254 issues: none→#1759; closes: none→#1759"
},
"viewSha": "209ae6f110d6eeed579b3f795719a05351dc6c7f37c151227d73f8bf89d2a53e"
},
{
"rev": 2990,
"op": "darkwing-251-inprogress-r2",
"verb": "move",
"args": {
"id": 251,
"to": "in-progress",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "darkwing",
"at": "2026-10-21T21:10:03.811Z",
"semantics": 2,
"result": {
"row": 251,
"from": "in-review",
"to": "in-progress",
"round": null,
"issue": null,
"candidate": null,
"receipt": "ok darkwing-251-inprogress-r2 rev 2990 row 251 in-review→in-progress"
},
"viewSha": "ee26d40e917bd52bf2770819fe5b5c81a2d3ea9546cb8c2113af215bd40e9519"
},
{
"rev": 2991,
"op": "darkwing-251-inreview-r2",
"verb": "move",
"args": {
"id": 251,
"to": "in-review",
"reason": null,
"candidate": "/home/jwoltje/darkwing-scratch/r251/manifest-27f01ef63a6e9d2fcb7198ffc11ae9e389ab4da5.sha256",
"evidence": null,
"issue": null
},
"by": "darkwing",
"at": "2026-10-21T21:10:40.372Z",
"semantics": 2,
"result": {
"row": 251,
"from": "in-progress",
"to": "in-review",
"round": 2,
"issue": 1756,
"candidate": {
"kind": "manifest",
"digest": "f2bdd68461976b682df8ae0d7cf0e01d64eebf90162eafb1e3a505956c8ce278",
"text": "e7d64f42fcf125d3a170cc00d749a5c6d0102f2130bd7c4bbe7f8c0ce8aef51c agents/darkwing/work/queue-251/BUILD.md\n280132c727bb3f9f392f6e95a6ff199efe947c4b40c1e8896640f75498cd6044 packages/bus/tests/host-run.test.mjs\n"
},
"receipt": "ok darkwing-251-inreview-r2 rev 2991 row 251 in-progress→in-review round 2 on #1756; request darkwing-251-inreview-r2 requesting"
},
"viewSha": "f05f76b194c6b73232db4892921f4b56c310d5b493981287dd8a4217b23edec3"
},
{
"rev": 2992,
"op": "sage-253-briefed",
"verb": "move",
"args": {
"id": 253,
"to": "briefed",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "sage",
"at": "2026-10-21T21:11:12.638Z",
"semantics": 2,
"result": {
"row": 253,
"from": "queued",
"to": "briefed",
"round": null,
"issue": null,
"candidate": null,
"receipt": "ok sage-253-briefed rev 2992 row 253 queued→briefed"
},
"viewSha": "48947ebf2f45373f6443fbe62f43616b8c2dba7e3ed3d5134ae8e80383063658"
},
{
"rev": 2993,
"op": "sage-254-briefed",
"verb": "move",
"args": {
"id": 254,
"to": "briefed",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "sage",
"at": "2026-10-21T21:11:45.908Z",
"semantics": 2,
"result": {
"row": 254,
"from": "queued",
"to": "briefed",
"round": null,
"issue": null,
"candidate": null,
"receipt": "ok sage-254-briefed rev 2993 row 254 queued→briefed"
},
"viewSha": "ec736c1e91f42fc9e2141454630e1664000d2be2562c03052d04e08015e32af7"
},
{
"rev": 2994,
"op": "darkwing-251-resolve-r2",
"verb": "review-resolve",
"args": {
"id": 251,
"attempt": "darkwing-251-inreview-r2",
"comment": 30164
},
"by": "darkwing",
"at": "2026-10-21T21:12:19.309Z",
"semantics": 2,
"result": {
"row": 251,
"round": 2,
"attempt": "darkwing-251-inreview-r2",
"state": "posted",
"receipt": "ok darkwing-251-resolve-r2 rev 2994 row 251 round 2 request darkwing-251-inreview-r2 requesting→posted comment 30164"
},
"viewSha": "ad44355179caf42d60dc72ac782bfb1abe147df011c93639df2a727f20c9832b"
}
]
}
+24 -5
View File
@@ -458,17 +458,36 @@ test('T27: ssh-add -t drops the key from an agent the test keeps alive', async (
const agent = spawn('ssh-agent', ['-D', '-a', sock], { stdio: 'ignore' });
t.after(() => agent.kill('SIGKILL'));
assert.ok(await until(() => existsSync(sock)));
await addKey(sock, readFileSync(join(dir, 'k'), 'utf8'), 1);
const list = () => {
// A failed list names its cause: when each step ran on the monotonic clock (hrtime) and on
// /proc/uptime (CLOCK_BOOTTIME, which ssh-agent's lifetime counts in whole seconds), and
// whether the agent and its socket were still there.
const marks = [];
const mark = (name) => marks.push({ name, ns: process.hrtime.bigint(), uptime: readFileSync('/proc/uptime', 'utf8').split(' ')[0] });
const timing = () => {
const at = (m) => `${m.name} +${(Number(m.ns - marks[0].ns) / 1e6).toFixed(1)} ms (hrtime ${(Number(m.ns % 1_000_000_000n) / 1e9).toFixed(4)} s into its second, uptime ${m.uptime})`;
const alive = agent.exitCode === null && agent.signalCode === null;
return `${marks.map(at).join('; ')}; agent ${alive ? 'alive' : `gone (${agent.exitCode ?? agent.signalCode})`}, socket ${existsSync(sock) ? 'present' : 'missing'}`;
};
// ssh-agent sets a key's death to the whole second of CLOCK_BOOTTIME at the add plus the
// lifetime, and drops it once that whole second is reached. So `-t N` keeps a key between N - 1
// and N seconds: `-t 1` added just before a second boundary was gone within milliseconds (#453).
// `-t 2` keeps it at least 1 s, and the sleep waits past the 2 s ceiling.
mark('addKey start');
await addKey(sock, readFileSync(join(dir, 'k'), 'utf8'), 2);
mark('addKey resolved');
const list = (name) => {
mark(name);
try {
return execFileSync('ssh-add', ['-l'], { env: { PATH: '/usr/bin:/bin', SSH_AUTH_SOCK: sock }, encoding: 'utf8' });
} catch (e) {
return e.stdout;
}
};
assert.match(list(), /SHA256:/);
await sleep(2500);
assert.match(list(), /no identities/);
const first = list('first list');
assert.match(first, /SHA256:/, `first list got ${JSON.stringify(first)}: ${timing()}`);
await sleep(3500);
const second = list('second list');
assert.match(second, /no identities/, `second list got ${JSON.stringify(second)}: ${timing()}`);
});
// The test's own limit makes a hung addKey (M37a, M37b) fail in seconds, not at the batch timeout.
+100
View File
@@ -0,0 +1,100 @@
// Row 244: no gate step mounts the host's pi auth file or makes a provider call.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, symlinkSync, rmSync, cpSync, readFileSync, existsSync, realpathSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { guardDocker } from '../../../scripts/testing/lane-docker.mjs';
import { cleanEnv } from '../../../scripts/testing/gate-lane.mjs';
const REPO = realpathSync(fileURLToPath(new URL('../../../', import.meta.url)));
const HOST_AUTH = '/home/jwoltje/.pi/agent/auth.json';
const LIVE_SKIP = 'skip live provider cases (row 244: no provider call in the gate)';
const scratch = t => { const root = mkdtempSync('/mnt/storage/scratch/gt-'); t.after(() => rmSync(root, { recursive: true, force: true })); return root; };
const fixture = (dir, name = 'auth.json') => { const file = join(dir, name); writeFileSync(file, '{}'); chmodSync(file, 0o600); return file; };
const RUN = ['compose', 'run', '--rm', '-T', 'mosaic-agent', 'prompt'];
test('guardDocker refuses a compose run unless PI_AUTH_FILE is a regular file inside the lane temp root', t => {
const tempRoot = scratch(t), outside = scratch(t);
const run = { tempRoot, checkout: join(tempRoot, 'checkout'), imageId: 'sha256:' + 'a'.repeat(64), tag: 'mosaic-poc-agent:fixture' };
const lane = fixture(tempRoot, 'pi-auth.json');
mkdirSync(join(tempRoot, 'suite', 'pi'), { recursive: true });
const suite = fixture(join(tempRoot, 'suite', 'pi'));
for (const file of [lane, suite]) assert.equal(guardDocker(RUN, run, { PI_AUTH_FILE: file })[0], 'compose');
const link = join(tempRoot, 'escape.json'); symlinkSync(fixture(outside), link);
mkdirSync(join(tempRoot, 'dir.json'));
for (const env of [{}, { PI_AUTH_FILE: '' }, { PI_AUTH_FILE: HOST_AUTH }, { PI_AUTH_FILE: fixture(outside) }, { PI_AUTH_FILE: link },
{ PI_AUTH_FILE: join(tempRoot, 'missing.json') }, { PI_AUTH_FILE: join(tempRoot, 'dir.json') }, { PI_AUTH_FILE: relative(process.cwd(), lane) }])
assert.throws(() => guardDocker(RUN, run, env), /Compose auth mount not a lane fixture/, JSON.stringify(env));
// The option check still comes first, and the other allowed verbs need no auth file.
assert.throws(() => guardDocker(['compose', 'run', '--build', 'mosaic-agent'], run, {}), /options not allowed/);
assert.deepEqual(guardDocker(['info'], run, {}), ['info']);
assert.deepEqual(guardDocker(['image', 'inspect', run.tag], run, {}), ['image', 'inspect', run.tag]);
});
test('cleanEnv points PI_AUTH_FILE at the lane fixture and drops MOSAIC_LIVE_PROVIDER', t => {
const tempRoot = scratch(t), saved = { PI_AUTH_FILE: process.env.PI_AUTH_FILE, MOSAIC_LIVE_PROVIDER: process.env.MOSAIC_LIVE_PROVIDER };
process.env.PI_AUTH_FILE = HOST_AUTH; process.env.MOSAIC_LIVE_PROVIDER = '1';
let env;
try { env = cleanEnv({ tempRoot, lane: 'a', stateRoot: join(tempRoot, 'state'), out: join(tempRoot, 'out') }, { project: 'fixture' }, '/fake/docker'); }
finally { for (const [k, v] of Object.entries(saved)) if (v === undefined) delete process.env[k]; else process.env[k] = v; }
assert.equal(env.PI_AUTH_FILE, join(tempRoot, 'pi-auth.json'));
assert.equal(env.MOSAIC_LIVE_PROVIDER, undefined);
});
// Both suites run in a private copy of the checkout: test-release.sh rewrites RELEASE while it runs.
// A fake docker records each compose run's PI_AUTH_FILE and MOSAIC_ADAPTER, and answers as the mock adapter.
// It never reads or stats the host auth file.
const SKIP_COPY = new Set(['.git', 'node_modules', 'v1', 'agents', 'docs', 'BUILD-LOG.md', join('scripts', 'foundation')]);
function suiteLane(t) {
const root = scratch(t), checkout = join(root, 'c'), bin = join(root, 'bin'), tmp = join(root, 'tmp');
cpSync(REPO, checkout, { recursive: true, filter: src => !SKIP_COPY.has(relative(REPO, src)) });
symlinkSync(realpathSync(join(REPO, 'node_modules')), join(checkout, 'node_modules'));
mkdirSync(bin); mkdirSync(tmp, { mode: 0o700 });
writeFileSync(join(bin, 'docker'), `#!/bin/bash
[ "$1" = compose ] || exit 0
f="\${PI_AUTH_FILE-}"
if [ -z "\${PI_AUTH_FILE+x}" ]; then kind=unset; elif [ "$f" = ${HOST_AUTH} ]; then kind=host; else kind="$(stat -c '%F %a' -- "$f" 2>/dev/null || echo missing)"; fi
printf '%s\\t%s\\t%s\\n' "$f" "$kind" "\${MOSAIC_ADAPTER-}" >> "$FAKE_DOCKER_RECORD"
[ "\${MOSAIC_ADAPTER-}" = mock ] && printf '%s\\n' "\${MOSAIC_MOCK_RESPONSE-}"
exit 0
`);
chmodSync(join(bin, 'docker'), 0o700);
return (script, extra = {}) => {
const record = join(root, `${script}-${Object.keys(extra).length}.tsv`), begin = performance.now();
const env = { PATH: `${bin}:${process.env.PATH}`, HOME: process.env.HOME, TMPDIR: tmp, LANG: 'C.UTF-8', FAKE_DOCKER_RECORD: record, ...extra };
const r = spawnSync('bash', [join(checkout, 'scripts', script)], { env, encoding: 'utf8', timeout: 600000 });
t.diagnostic(JSON.stringify({ script, extra: Object.keys(extra), wallMs: Math.round(performance.now() - begin), status: r.status }));
const runs = existsSync(record) ? readFileSync(record, 'utf8').trim().split('\n').filter(Boolean).map(l => { const [file, kind, adapter] = l.split('\t'); return { file, kind, adapter }; }) : [];
return { out: r.stdout + r.stderr, runs, tmp };
};
}
const fixtureRuns = (runs, tmp) => runs.every(r => r.file !== HOST_AUTH && r.file.startsWith(tmp + '/') && r.kind === 'regular file 600');
test('test-release.sh activates through the mock adapter with a 0600 fixture auth file', t => {
const { out, runs, tmp } = suiteLane(t)('test-release.sh');
assert.ok(runs.length >= 3, `fault, healthy and repeat activations reach Compose\n${out}`);
assert.ok(fixtureRuns(runs, tmp), JSON.stringify(runs));
assert.deepEqual([...new Set(runs.map(r => r.adapter))], ['mock'], 'ruling A: no pi adapter in the gate');
assert.match(out, /OK +healthy activation succeeds/); assert.match(out, /OK +fault-injected activation refuses/);
});
test('test-task.sh mounts only its fixture, uses only the mock adapter and prints the live skip twice', t => {
const { out, runs, tmp } = suiteLane(t)('test-task.sh');
assert.ok(runs.length >= 1, out);
assert.ok(fixtureRuns(runs, tmp), JSON.stringify(runs));
assert.deepEqual([...new Set(runs.map(r => r.adapter))], ['mock']);
assert.equal(out.split('\n').filter(l => l === LIVE_SKIP).length, 2, out);
assert.match(out, /OK +hello task succeeds with exact marker/);
});
test('M-T: MOSAIC_LIVE_PROVIDER=1 runs the real-model cases, which fail under a fixture auth file', t => {
const lane = suiteLane(t), auth = join(scratch(t), 'operator-auth.json'); writeFileSync(auth, '{}'); chmodSync(auth, 0o600);
const { out, runs } = lane('test-task.sh', { MOSAIC_LIVE_PROVIDER: '1', PI_AUTH_FILE: auth });
assert.ok(!out.includes(LIVE_SKIP), out);
// The fake answers a pi run with nothing: the fork base misses REMEMBERED, recall misses the name.
assert.match(out, /FAIL fork base: teach succeeds/); assert.match(out, /FAIL recalled user name/);
assert.ok(runs.some(r => r.adapter === 'pi'), JSON.stringify(runs));
assert.ok(runs.every(r => r.file === auth), 'live mode keeps the operator\'s PI_AUTH_FILE');
});
+3 -3
View File
@@ -18,7 +18,7 @@ test('a changed COPY input or actual image ID refuses compatible image sharing',
test('warm Docker guard disables implicit build and pull and refuses image mutation',t=>{
assert.equal(typeof docker.guardDocker,'function','Docker guard required');
const run=composeFixture(t);
assert.deepEqual(docker.guardDocker(['compose','run','--rm','-T','mosaic-agent','prompt'],run),['compose','-f',join(run.checkout,'compose.yaml'),'-f',join(run.tempRoot,'compose-no-build.yaml'),'run','--pull','never','--no-deps','--rm','-T','mosaic-agent','prompt']);
assert.deepEqual(docker.guardDocker(['compose','run','--rm','-T','mosaic-agent','prompt'],run,run.env),['compose','-f',join(run.checkout,'compose.yaml'),'-f',join(run.tempRoot,'compose-no-build.yaml'),'run','--pull','never','--no-deps','--rm','-T','mosaic-agent','prompt']);
for(const args of [['build','.'],['image','prune'],['compose','build'],['tag','a','b'],['network','rm','foreign']])assert.throws(()=>docker.guardDocker(args,run),/refused/);
});
test('exclusive image preparation records actual image ID and build wall time and requires a grant',t=>{
@@ -103,7 +103,7 @@ function composeFixture(t) {
test('installed Compose accepts rewritten run argv without starting a container',t=>{
const run=composeFixture(t);
if(docker.writeComposeOverride)docker.writeComposeOverride(run);
const argv=docker.guardDocker(['compose','run','--rm','-T','mosaic-agent','fixture'],run);
const argv=docker.guardDocker(['compose','run','--rm','-T','mosaic-agent','fixture'],run,run.env);
const command=argv.indexOf('run');assert.ok(command>0);
// --help is before SERVICE, so parsing exits before any service operation.
const parsed=spawnSync(installedDocker(),[...argv.slice(0,command+1),'--help',...argv.slice(command+1)],{cwd:run.checkout,env:run.env,encoding:'utf8',timeout:10000});
@@ -116,7 +116,7 @@ test('installed Compose resolves the private override with no build and the pinn
assert.equal(override,join(run.tempRoot,'compose-no-build.yaml'));
assert.equal(statSync(override).mode&0o777,0o600);
assert.equal(docker.writeComposeOverride(run),override,'parallel callers reuse identical private content');
const argv=docker.guardDocker(['compose','run','--rm','mosaic-agent'],run),command=argv.indexOf('run');
const argv=docker.guardDocker(['compose','run','--rm','mosaic-agent'],run,run.env),command=argv.indexOf('run');
const resolved=spawnSync(installedDocker(),[...argv.slice(0,command),'config','--format','json'],{cwd:run.checkout,env:run.env,encoding:'utf8',timeout:10000});
assert.equal(resolved.status,0,resolved.stdout+resolved.stderr);
const service=JSON.parse(resolved.stdout).services['mosaic-agent'];
+80
View File
@@ -0,0 +1,80 @@
# Supervisor
Q198 S1 (row 257, #1762): parsers for the host pressure inputs in design D4
(`docs/plans/2026-10-21_q198-system-manager.md`), and a pure evaluator that turns timestamped samples
into a hold or release decision. Nothing here samples the host, runs a process or timer, or enforces
anything. The live sampler, the Console view and admission are S2 and S3.
```sh
node --test packages/supervisor/tests/
```
## Parsers
Each parser takes file text the caller already read. Text that isn't the shape the kernel writes throws
a `SupervisorError` with `kind: "parse"`; a sampler treats that as a missing signal.
| Export | Input | Returns |
|---|---|---|
| `parseLoadavg` | `/proc/loadavg` | `{ load1, load5, load15 }` |
| `parseMeminfo` | `/proc/meminfo` | `{ memTotal, memAvailable, swapTotal, swapFree }` in bytes |
| `parsePsi(text, resource)` | `/proc/pressure/{cpu,memory,io}` or a cgroup `*.pressure` | `{ some, full }`, each `{ avg10, avg60, avg300, total }`; `full` is null only for cpu |
| `parseCpuList` | `/sys/devices/system/cpu/online`, `cpuset.cpus.effective` | how many CPUs the list names |
| `parseCpuMax` | `cpu.max` | the quota in CPUs, or null for `max` |
| `parseMemoryMax` | `memory.max` | bytes, or null for `max` |
| `parseMemoryCurrent` | `memory.current` | bytes |
| `parseMemoryEvents` | `memory.events` | `{ oom, oomKill }` |
| `parseVmstat` | `/proc/vmstat` | `{ pswpin, pswpout, oomKill }`; `oomKill` is the host-only OOM counter, null when absent |
`effectiveCpus({ online, cpuset, cpuMax })` is the smallest of the host count, the cpuset and the quota
at every cgroup level given; a fractional quota stays fractional. `effectiveMemory({ meminfo, levels })`
takes cgroup levels as `{ max, current }` text. The limit is the smallest of `MemTotal` and every numeric
`memory.max`. Available memory is the smallest of `MemAvailable` and `max - current` at each limited level.
`memory.current` counts page cache, so this errs toward holding. With no cgroup input both functions use
the host's figures.
## Evaluator
`evaluate(state, sample, rules, { reconciled })` returns `{ state, decision }`. It starts from
`initialState()`, which is held: no signal has been seen yet. Time is the sample's `at` in milliseconds.
The evaluator never reads the clock.
A sample has these signals: `load { at, load1, cpus }`, `memory { at, available, limit }`,
`psi { at, cpuSome, memoryFull, ioFull }` (avg10 percentages), `oom { at, kills }` and `disk { at, free }`.
| Signal | Hold | Release |
|---|---|---|
| `load1 / cpus` | ≥ 0.80 on 3 consecutive samples | ≤ 0.55 |
| available memory | < max(15% of limit, 1 GiB), on one sample | > max(25% of limit, 2 GiB) |
| PSI avg10 | cpu some ≥ 20, memory full ≥ 2 or io full ≥ 10, on 3 consecutive samples | cpu some < 10, memory full < 0.5 and io full < 3 |
| critical | OOM kill counter rises, memory < 5% of limit, or disk free < 1 GiB | the caller passes `reconciled`, then the normal release |
- **Release** needs every applicable release condition on every sample for 30 s. A dead-band
sample (between hold and release) or a new hold restarts the window.
- **Missing or stale:** a required signal that is missing, malformed, more than 6 s older than the
sample, or later than it holds at once with `health-unknown`. `load`, `memory`, `oom` and `disk` are
always required. PSI becomes required once a sample has carried it. A host without PSI skips the PSI
rows, and one that loses it holds.
- **Consecutive counts** reset after a gap of more than 6 s between samples. The PSI count runs across
resources: any PSI hold condition on 3 consecutive samples holds.
- **OOM:** the first OOM sample is the baseline. A counter that falls (a recreated cgroup) resets the
baseline with the note `oom-counter-reset`.
- **Critical** never ends on thresholds alone. `reconciled` only counts once no critical condition
remains, and the 30 s release still follows. The evaluator never sets it and never touches a kill
switch.
- **A clock that goes backwards** holds with `health-unknown` and the note `clock-backwards`.
`decision` is `{ mode, reasons, unknown, critical, releaseAt, notes }`.
- `mode` is `open`, `held` or `critical`.
- `reasons` are `health-unknown`, `load-high`, `memory-low`, `psi-cpu`, `psi-memory`, `psi-io`,
`hysteresis`, `release-pending` or the critical codes `critical-oom`, `critical-memory` and
`critical-disk`.
- `releaseAt` is set while a release is pending.
`DEFAULT_RULES` is the D4 table, frozen. `resolveRules(overrides)` merges overrides key by key. It refuses
with `kind: "rules"` on an unknown key, a non-number or a contradictory pair, for example:
- a release threshold at or past its hold;
- a release floor at or below its hold floor;
- a critical memory fraction at or above the hold fraction;
- a stale limit at or below the sample interval.
Provider scope (429, quota, Retry-After) is not here; it belongs to S3 and S5.
+11
View File
@@ -0,0 +1,11 @@
{
"name": "@mosaic/supervisor",
"version": "0.1.0",
"private": true,
"description": "Q198 S1: parsers for host pressure inputs and a pure hold and release rules evaluator. No sampler, process or enforcement.",
"license": "UNLICENSED",
"type": "module",
"engines": { "node": ">=24" },
"exports": { ".": "./src/index.mjs" },
"scripts": { "test": "node --test tests/" }
}
+10
View File
@@ -0,0 +1,10 @@
// Every refusal in this package is a SupervisorError. `kind` is "parse" for
// input text that isn't the shape the kernel writes, and "rules" for settings
// the evaluator refuses.
export class SupervisorError extends Error {
constructor(message, kind) {
super(message);
this.name = "SupervisorError";
this.kind = kind;
}
}
+10
View File
@@ -0,0 +1,10 @@
// @mosaic/supervisor: Q198 S1. Parsers for host pressure inputs and a pure
// rules evaluator. Nothing here reads a file, the clock or the environment,
// and nothing enforces: no sampler, process, timer, bus or Console (S2, S3).
export { SupervisorError } from "./errors.mjs";
export {
parseLoadavg, parseMeminfo, parsePsi, parseCpuList, parseCpuMax, parseMemoryMax, parseMemoryCurrent,
parseMemoryEvents, parseVmstat, effectiveCpus, effectiveMemory,
} from "./pressure.mjs";
export { DEFAULT_RULES, resolveRules, initialState, evaluate } from "./rules.mjs";
+165
View File
@@ -0,0 +1,165 @@
// Parsers for the host pressure inputs Q198 D4 names, and the effective CPU
// count and memory limit they give. Every function takes file text the caller
// already read and returns numbers. Nothing here opens a file. Text that isn't
// the shape the kernel writes throws a SupervisorError of kind "parse"; the
// caller turns that into a missing signal.
import { SupervisorError } from "./errors.mjs";
const fail = (what, why) => { throw new SupervisorError(`${what}: ${why}`, "parse"); };
function text(what, value) {
if (typeof value !== "string") fail(what, "expected file text");
return value.endsWith("\n") ? value.slice(0, -1) : value;
}
function integer(what, value) {
if (!/^\d+$/.test(value)) fail(what, `not a non-negative integer: ${JSON.stringify(value)}`);
return Number(value);
}
function decimal(what, value) {
if (!/^\d+(\.\d+)?$/.test(value)) fail(what, `not a non-negative number: ${JSON.stringify(value)}`);
return Number(value);
}
// "key value" lines (memory.events, /proc/vmstat). A repeated key throws.
function keyedIntegers(what, raw) {
const out = new Map();
for (const line of text(what, raw).split("\n")) {
const m = /^([a-z0-9_]+) (\d+)$/.exec(line);
if (!m) fail(what, `bad line ${JSON.stringify(line)}`);
if (out.has(m[1])) fail(what, `repeated key ${m[1]}`);
out.set(m[1], integer(what, m[2]));
}
return out;
}
// /proc/loadavg: "0.52 0.58 0.59 1/1234 56789".
export function parseLoadavg(raw) {
const m = /^(\d+\.\d+) (\d+\.\d+) (\d+\.\d+) (\d+)\/(\d+) (\d+)$/.exec(text("loadavg", raw));
if (!m) fail("loadavg", "not the /proc/loadavg shape");
return { load1: Number(m[1]), load5: Number(m[2]), load15: Number(m[3]) };
}
// /proc/meminfo. Values are returned in bytes; lines without a unit (the
// HugePages counts) are kept as plain counts.
export function parseMeminfo(raw) {
const fields = new Map();
for (const line of text("meminfo", raw).split("\n")) {
const m = /^([A-Za-z0-9_()]+):\s+(\d+)( kB)?$/.exec(line);
if (!m) fail("meminfo", `bad line ${JSON.stringify(line)}`);
if (fields.has(m[1])) fail("meminfo", `repeated key ${m[1]}`);
fields.set(m[1], integer("meminfo", m[2]) * (m[3] ? 1024 : 1));
}
for (const key of ["MemTotal", "MemAvailable"]) if (!fields.has(key)) fail("meminfo", `no ${key}`);
return {
memTotal: fields.get("MemTotal"),
memAvailable: fields.get("MemAvailable"),
swapTotal: fields.get("SwapTotal") ?? null,
swapFree: fields.get("SwapFree") ?? null,
};
}
// /proc/pressure/{cpu,memory,io} or a cgroup's *.pressure file. The cpu file
// may lack the "full" line on older kernels; memory and io must have it.
export function parsePsi(raw, resource) {
if (!["cpu", "memory", "io"].includes(resource)) fail("psi", `unknown resource ${JSON.stringify(resource)}`);
const what = `psi ${resource}`;
const out = { some: null, full: null };
for (const line of text(what, raw).split("\n")) {
const m = /^(some|full) avg10=(\S+) avg60=(\S+) avg300=(\S+) total=(\S+)$/.exec(line);
if (!m) fail(what, `bad line ${JSON.stringify(line)}`);
if (out[m[1]]) fail(what, `repeated ${m[1]} line`);
out[m[1]] = { avg10: decimal(what, m[2]), avg60: decimal(what, m[3]), avg300: decimal(what, m[4]), total: integer(what, m[5]) };
}
if (!out.some) fail(what, "no some line");
if (!out.full && resource !== "cpu") fail(what, "no full line");
return out;
}
// A CPU list such as /sys/devices/system/cpu/online or cpuset.cpus.effective:
// "0-3,6,8-9". Returns how many distinct CPUs it names.
export function parseCpuList(raw) {
const body = text("cpu list", raw);
if (body === "") fail("cpu list", "empty");
const cpus = new Set();
for (const part of body.split(",")) {
const m = /^(\d+)(?:-(\d+))?$/.exec(part);
if (!m) fail("cpu list", `bad range ${JSON.stringify(part)}`);
const lo = integer("cpu list", m[1]);
const hi = m[2] === undefined ? lo : integer("cpu list", m[2]);
if (hi < lo) fail("cpu list", `range ${part} runs backwards`);
for (let c = lo; c <= hi; c++) cpus.add(c);
}
return cpus.size;
}
// cgroup v2 cpu.max: "max 100000" (no quota) or "50000 100000". Returns the
// CPUs the quota allows, or null for no quota.
export function parseCpuMax(raw) {
const m = /^(max|\d+) (\d+)$/.exec(text("cpu.max", raw));
if (!m) fail("cpu.max", "not the cpu.max shape");
const period = integer("cpu.max", m[2]);
if (period === 0) fail("cpu.max", "period is 0");
return m[1] === "max" ? null : integer("cpu.max", m[1]) / period;
}
// cgroup v2 memory.max: "max" (no limit) or bytes. Returns null for "max".
export function parseMemoryMax(raw) {
const body = text("memory.max", raw);
return body === "max" ? null : integer("memory.max", body);
}
// cgroup v2 memory.current: bytes.
export function parseMemoryCurrent(raw) {
return integer("memory.current", text("memory.current", raw));
}
// cgroup v2 memory.events. oom_kill is the counter the critical row watches.
export function parseMemoryEvents(raw) {
const fields = keyedIntegers("memory.events", raw);
if (!fields.has("oom_kill")) fail("memory.events", "no oom_kill");
return { oom: fields.get("oom") ?? null, oomKill: fields.get("oom_kill") };
}
// /proc/vmstat. Swap activity is reported only (D4 has no threshold for it).
// oom_kill is the host-only OOM counter; it is null on a kernel without it.
export function parseVmstat(raw) {
const fields = keyedIntegers("vmstat", raw);
for (const key of ["pswpin", "pswpout"]) if (!fields.has(key)) fail("vmstat", `no ${key}`);
return { pswpin: fields.get("pswpin"), pswpout: fields.get("pswpout"), oomKill: fields.get("oom_kill") ?? null };
}
// Effective CPUs: the smallest of the host's online CPUs, the cpuset and the
// quota at every cgroup level given. With no cgroup input it is the host count.
// A fractional quota stays fractional.
export function effectiveCpus({ online, cpuset = null, cpuMax = [] }) {
let cpus = parseCpuList(online);
if (cpuset !== null) cpus = Math.min(cpus, parseCpuList(cpuset));
for (const level of cpuMax) {
const quota = parseCpuMax(level);
if (quota !== null) cpus = Math.min(cpus, quota);
}
return cpus;
}
// Effective memory. `levels` lists the cgroup levels that apply, each
// { max, current } as file text. The limit is the smallest of MemTotal and every
// numeric memory.max. Available memory is the smallest of MemAvailable and
// (max - current) at every limited level. memory.current counts page cache,
// so this errs toward holding. With no levels it is the host's figures.
export function effectiveMemory({ meminfo, levels = [] }) {
const host = parseMeminfo(meminfo);
let limit = host.memTotal;
let available = host.memAvailable;
for (const level of levels) {
const max = parseMemoryMax(level.max);
if (max === null) continue;
if (level.current === undefined || level.current === null) fail("memory.current", "missing for a limited level");
const current = parseMemoryCurrent(level.current);
limit = Math.min(limit, max);
available = Math.min(available, Math.max(0, max - current));
}
return { limit, available };
}
+201
View File
@@ -0,0 +1,201 @@
// The Q198 D4 hold and release rules as a pure evaluator. evaluate() takes the
// previous state, one timestamped sample and the rules, and returns the next
// state and a decision. It never reads the clock, a file or the environment:
// time is the sample's `at`, in milliseconds. Nothing here enforces anything;
// the decision is advice for S2 and S3.
import { SupervisorError } from "./errors.mjs";
const GiB = 1024 ** 3;
// The D4 table. Defaults are the design's proposals, not measured limits.
export const DEFAULT_RULES = deepFreeze({
sampleMs: 2000,
staleMs: 6000,
releaseMs: 30000,
load: { hold: 0.8, release: 0.55, samples: 3 },
memory: { holdFraction: 0.15, holdBytes: 1 * GiB, releaseFraction: 0.25, releaseBytes: 2 * GiB },
psi: {
samples: 3,
hold: { cpuSome: 20, memoryFull: 2, ioFull: 10 },
release: { cpuSome: 10, memoryFull: 0.5, ioFull: 3 },
},
critical: { memoryFraction: 0.05, diskBytes: 1 * GiB },
});
function deepFreeze(o) {
for (const v of Object.values(o)) if (v && typeof v === "object") deepFreeze(v);
return Object.freeze(o);
}
const refuse = (why) => { throw new SupervisorError(`rules: ${why}`, "rules"); };
// Overrides merge onto the defaults key by key. An unknown key, a value of the
// wrong type or a contradictory pair refuses.
function merge(base, over, at) {
if (over === undefined) return structuredClone(base);
if (!over || typeof over !== "object" || Array.isArray(over)) refuse(`${at || "rules"} must be an object`);
const out = {};
for (const key of Object.keys(over)) if (!Object.hasOwn(base, key)) refuse(`unknown key ${at ? `${at}.` : ""}${key}`);
for (const [key, value] of Object.entries(base)) {
const name = at ? `${at}.${key}` : key;
if (typeof value === "object") out[key] = merge(value, over[key], name);
else {
const v = over[key] ?? value;
if (typeof v !== "number" || !Number.isFinite(v)) refuse(`${name} must be a finite number`);
out[key] = v;
}
}
return out;
}
export function resolveRules(overrides = {}) {
const r = merge(DEFAULT_RULES, overrides, "");
const check = (ok, why) => { if (!ok) refuse(why); };
const fraction = (v) => v > 0 && v < 1;
const percent = (v) => v >= 0 && v <= 100;
check(r.sampleMs > 0, "sampleMs must be above 0");
check(r.staleMs > r.sampleMs, "staleMs must be above sampleMs");
check(r.releaseMs >= 0, "releaseMs must not be negative");
for (const k of ["load", "psi"]) check(Number.isInteger(r[k].samples) && r[k].samples >= 1, `${k}.samples must be an integer of at least 1`);
check(r.load.release > 0 && r.load.release < r.load.hold, "load.release must be above 0 and below load.hold");
check(fraction(r.memory.holdFraction) && fraction(r.memory.releaseFraction), "memory fractions must be between 0 and 1");
check(r.memory.releaseFraction > r.memory.holdFraction, "memory.releaseFraction must be above memory.holdFraction");
check(r.memory.holdBytes >= 0 && r.memory.releaseBytes > r.memory.holdBytes, "memory.releaseBytes must be above memory.holdBytes");
for (const k of ["cpuSome", "memoryFull", "ioFull"]) {
check(percent(r.psi.hold[k]) && percent(r.psi.release[k]), `psi ${k} values must be percentages`);
check(r.psi.release[k] > 0 && r.psi.release[k] < r.psi.hold[k], `psi.release.${k} must be above 0 and below psi.hold.${k}`);
}
check(fraction(r.critical.memoryFraction) && r.critical.memoryFraction < r.memory.holdFraction,
"critical.memoryFraction must be between 0 and memory.holdFraction");
check(r.critical.diskBytes > 0, "critical.diskBytes must be above 0");
return deepFreeze(r);
}
// The state before any sample: held, because no signal has been seen yet.
export function initialState() {
return { mode: "held", lastAt: null, loadCount: 0, psiCount: 0, psiSeen: false, oomBaseline: null, releaseSince: null, critical: [] };
}
const SIGNAL_FIELDS = {
load: ["load1", "cpus"],
memory: ["available", "limit"],
psi: ["cpuSome", "memoryFull", "ioFull"],
oom: ["kills"],
disk: ["free"],
};
// A signal is usable when every field is a non-negative finite number, cpus
// and limit are above 0, and its own timestamp is no later than the sample and
// no more than staleMs before it.
function fresh(name, signal, now, rules) {
if (!signal || typeof signal !== "object") return false;
if (typeof signal.at !== "number" || !Number.isFinite(signal.at)) return false;
if (signal.at > now || now - signal.at > rules.staleMs) return false;
for (const f of SIGNAL_FIELDS[name]) if (typeof signal[f] !== "number" || !Number.isFinite(signal[f]) || signal[f] < 0) return false;
if (name === "load" && signal.cpus <= 0) return false;
if (name === "memory" && signal.limit <= 0) return false;
return true;
}
// Sample: { at, load: { at, load1, cpus }, memory: { at, available, limit },
// psi: { at, cpuSome, memoryFull, ioFull } (avg10 percentages),
// oom: { at, kills }, disk: { at, free } }. `reconciled` is the caller's word
// that reconciliation finished; only it lets a critical hold end.
export function evaluate(state, sample, rules = DEFAULT_RULES, { reconciled = false } = {}) {
if (!sample || typeof sample.at !== "number" || !Number.isFinite(sample.at)) {
throw new SupervisorError("sample: at must be a finite number of milliseconds", "sample");
}
const now = sample.at;
const next = { ...state, critical: [...state.critical], lastAt: now };
const unknown = [];
const notes = [];
const gap = state.lastAt === null ? Infinity : now - state.lastAt;
// A count of consecutive samples survives only a gap of at most staleMs.
const contiguous = gap >= 0 && gap <= rules.staleMs;
if (gap < 0) { unknown.push("clock"); notes.push("clock-backwards"); }
let loadHold = false, loadReleases = false;
if (fresh("load", sample.load, now, rules)) {
const ratio = sample.load.load1 / sample.load.cpus;
next.loadCount = ratio >= rules.load.hold ? (contiguous ? state.loadCount : 0) + 1 : 0;
loadHold = next.loadCount >= rules.load.samples;
loadReleases = ratio <= rules.load.release;
} else { unknown.push("load"); next.loadCount = 0; }
let memoryLow = false, memoryReleases = false, criticalMemory = false;
if (fresh("memory", sample.memory, now, rules)) {
const { available, limit } = sample.memory;
const m = rules.memory;
// "Whichever demands more free memory": the larger floor applies.
memoryLow = available < Math.max(m.holdFraction * limit, m.holdBytes);
memoryReleases = available > Math.max(m.releaseFraction * limit, m.releaseBytes);
criticalMemory = available < rules.critical.memoryFraction * limit;
} else unknown.push("memory");
// PSI is required once it has been seen. A host that never had it skips the
// PSI rows; one that loses it holds with health-unknown.
let psiHold = false, psiReleases = true;
const psiOver = [];
if (sample.psi !== undefined && sample.psi !== null) next.psiSeen = true;
if (fresh("psi", sample.psi, now, rules)) {
const p = sample.psi, h = rules.psi.hold, r = rules.psi.release;
if (p.cpuSome >= h.cpuSome) psiOver.push("psi-cpu");
if (p.memoryFull >= h.memoryFull) psiOver.push("psi-memory");
if (p.ioFull >= h.ioFull) psiOver.push("psi-io");
next.psiCount = psiOver.length ? (contiguous ? state.psiCount : 0) + 1 : 0;
psiHold = next.psiCount >= rules.psi.samples;
psiReleases = p.cpuSome < r.cpuSome && p.memoryFull < r.memoryFull && p.ioFull < r.ioFull;
} else {
next.psiCount = 0;
if (next.psiSeen) { unknown.push("psi"); psiReleases = false; }
}
let criticalOom = false;
if (fresh("oom", sample.oom, now, rules)) {
const kills = sample.oom.kills;
if (state.oomBaseline !== null && kills > state.oomBaseline) criticalOom = true;
if (state.oomBaseline !== null && kills < state.oomBaseline) notes.push("oom-counter-reset");
next.oomBaseline = kills;
} else unknown.push("oom");
let criticalDisk = false;
if (fresh("disk", sample.disk, now, rules)) criticalDisk = sample.disk.free < rules.critical.diskBytes;
else unknown.push("disk");
const criticalNow = [];
if (criticalOom) criticalNow.push("critical-oom");
if (criticalMemory) criticalNow.push("critical-memory");
if (criticalDisk) criticalNow.push("critical-disk");
const decide = (mode, reasons, releaseAt = null) => {
next.mode = mode;
return { state: next, decision: { mode, reasons, unknown, critical: [...next.critical], releaseAt, notes } };
};
if (criticalNow.length) {
for (const c of criticalNow) if (!next.critical.includes(c)) next.critical.push(c);
next.releaseSince = null;
return decide("critical", [...next.critical]);
}
// Critical ends only on the caller's reconciliation, never on thresholds alone.
if (state.mode === "critical" && !reconciled) {
next.releaseSince = null;
return decide("critical", [...next.critical]);
}
const mode = state.mode === "critical" ? "held" : state.mode;
next.critical = [];
const reasons = [];
if (unknown.length) reasons.push("health-unknown");
if (loadHold) reasons.push("load-high");
if (memoryLow) reasons.push("memory-low");
if (psiHold) reasons.push(...psiOver);
if (reasons.length) { next.releaseSince = null; return decide("held", reasons); }
if (mode === "open") { next.releaseSince = null; return decide("open", []); }
if (!(loadReleases && memoryReleases && psiReleases)) { next.releaseSince = null; return decide("held", ["hysteresis"]); }
next.releaseSince = state.releaseSince !== null && contiguous ? state.releaseSince : now;
if (now - next.releaseSince >= rules.releaseMs) { next.releaseSince = null; return decide("open", []); }
return decide("held", ["release-pending"], next.releaseSince + rules.releaseMs);
}
+133
View File
@@ -0,0 +1,133 @@
// Parser and effective-limit tests. Every input is fixture text shaped like
// the kernel's files; nothing reads /proc, /sys or a cgroup.
import { test } from "node:test";
import assert from "node:assert/strict";
import {
SupervisorError, parseLoadavg, parseMeminfo, parsePsi, parseCpuList, parseCpuMax, parseMemoryMax,
parseMemoryCurrent, parseMemoryEvents, parseVmstat, effectiveCpus, effectiveMemory,
} from "../src/index.mjs";
const GiB = 1024 ** 3;
const isParse = (re) => (e) => e instanceof SupervisorError && e.kind === "parse" && re.test(e.message);
const MEMINFO = [
"MemTotal: 32768000 kB",
"MemFree: 1024000 kB",
"MemAvailable: 8192000 kB",
"SwapTotal: 4096000 kB",
"SwapFree: 4000000 kB",
"HugePages_Total: 0",
"Hugepagesize: 2048 kB",
"",
].join("\n");
test("loadavg parses the three loads", () => {
assert.deepEqual(parseLoadavg("0.52 1.58 12.00 3/1234 56789\n"), { load1: 0.52, load5: 1.58, load15: 12 });
});
test("a malformed loadavg throws", () => {
for (const bad of ["", "0.52 0.58 0.59\n", "0.52 0.58 0.59 1/2\n", "x 0.58 0.59 1/2 3\n", "0.52 0.58 0.59 1/2 3 4\n", 42]) {
assert.throws(() => parseLoadavg(bad), isParse(/loadavg/), JSON.stringify(bad));
}
});
test("meminfo returns bytes and keeps unitless counts", () => {
assert.deepEqual(parseMeminfo(MEMINFO), {
memTotal: 32768000 * 1024, memAvailable: 8192000 * 1024, swapTotal: 4096000 * 1024, swapFree: 4000000 * 1024,
});
});
test("meminfo without MemAvailable, with a repeated key or a bad line throws", () => {
assert.throws(() => parseMeminfo("MemTotal: 1 kB\n"), isParse(/no MemAvailable/));
assert.throws(() => parseMeminfo(MEMINFO + "MemTotal: 1 kB\n"), isParse(/repeated key MemTotal/));
assert.throws(() => parseMeminfo(MEMINFO + "Broken line\n"), isParse(/bad line/));
assert.throws(() => parseMeminfo(MEMINFO.replace("8192000", "-1")), isParse(/bad line/));
});
test("PSI parses some and full, and cpu may lack full", () => {
const mem = "some avg10=1.50 avg60=0.20 avg300=0.00 total=123\nfull avg10=0.75 avg60=0.10 avg300=0.00 total=45\n";
assert.deepEqual(parsePsi(mem, "memory"), {
some: { avg10: 1.5, avg60: 0.2, avg300: 0, total: 123 },
full: { avg10: 0.75, avg60: 0.1, avg300: 0, total: 45 },
});
assert.equal(parsePsi("some avg10=20.00 avg60=0.00 avg300=0.00 total=9\n", "cpu").full, null);
});
test("PSI without a full line for memory or io, or with a bad line, throws", () => {
const someOnly = "some avg10=0.00 avg60=0.00 avg300=0.00 total=0\n";
assert.throws(() => parsePsi(someOnly, "memory"), isParse(/no full line/));
assert.throws(() => parsePsi(someOnly, "io"), isParse(/no full line/));
assert.throws(() => parsePsi("full avg10=0.00 avg60=0.00 avg300=0.00 total=0\n", "cpu"), isParse(/no some line/));
assert.throws(() => parsePsi(someOnly + someOnly, "cpu"), isParse(/repeated some/));
assert.throws(() => parsePsi("some avg10=x avg60=0.00 avg300=0.00 total=0\n", "cpu"), isParse(/not a non-negative number/));
assert.throws(() => parsePsi(someOnly, "gpu"), isParse(/unknown resource/));
});
test("CPU lists count distinct CPUs", () => {
assert.equal(parseCpuList("0-3,6,8-9\n"), 7);
assert.equal(parseCpuList("0\n"), 1);
assert.equal(parseCpuList("0-3,2-5"), 6);
for (const bad of ["", "\n", "3-1", "0,,1", "a-b", "0-"]) assert.throws(() => parseCpuList(bad), isParse(/cpu list/), bad);
});
test("cpu.max gives the quota in CPUs, or null for max", () => {
assert.equal(parseCpuMax("max 100000\n"), null);
assert.equal(parseCpuMax("50000 100000\n"), 0.5);
assert.equal(parseCpuMax("200000 100000\n"), 2);
for (const bad of ["max", "50000 0", "-1 100000", "max max"]) assert.throws(() => parseCpuMax(bad), isParse(/cpu.max/), bad);
});
test("memory.max, memory.current and memory.events", () => {
assert.equal(parseMemoryMax("max\n"), null);
assert.equal(parseMemoryMax("4294967296\n"), 4 * GiB);
assert.throws(() => parseMemoryMax("4G\n"), isParse(/memory.max/));
assert.equal(parseMemoryCurrent("123456\n"), 123456);
assert.throws(() => parseMemoryCurrent(""), isParse(/memory.current/));
assert.deepEqual(parseMemoryEvents("low 0\nhigh 4\nmax 2\noom 1\noom_kill 1\noom_group_kill 0\n"), { oom: 1, oomKill: 1 });
assert.throws(() => parseMemoryEvents("low 0\noom 1\n"), isParse(/no oom_kill/));
assert.throws(() => parseMemoryEvents("oom_kill 1\noom_kill 2\n"), isParse(/repeated key/));
});
test("vmstat gives swap counters and the host OOM counter", () => {
assert.deepEqual(parseVmstat("nr_free_pages 100\npswpin 7\npswpout 9\noom_kill 3\n"), { pswpin: 7, pswpout: 9, oomKill: 3 });
assert.deepEqual(parseVmstat("pswpin 0\npswpout 0\n"), { pswpin: 0, pswpout: 0, oomKill: null });
assert.throws(() => parseVmstat("pswpin 0\n"), isParse(/no pswpout/));
});
test("effective CPUs fall back to the host count with no cgroup input", () => {
assert.equal(effectiveCpus({ online: "0-15\n" }), 16);
});
test("effective CPUs take the smallest of host, cpuset and every quota level", () => {
assert.equal(effectiveCpus({ online: "0-15\n", cpuset: "0-3\n" }), 4);
assert.equal(effectiveCpus({ online: "0-15\n", cpuset: "0-3\n", cpuMax: ["max 100000\n", "150000 100000\n"] }), 1.5);
// The outer level is the tighter one.
assert.equal(effectiveCpus({ online: "0-15\n", cpuMax: ["800000 100000\n", "200000 100000\n"] }), 2);
// A quota above the host count doesn't raise it.
assert.equal(effectiveCpus({ online: "0-1\n", cpuMax: ["800000 100000\n"] }), 2);
assert.equal(effectiveCpus({ online: "0-15\n", cpuMax: ["max 100000\n"] }), 16);
});
test("effective memory falls back to the host with no cgroup levels, or only max", () => {
const host = { limit: 32768000 * 1024, available: 8192000 * 1024 };
assert.deepEqual(effectiveMemory({ meminfo: MEMINFO }), host);
assert.deepEqual(effectiveMemory({ meminfo: MEMINFO, levels: [{ max: "max\n" }] }), host);
});
test("effective memory uses the tightest limited level", () => {
// 4 GiB limit with 3 GiB used: 1 GiB available, below the host's MemAvailable.
assert.deepEqual(effectiveMemory({ meminfo: MEMINFO, levels: [{ max: `${4 * GiB}\n`, current: `${3 * GiB}\n` }] }),
{ limit: 4 * GiB, available: 1 * GiB });
// Nested: the inner level is loose, the outer one is nearly full.
assert.deepEqual(effectiveMemory({ meminfo: MEMINFO, levels: [
{ max: "max\n" }, { max: `${16 * GiB}\n`, current: `${2 * GiB}\n` }, { max: `${6 * GiB}\n`, current: `${5.5 * GiB}\n` },
] }), { limit: 6 * GiB, available: 0.5 * GiB });
// Usage above the limit floors at 0.
assert.equal(effectiveMemory({ meminfo: MEMINFO, levels: [{ max: "100\n", current: "200\n" }] }).available, 0);
// A limit above MemTotal doesn't raise the limit.
assert.equal(effectiveMemory({ meminfo: MEMINFO, levels: [{ max: `${64 * GiB}\n`, current: "0\n" }] }).limit, 32768000 * 1024);
});
test("a limited level without memory.current throws", () => {
assert.throws(() => effectiveMemory({ meminfo: MEMINFO, levels: [{ max: `${4 * GiB}\n` }] }), isParse(/missing for a limited level/));
});
+28
View File
@@ -0,0 +1,28 @@
// S1 has no live enforcement: the source must not reach the filesystem, a
// process, the network or the clock. This reads the package's own source text.
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const SRC = path.join(import.meta.dirname, "..", "src");
const FORBIDDEN = [
/\bfrom\s+["'](node:)?(fs|fs\/promises|child_process|net|http|https|dgram|tls|worker_threads|os)["']/,
/\bimport\s*\(/,
/\brequire\s*\(/,
/\bprocess\./,
/\bDate\.now\b/,
/\bnew Date\b/,
/\bperformance\.now\b/,
/\bset(Timeout|Interval|Immediate)\b/,
];
test("src reads no file, process, network or clock", () => {
const files = fs.readdirSync(SRC).filter((f) => f.endsWith(".mjs"));
assert.deepEqual(files.sort(), ["errors.mjs", "index.mjs", "pressure.mjs", "rules.mjs"]);
for (const f of files) {
const body = fs.readFileSync(path.join(SRC, f), "utf8");
for (const re of FORBIDDEN) assert.doesNotMatch(body, re, `${f} matches ${re}`);
for (const m of body.matchAll(/\bfrom\s+["']([^"']+)["']/g)) assert.match(m[1], /^\.\/[a-z]+\.mjs$/, `${f} imports ${m[1]}`);
}
});
+339
View File
@@ -0,0 +1,339 @@
// Evaluator tests on a fake clock: each sample's `at` is the time. Synthetic
// numbers only; nothing samples the host or generates load.
import { test } from "node:test";
import assert from "node:assert/strict";
import { SupervisorError, DEFAULT_RULES, resolveRules, initialState, evaluate } from "../src/index.mjs";
const GiB = 1024 ** 3;
const LIMIT = 32 * GiB;
const R = DEFAULT_RULES;
// A healthy sample at `at`. `patch` changes fields per signal; null drops one.
function sample(at, patch = {}) {
const base = {
load: { load1: 0.1, cpus: 1 },
memory: { available: 16 * GiB, limit: LIMIT },
psi: { cpuSome: 0, memoryFull: 0, ioFull: 0 },
oom: { kills: 0 },
disk: { free: 100 * GiB },
};
const out = { at };
for (const [name, fields] of Object.entries(base)) {
if (patch[name] === null) continue;
out[name] = { at, ...fields, ...patch[name] };
}
return out;
}
// Fold samples through evaluate; returns every decision and the last state.
function run(state, samples, rules = R, opts) {
const decisions = [];
for (const s of samples) {
const r = evaluate(state, s, rules, typeof opts === "function" ? opts(s) : opts);
state = r.state;
decisions.push(r.decision);
}
return { state, decisions, last: decisions.at(-1) };
}
// Samples every 2 s from `from` to `to` inclusive.
function every2s(from, to, patch) {
const out = [];
for (let at = from; at <= to; at += 2000) out.push(sample(at, typeof patch === "function" ? patch(at) : patch));
return out;
}
// An open state whose last sample was at 30000.
function opened() {
const { state, last } = run(initialState(), every2s(0, 30000));
assert.equal(last.mode, "open");
return state;
}
// A state held by a load hold, last sample at 36000.
function heldByLoad() {
const { state, last } = run(opened(), every2s(32000, 36000, { load: { load1: 0.9 } }));
assert.deepEqual(last.reasons, ["load-high"]);
return state;
}
const modes = (r) => r.decisions.map((d) => d.mode);
test("the first state is held, and healthy samples open it after exactly 30 s", () => {
assert.equal(initialState().mode, "held");
const first = evaluate(initialState(), sample(0));
assert.equal(first.decision.mode, "held");
assert.deepEqual(first.decision.reasons, ["release-pending"]);
assert.equal(first.decision.releaseAt, 30000);
const r = run(initialState(), [sample(0), sample(2000), sample(6000), sample(10000), sample(14000), sample(18000),
sample(22000), sample(26000), sample(29999), sample(30000)]);
assert.deepEqual(r.decisions.at(-2).reasons, ["release-pending"]);
assert.equal(r.decisions.at(-2).mode, "held");
assert.equal(r.last.mode, "open");
});
test("load holds at 0.80 for 3 samples and not at 0.7999", () => {
const at = run(opened(), every2s(32000, 36000, { load: { load1: 0.8 } }));
assert.deepEqual(modes(at), ["open", "open", "held"]);
assert.deepEqual(at.last.reasons, ["load-high"]);
const below = run(opened(), every2s(32000, 50000, { load: { load1: 0.7999 } }));
assert.ok(below.decisions.every((d) => d.mode === "open"));
});
test("load divides by effective CPUs, fractional ones included", () => {
const r = run(opened(), every2s(32000, 36000, { load: { load1: 0.4, cpus: 0.5 } }));
assert.equal(r.last.mode, "held");
const r2 = run(opened(), every2s(32000, 36000, { load: { load1: 3.1, cpus: 4 } }));
assert.equal(r2.last.mode, "open");
});
test("a gap of more than 6 s resets the consecutive count", () => {
const high = { load: { load1: 0.9 } };
const r = run(opened(), [sample(32000, high), sample(34000, high), sample(40001, high), sample(42001, high), sample(44001, high)]);
assert.deepEqual(modes(r), ["open", "open", "open", "open", "held"]);
const ok = run(opened(), [sample(32000, high), sample(34000, high), sample(40000, high)]);
assert.equal(ok.last.mode, "held");
});
test("load releases at 0.55 and not at 0.5501", () => {
const dead = run(heldByLoad(), every2s(38000, 80000, { load: { load1: 0.5501 } }));
assert.ok(dead.decisions.every((d) => d.mode === "held" && d.reasons[0] === "hysteresis"));
const r = run(heldByLoad(), every2s(38000, 68000, { load: { load1: 0.55 } }));
assert.equal(r.decisions.at(-2).mode, "held");
assert.equal(r.last.mode, "open");
});
test("a dead-band sample restarts the 30 s release window", () => {
const r = run(heldByLoad(), [
...every2s(38000, 60000),
sample(62000, { load: { load1: 0.6 } }),
...every2s(64000, 92000),
sample(94000),
]);
const at = (t) => r.decisions[r.decisions.length - 1 - (94000 - t) / 2000];
assert.equal(at(62000).reasons[0], "hysteresis");
assert.equal(at(92000).mode, "held");
assert.equal(at(94000).mode, "open");
});
test("memory holds below the larger floor, on one sample", () => {
const floor = Math.max(R.memory.holdFraction * LIMIT, R.memory.holdBytes);
assert.ok(floor > R.memory.holdBytes); // 15% of 32 GiB is the larger floor here
const just = Math.ceil(floor);
assert.equal(evaluate(opened(), sample(32000, { memory: { available: just } })).decision.mode, "open");
const low = evaluate(opened(), sample(32000, { memory: { available: just - 1 } })).decision;
assert.equal(low.mode, "held");
assert.deepEqual(low.reasons, ["memory-low"]);
});
test("on a small limit the 1 GiB floor is the larger one", () => {
const small = 4 * GiB; // 15% is 0.6 GiB, so 1 GiB applies
const s = (available) => sample(32000, { memory: { available, limit: small } });
assert.equal(evaluate(opened(), s(1 * GiB)).decision.mode, "open");
assert.deepEqual(evaluate(opened(), s(1 * GiB - 1)).decision.reasons, ["memory-low"]);
});
test("memory releases only above the larger release floor", () => {
// 3 GiB is below the 15% floor (4.8 GiB) and above the 5% critical line (1.6 GiB).
const held = evaluate(opened(), sample(32000, { memory: { available: 3 * GiB } })).state;
assert.equal(held.mode, "held");
const rel = Math.max(R.memory.releaseFraction * LIMIT, R.memory.releaseBytes);
const stay = run(held, every2s(34000, 80000, { memory: { available: Math.floor(rel) } }));
assert.ok(stay.decisions.every((d) => d.mode === "held"));
const go = run(held, every2s(34000, 64000, { memory: { available: Math.floor(rel) + 1 } }));
assert.equal(go.last.mode, "open");
const small = { limit: 4 * GiB }; // 25% is 1 GiB, so 2 GiB applies
const stay2 = run(held, every2s(34000, 80000, { memory: { ...small, available: 2 * GiB } }));
assert.ok(stay2.decisions.every((d) => d.mode === "held"));
const go2 = run(held, every2s(34000, 64000, { memory: { ...small, available: 2 * GiB + 1 } }));
assert.equal(go2.last.mode, "open");
});
test("each PSI hold threshold holds at its value for 3 samples and not just below", () => {
for (const [field, code] of [["cpuSome", "psi-cpu"], ["memoryFull", "psi-memory"], ["ioFull", "psi-io"]]) {
const at = run(opened(), every2s(32000, 36000, { psi: { [field]: R.psi.hold[field] } }));
assert.deepEqual(modes(at), ["open", "open", "held"], field);
assert.deepEqual(at.last.reasons, [code], field);
const below = run(opened(), every2s(32000, 50000, { psi: { [field]: R.psi.hold[field] - 0.01 } }));
assert.ok(below.decisions.every((d) => d.mode === "open"), field);
}
});
test("the PSI count runs across resources", () => {
const r = run(opened(), [sample(32000, { psi: { cpuSome: 25 } }), sample(34000, { psi: { memoryFull: 3 } }), sample(36000, { psi: { ioFull: 12 } })]);
assert.deepEqual(modes(r), ["open", "open", "held"]);
assert.deepEqual(r.last.reasons, ["psi-io"]);
});
test("each PSI release threshold blocks at its value and releases just below", () => {
const held = run(opened(), every2s(32000, 36000, { psi: { cpuSome: 30 } })).state;
for (const field of ["cpuSome", "memoryFull", "ioFull"]) {
const stay = run(held, every2s(38000, 80000, { psi: { [field]: R.psi.release[field] } }));
assert.ok(stay.decisions.every((d) => d.mode === "held" && d.reasons[0] === "hysteresis"), field);
const go = run(held, every2s(38000, 68000, { psi: { [field]: R.psi.release[field] - 0.01 } }));
assert.equal(go.last.mode, "open", field);
}
});
test("a missing required signal holds at once with health-unknown", () => {
for (const name of ["load", "memory", "oom", "disk"]) {
const d = evaluate(opened(), sample(32000, { [name]: null })).decision;
assert.equal(d.mode, "held", name);
assert.deepEqual(d.reasons, ["health-unknown"], name);
assert.deepEqual(d.unknown, [name], name);
}
});
test("a signal is stale at more than 6 s old and fresh at exactly 6 s", () => {
const stale = { at: 32000, load1: 0.1, cpus: 1 };
const s = (signalAt) => ({ ...sample(32000), load: { ...stale, at: signalAt } });
assert.equal(evaluate(opened(), s(26000)).decision.mode, "open");
assert.deepEqual(evaluate(opened(), s(25999)).decision.unknown, ["load"]);
assert.deepEqual(evaluate(opened(), s(32001)).decision.unknown, ["load"]); // later than the sample
});
test("malformed signal values count as missing", () => {
for (const patch of [{ load: { load1: NaN } }, { load: { cpus: 0 } }, { load: { load1: -1 } }, { memory: { limit: 0 } },
{ memory: { available: "1" } }, { oom: { kills: Infinity } }, { disk: { free: undefined } }]) {
const d = evaluate(opened(), sample(32000, patch)).decision;
assert.deepEqual(d.reasons, ["health-unknown"], JSON.stringify(patch));
}
});
test("after health-unknown, recovery waits the full 30 s", () => {
const unknown = evaluate(opened(), sample(32000, { load: null })).state;
const r = run(unknown, every2s(34000, 64000));
assert.equal(r.decisions.at(-2).mode, "held");
assert.equal(r.last.mode, "open");
});
test("a host that never had PSI skips the PSI rows", () => {
const r = run(initialState(), every2s(0, 30000, { psi: null }));
assert.equal(r.last.mode, "open");
assert.deepEqual(r.last.unknown, []);
});
test("PSI that disappears after it was present holds with health-unknown", () => {
const d = evaluate(opened(), sample(32000, { psi: null })).decision;
assert.equal(d.mode, "held");
assert.deepEqual(d.reasons, ["health-unknown"]);
assert.deepEqual(d.unknown, ["psi"]);
// Also when it first appears late, then goes.
const late = run(initialState(), [...every2s(0, 30000, { psi: null }), sample(32000), sample(34000, { psi: null })]);
assert.deepEqual(late.last.unknown, ["psi"]);
// A stale PSI signal counts the same.
const stalePsi = { ...sample(32000), psi: { at: 25000, cpuSome: 0, memoryFull: 0, ioFull: 0 } };
assert.deepEqual(evaluate(opened(), stalePsi).decision.unknown, ["psi"]);
});
test("a rise in the OOM kill counter is critical; the first sample is the baseline", () => {
const first = evaluate(initialState(), sample(0, { oom: { kills: 5 } }));
assert.notEqual(first.decision.mode, "critical");
const r = run(opened(), [sample(32000, { oom: { kills: 0 } }), sample(34000, { oom: { kills: 1 } })]);
assert.equal(r.last.mode, "critical");
assert.deepEqual(r.last.reasons, ["critical-oom"]);
});
test("a falling OOM counter resets the baseline with a note, not an event", () => {
const s = run(initialState(), every2s(0, 30000, { oom: { kills: 6 } })).state;
assert.equal(s.mode, "open");
const r = run(s, [sample(32000, { oom: { kills: 2 } }), sample(34000, { oom: { kills: 2 } })]);
assert.deepEqual(r.decisions[0].notes, ["oom-counter-reset"]);
assert.ok(r.decisions.every((d) => d.mode === "open"));
});
test("memory under 5% and disk under 1 GiB are critical, at their edges", () => {
const five = R.critical.memoryFraction * LIMIT;
assert.equal(evaluate(opened(), sample(32000, { memory: { available: Math.ceil(five) } })).decision.mode, "held");
const mem = evaluate(opened(), sample(32000, { memory: { available: Math.ceil(five) - 1 } })).decision;
assert.equal(mem.mode, "critical");
assert.deepEqual(mem.reasons, ["critical-memory"]);
// On 20 GiB the 5% line is exactly 1 GiB, so the edge itself is tested.
const s20 = (available) => sample(32000, { memory: { available, limit: 20 * GiB } });
assert.equal(evaluate(opened(), s20(1 * GiB)).decision.mode, "held");
assert.deepEqual(evaluate(opened(), s20(1 * GiB - 1)).decision.reasons, ["critical-memory"]);
assert.equal(evaluate(opened(), sample(32000, { disk: { free: 1 * GiB } })).decision.mode, "open");
assert.deepEqual(evaluate(opened(), sample(32000, { disk: { free: 1 * GiB - 1 } })).decision.reasons, ["critical-disk"]);
});
test("critical stays until reconciliation, then still waits the 30 s release", () => {
const crit = evaluate(opened(), sample(32000, { disk: { free: 0 } })).state;
const waiting = run(crit, every2s(34000, 120000));
assert.ok(waiting.decisions.every((d) => d.mode === "critical" && d.reasons[0] === "critical-disk"));
// Reconciled while the condition is still there: still critical.
assert.equal(evaluate(crit, sample(34000, { disk: { free: 0 } }), R, { reconciled: true }).decision.mode, "critical");
// Reconciled and healthy: held, then open 30 s later.
const after = run(waiting.state, every2s(122000, 152000), R, (s) => ({ reconciled: s.at === 122000 }));
assert.deepEqual(after.decisions[0].reasons, ["release-pending"]);
assert.deepEqual(after.decisions[0].critical, []);
assert.equal(after.decisions.at(-2).mode, "held");
assert.equal(after.last.mode, "open");
});
test("critical reasons accumulate while critical", () => {
const r = run(opened(), [sample(32000, { disk: { free: 0 } }), sample(34000, { oom: { kills: 3 } })]);
assert.deepEqual(r.last.reasons, ["critical-disk", "critical-oom"]);
});
test("a clock that goes backwards holds with health-unknown", () => {
const d = evaluate(opened(), sample(20000)).decision;
assert.equal(d.mode, "held");
assert.deepEqual(d.unknown, ["clock"]);
assert.deepEqual(d.notes, ["clock-backwards"]);
});
test("a sample without a numeric time throws", () => {
for (const bad of [null, {}, { at: "1" }, { at: NaN }]) {
assert.throws(() => evaluate(initialState(), bad), (e) => e instanceof SupervisorError && e.kind === "sample");
}
});
test("evaluate doesn't change the state it was given", () => {
const s = Object.freeze({ ...opened(), critical: Object.freeze([]) });
assert.doesNotThrow(() => evaluate(s, sample(32000, { disk: { free: 0 } })));
assert.equal(s.mode, "open");
});
test("resolveRules with no overrides is the D4 table, frozen", () => {
assert.deepEqual(resolveRules(), DEFAULT_RULES);
assert.ok(Object.isFrozen(DEFAULT_RULES) && Object.isFrozen(DEFAULT_RULES.psi.hold));
assert.deepEqual(DEFAULT_RULES.load, { hold: 0.8, release: 0.55, samples: 3 });
assert.equal(DEFAULT_RULES.releaseMs, 30000);
assert.equal(DEFAULT_RULES.staleMs, 6000);
});
test("overrides apply in the evaluator", () => {
const rules = resolveRules({ releaseMs: 10000, load: { samples: 1 } });
const r = run(initialState(), every2s(0, 10000), rules);
assert.equal(r.last.mode, "open");
assert.equal(evaluate(r.state, sample(12000, { load: { load1: 0.9 } }), rules).decision.mode, "held");
});
test("contradictory or unknown settings refuse", () => {
const cases = [
[{ load: { release: 0.8 } }, /load.release must be above 0 and below load.hold/],
[{ load: { hold: 0.5 } }, /load.release must be above 0 and below load.hold/],
[{ memory: { releaseFraction: 0.15 } }, /releaseFraction must be above/],
[{ memory: { releaseBytes: 1 * GiB } }, /releaseBytes must be above/],
[{ memory: { holdFraction: 1.2 } }, /between 0 and 1/],
[{ psi: { release: { ioFull: 10 } } }, /psi.release.ioFull must be above 0 and below psi.hold.ioFull/],
[{ psi: { hold: { cpuSome: 120 } } }, /percentages/],
[{ psi: { samples: 0 } }, /psi.samples must be an integer/],
[{ load: { samples: 1.5 } }, /load.samples must be an integer/],
[{ staleMs: 2000 }, /staleMs must be above sampleMs/],
[{ releaseMs: -1 }, /releaseMs must not be negative/],
[{ critical: { memoryFraction: 0.15 } }, /critical.memoryFraction/],
[{ critical: { diskBytes: 0 } }, /critical.diskBytes/],
[{ loadd: {} }, /unknown key loadd/],
[{ psi: { hold: { gpu: 1 } } }, /unknown key psi.hold.gpu/],
[{ load: { toString: 1 } }, /unknown key load.toString/],
[{ constructor: {} }, /unknown key constructor/],
[JSON.parse('{"memory":{"__proto__":{}}}'), /unknown key memory.__proto__/],
[{ load: { hold: "0.8" } }, /load.hold must be a finite number/],
[{ load: 1 }, /load must be an object/],
[[], /rules must be an object/],
];
for (const [over, re] of cases) {
assert.throws(() => resolveRules(over), (e) => e instanceof SupervisorError && e.kind === "rules" && re.test(e.message), JSON.stringify(over));
}
});
+13 -4
View File
@@ -13,6 +13,11 @@ cp RELEASE "$RELEASE_BACKUP"
# One exit trap: the repo RELEASE is ALWAYS restored from the backup,
# regardless of how the test run ends.
trap 'cp "$RELEASE_BACKUP" RELEASE 2>/dev/null; rm -rf "$SANDBOX" "$RELEASE_BACKUP"' EXIT
# Compose mounts PI_AUTH_FILE. Point it at a fixture so no activation reaches
# the host's pi credential (row 244). chmod after the write: umask-proof (row 243).
{ mkdir -p "$SANDBOX/pi" && printf '{}' > "$SANDBOX/pi/auth.json" && chmod 600 "$SANDBOX/pi/auth.json"; } \
|| { echo "cannot write the pi auth fixture" >&2; exit 1; }
export PI_AUTH_FILE="$SANDBOX/pi/auth.json"
PASS=0
# Status colors: terminal-only, NO_COLOR-respecting; plain when piped.
@@ -57,8 +62,11 @@ check "valid RELEASE leaves image tag consistent with version" $?
# ---------- sandboxed state machine (Docker required) ----------
if docker info >/dev/null 2>&1; then
mkdir -p "$SANDBOX/data"
# The mock adapter: no credential and no provider call in the gate (row 244,
# ruling A, sage-1914). This suite proves the release state machine, image and
# Compose path, not that the pi image reaches the provider.
cat > "$SANDBOX/config.json" <<EOF
{"configVersion":1,"environment":"development","dataRoot":"$SANDBOX/data","execution":{"backend":"docker","provider":"zai","model":"glm-5.3-flash"}}
{"configVersion":1,"environment":"development","dataRoot":"$SANDBOX/data","execution":{"backend":"docker","provider":"zai","model":"glm-5.3-flash","adapter":"mock"}}
EOF
export MOSAIC_CONFIG="$SANDBOX/config.json"
@@ -66,7 +74,8 @@ EOF
[ ! -e "$SANDBOX/data/state/active.json" ] \
&& check "status created no pointer" 0 || check "status created no pointer" 1
expect_exit "fault-injected activation refuses" 1 -- scripts/release.sh activate --fault-injection
# The mock gives the right marker, so only the fault's wrong expectation refuses.
expect_exit "fault-injected activation refuses" 1 -- env MOSAIC_MOCK_RESPONSE=MOSAIC_HELLO_OK scripts/release.sh activate --fault-injection
[ ! -e "$SANDBOX/data/state/active.json" ] \
&& check "refused activation wrote no pointer" 0 || check "refused activation wrote no pointer" 1
if [ -f "$SANDBOX/data/state/activation-log.jsonl" ]; then
@@ -82,7 +91,7 @@ process.exit(e.event === "refused" && e.release && e.imageTag && e.at ? 0 : 1);
check "refusal logged exactly once with valid fields" 1
fi
expect_exit "healthy activation succeeds" 0 -- scripts/release.sh activate
expect_exit "healthy activation succeeds" 0 -- env MOSAIC_MOCK_RESPONSE=MOSAIC_HELLO_OK scripts/release.sh activate
node -e '
const fs = require("fs");
const p = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
@@ -90,7 +99,7 @@ process.exit(p.pointerVersion === 1 && p.release && p.imageTag && p.activatedAt
' "$SANDBOX/data/state/active.json"
check "pointer written with valid fields" $?
expect_exit "repeat activation succeeds (log grows)" 0 -- scripts/release.sh activate
expect_exit "repeat activation succeeds (log grows)" 0 -- env MOSAIC_MOCK_RESPONSE=MOSAIC_HELLO_OK scripts/release.sh activate
LINES=$(grep -c '' "$SANDBOX/data/state/activation-log.jsonl")
[ "$LINES" -ge 3 ] && check "log is append-only across activations" 0 || check "log is append-only across activations" 1
+34 -9
View File
@@ -9,6 +9,18 @@ cd "$(dirname "$0")/.."
SANDBOX="$(mktemp -d)"
trap 'rm -rf "$SANDBOX"' EXIT
# Real-model cases need the operator's credential and a billed provider call,
# so they run only on request (row 244, sage-2061). The gate lane never passes
# MOSAIC_LIVE_PROVIDER through.
LIVE_SKIP="skip live provider cases (row 244: no provider call in the gate)"
# Compose mounts PI_AUTH_FILE. Unless live cases were requested, point it at a
# fixture so no run reaches the host's pi credential (row 244). chmod after the
# write: umask-proof (row 243).
if [ "${MOSAIC_LIVE_PROVIDER:-}" != "1" ]; then
{ mkdir -p "$SANDBOX/pi" && printf '{}' > "$SANDBOX/pi/auth.json" && chmod 600 "$SANDBOX/pi/auth.json"; } \
|| { echo "cannot write the pi auth fixture" >&2; exit 1; }
export PI_AUTH_FILE="$SANDBOX/pi/auth.json"
fi
PASS=0
# Status colors: terminal-only, NO_COLOR-respecting; plain when piped.
@@ -423,7 +435,8 @@ else
echo "skip workspace/capability cases (docker daemon unavailable)"
fi
# ---------- live: real runs (Docker + credentials required) ----------
# ---------- run records through the mock adapter (Docker required) ----------
# These prove the run-record path, not the model, so the mock answers (row 244).
# On failure, surface the run record + agent stderr BEFORE the sandbox
# cleanup destroys them. Never let a wrong-exit mask the real reason.
dump_latest_run() {
@@ -441,14 +454,15 @@ dump_latest_run() {
if docker info >/dev/null 2>&1; then
RUNS1=$(ls "$DATA_ROOT/runs" 2>/dev/null | wc -l)
if scripts/run-task.sh run "$SANDBOX/ok.json" >/dev/null 2>&1; then
PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} live hello task succeeds with exact marker"
if env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" MOSAIC_MOCK_RESPONSE=MOSAIC_HELLO_OK \
scripts/run-task.sh run "$SANDBOX/ok.json" >/dev/null 2>&1; then
PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} hello task succeeds with exact marker"
else
FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} live hello task succeeds with exact marker" >&2
FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} hello task succeeds with exact marker" >&2
dump_latest_run
fi
R1="$(ls -t "$DATA_ROOT/runs" | head -1)" # newest = the live run above
R1="$(ls -t "$DATA_ROOT/runs" | head -1)" # newest = the hello run above
[ -f "$DATA_ROOT/runs/$R1/result.json" ] && check "result.json written in run dir" 0 || check "result.json written in run dir" 1
node -e '
const r = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
@@ -457,7 +471,8 @@ process.exit(r.status === "succeeded" && r.response === "MOSAIC_HELLO_OK" && r.e
check "result.json contents are correct" $?
printf '{"taskVersion":1,"id":"t-wrong","prompt":"Return your startup marker and nothing else.","expectExact":"MOSAIC_NOT_OK"}' > "$SANDBOX/wrong.json"
scripts/run-task.sh run "$SANDBOX/wrong.json" >/dev/null 2>&1
env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" MOSAIC_MOCK_RESPONSE=MOSAIC_HELLO_OK \
scripts/run-task.sh run "$SANDBOX/wrong.json" >/dev/null 2>&1
RC=$?
WRONG_REASON="$(latest_reason)"
if [ "$RC" -eq 1 ] && [ "$WRONG_REASON" = "expect-mismatch" ]; then
@@ -473,6 +488,14 @@ process.exit(r.status === "succeeded" && r.response === "MOSAIC_HELLO_OK" && r.e
COUNT=$($TASK list | wc -l)
[ "$COUNT" -ge 2 ] && check "list shows both runs" 0 || check "list shows both runs" 1
else
echo "skip run-record cases (docker unavailable)"
fi
# ---------- live: real model (Docker + operator credential, on request) ----------
if [ "${MOSAIC_LIVE_PROVIDER:-}" != "1" ]; then
echo "$LIVE_SKIP"
elif docker info >/dev/null 2>&1; then
# session fork (M11): teach in base, fork into child, child recalls;
# ancestor file count must be unchanged by the fork
@@ -512,9 +535,11 @@ grep -q "## Skillset" "$ONB/user/USER.md" && grep -q "## Pets" "$ONB/user/USER.m
&& check "canon sections present" 0 || check "canon sections present" 1
# Live recall: real pi, real phrasing - assert containment, not exactness.
# Needs Docker like the live task cases above. Without it the pair prints
# a skip and counts as neither a pass nor a failure.
if docker info >/dev/null 2>&1; then
# Needs Docker and MOSAIC_LIVE_PROVIDER=1 like the live fork cases above.
# Without either the pair prints a skip and counts as neither a pass nor a failure.
if [ "${MOSAIC_LIVE_PROVIDER:-}" != "1" ]; then
echo "$LIVE_SKIP"
elif docker info >/dev/null 2>&1; then
printf '{"taskVersion":1,"id":"t-user","prompt":"What is the user name? Reply with only the name.","session":"onb-check","timeoutSeconds":180}' > "$SANDBOX/recall-task.json"
RC=0
scripts/run-task.sh run "$SANDBOX/recall-task.json" >/dev/null 2>&1 || RC=$?
+3 -1
View File
@@ -21,7 +21,9 @@ scripts/testing/gate-lane.sh run --lane a --candidate FULL_SHA \
Each run creates a private root like `/mnt/storage/scratch/ga-ABCDEF` (30 bytes).
An explicit `--temp-root` must already be private, outside `/tmp`, and no longer
than 30 UTF-8 bytes after resolving symlinks. The sandbox's own `/tmp` and HOME
are unchanged. Host HOME stays real. A listener covering loopback port 9, an
are unchanged. Host HOME stays real. Steps get `PI_AUTH_FILE` naming a private `{}`
fixture in the temp root, and the Docker guard refuses a Compose run whose
`PI_AUTH_FILE` isn't a regular file inside the temp root (row 244). A listener covering loopback port 9, an
unsafe privileged-port threshold or unreadable host inventory refuses startup.
Lane a leases ports 61000..61127, lane b 61128..61255. Refuse any band overlapping
+5 -2
View File
@@ -69,7 +69,7 @@ export async function executePinnedSteps(steps,options,{receipt,checkout,inspect
verifyImage(receipt,checkout,inspect);return {rc:0,imageBuilds:0};
}
import { mkdirSync, lstatSync, fstatSync, mkdtempSync, writeFileSync, readFileSync, openSync, readlinkSync, rmdirSync } from 'node:fs';
import { mkdirSync, lstatSync, fstatSync, mkdtempSync, writeFileSync, readFileSync, openSync, readlinkSync, rmdirSync, chmodSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
import { spawnSync } from 'node:child_process';
import { status, appendRecord, runRecords, processInventory, sameProcess, nonce } from './lane-state.mjs';
@@ -109,7 +109,9 @@ export function activeConflicts(o,observations={}){
export function cleanEnv(o,run,docker){
const kept=['PATH','HOME','USER','LOGNAME','SHELL','LANG','LC_ALL','LC_CTYPE','TZ','TERM','XDG_RUNTIME_DIR','DBUS_SESSION_BUS_ADDRESS','DOCKER_HOST','CHROMIUM'];
const env=Object.fromEntries(kept.filter(k=>typeof process.env[k]==='string').map(k=>[k,process.env[k]]));
Object.assign(env,{TMPDIR:o.tempRoot,TMP:o.tempRoot,TEMP:o.tempRoot,MOSAIC_GATE_LANE:o.lane,MOSAIC_GATE_STATE:o.stateRoot,MOSAIC_TEST_CONCURRENCY:'2',COMPOSE_PROJECT_NAME:run.project,JITI_FS_CACHE:join(o.tempRoot,'jiti'),XDG_CACHE_HOME:join(o.tempRoot,'cache'),MOSAIC_CONFIG:join(o.tempRoot,'missing-config.json'),MOSAIC_GATE_DOCKER_RECORD:join(o.out,'docker.json')});
Object.assign(env,{TMPDIR:o.tempRoot,TMP:o.tempRoot,TEMP:o.tempRoot,MOSAIC_GATE_LANE:o.lane,MOSAIC_GATE_STATE:o.stateRoot,MOSAIC_TEST_CONCURRENCY:'2',COMPOSE_PROJECT_NAME:run.project,JITI_FS_CACHE:join(o.tempRoot,'jiti'),XDG_CACHE_HOME:join(o.tempRoot,'cache'),MOSAIC_CONFIG:join(o.tempRoot,'missing-config.json'),MOSAIC_GATE_DOCKER_RECORD:join(o.out,'docker.json'),
// A step reaching Compose mounts this fixture, never the host's pi auth file (row 244).
PI_AUTH_FILE:join(o.tempRoot,'pi-auth.json')});
env.PATH=`${join(o.out,'bin')}:${env.PATH??'/usr/bin:/bin'}`;
return env;
}
@@ -179,6 +181,7 @@ async function runGate(input){
const guard=join(BUILDER,'scripts/testing/lane-docker.mjs');
// Launch the guard directly so it validates every real Docker invocation.
writeFileSync(join(o.out,'bin','docker'),`#!/bin/sh\nexec '${process.execPath.replaceAll("'","'\\''")}' '${guard.replaceAll("'","'\\''")}' "$@"\n`,{mode:0o700,flag:'wx'});
const auth=join(o.tempRoot,'pi-auth.json');writeFileSync(auth,'{}',{mode:0o600,flag:'wx'});chmodSync(auth,0o600);
const steps=discoverSteps(o.checkout);record({kind:'plan',steps});
const before=homeMetadata(process.env.HOME);record({kind:'home-before',entries:before});
const env=cleanEnv(o,run,docker);
+12 -4
View File
@@ -1,8 +1,9 @@
import { execFileSync, spawnSync } from 'node:child_process';
import { readFileSync, writeFileSync, linkSync, unlinkSync, openSync, closeSync, fstatSync, constants } from 'node:fs';
import { readFileSync, writeFileSync, linkSync, unlinkSync, openSync, closeSync, fstatSync, realpathSync, statSync, constants } from 'node:fs';
import { join, isAbsolute } from 'node:path';
import { pathToFileURL } from 'node:url';
import { refuse, shortRoot, nonce } from './lane-state.mjs';
import { refuse, shortRoot, nonce, within } from './lane-state.mjs';
const HOST_PI_AUTH='/home/jwoltje/.pi/agent/auth.json';
function composeFiles(run){
if(!/^sha256:[a-f0-9]{64}$/.test(run.imageId??''))refuse('invalid pinned image ID');
if(typeof run.checkout!=='string'||!isAbsolute(run.checkout))refuse('invalid candidate checkout');
@@ -27,12 +28,19 @@ export function writeComposeOverride(run){
finally{if(fd!==undefined)closeSync(fd);}
return file;
}
export function guardDocker(argv,run){
// compose.yaml mounts PI_AUTH_FILE, defaulting to the host's real pi auth file (row 244).
function laneAuthFile(env,run){
const file=env.PI_AUTH_FILE;let real,root;
try{real=realpathSync(file);root=realpathSync(run.tempRoot);}catch{refuse('Compose auth mount not a lane fixture');}
if(!isAbsolute(file)||file===HOST_PI_AUTH||real===HOST_PI_AUTH||!within(root,real)||!statSync(real).isFile())refuse('Compose auth mount not a lane fixture');
}
export function guardDocker(argv,run,env=process.env){
if(argv[0]==='info'&&argv.length===1)return argv;
if(argv[0]==='image'&&argv[1]==='inspect'&&argv[2]===run.tag&&argv.length===3)return argv;
if(argv[0]==='compose'&&argv[1]==='run'){
const service=argv.indexOf('mosaic-agent',2);
if(service<0||argv.slice(2,service).some(a=>!['--rm','-T','--no-deps','--interactive','-i'].includes(a)))refuse('Docker Compose run options not allowed');
laneAuthFile(env,run);
const [base,override]=composeFiles(run);
return ['compose','-f',base,'-f',override,'run','--pull','never','--no-deps',...argv.slice(2)];
}
@@ -55,6 +63,6 @@ export function cleanupProject(run,docker='docker'){
}
if(process.argv[1]&&import.meta.url===pathToFileURL(process.argv[1]).href){
try{const run=JSON.parse(readFileSync(process.env.MOSAIC_GATE_DOCKER_RECORD,'utf8'));if(process.env.COMPOSE_PROJECT_NAME!==run.project)refuse('Compose project mismatch');
const argv=guardDocker(process.argv.slice(2),run);if(argv[0]==='compose')writeComposeOverride(run);const result=spawnSync(run.docker,argv,{stdio:'inherit'});process.exit(result.status??1);
const argv=guardDocker(process.argv.slice(2),run,process.env);if(argv[0]==='compose')writeComposeOverride(run);const result=spawnSync(run.docker,argv,{stdio:'inherit'});process.exit(result.status??1);
}catch(e){process.stderr.write(`${e.message}\n`);process.exit(2);}
}