Compare commits

..
Author SHA1 Message Date
jason.woltje f869fd0624 morgana: row 204 packet records Duckworth's round 1 approve and carry items N1-N3 (duckworth-0037) 2026-10-20 15:12:19 -05:00
jason.woltje 0caf6e2f67 morgana: row 204 (193a, #1702) covers takes a required least level (Duckworth N1 on R1, sage-1420), unknown grant level covers nothing, 7/7 mutants killed, manifest 2026-10-20 15:08:28 -05:00
jason.woltje e9ce59cc43 morgana: row 204 (193a, #1702) viewer level on R1's candidate 44e36fc4c: LEVELS collaborator and viewer, R1's level tests follow, packet updated 2026-10-20 15:04:19 -05:00
jason.woltje 1aa9ca9731 morgana: row 193a test context takes R1's ssoSubjects (gizmoduck-0029), packet records the confirmed interface 2026-10-20 15:03:37 -05:00
jason.woltje 7f07da4aa2 morgana: row 193a (#1691) viewer level tests against FED r4.5 R1's interface, source change waits for R1's candidate (sage-1373 item 3) 2026-10-20 15:03:37 -05:00
jason.woltje 44e36fc4c6 row 201: drop a stray __pycache__ file from the queue-201 packet 2026-10-20 14:57:07 -05:00
jason.woltje 80fe310713 row 201 (#1699): FED R1 access.json loader and validator, covers, tests and 44 mutants (Gizmoduck, round 1) 2026-10-20 14:57:00 -05:00
jason.woltje ae3c36f248 agents: Gizmoduck seat dir and roster row (FED R1 owner, #414) 2026-10-20 14:42:15 -05:00
jason.woltje a2af7ab495 plans: FED R1, R5, R2 and R8 briefs (#414, r4.9 a1daa3c07) and row 193a-d build briefs (#1691); Quackerjack seat dir and roster row 2026-10-20 14:41:03 -05:00
jason.woltje aa1bd209e3 gate-lane: row 189 gate timing table for row 197 (Herb) 2026-10-20 14:25:17 -05:00
jason.woltje ace27dc5eb row 189 (#1687): stack-release.sh activate --no-send drops the Console's --send (Sage; Dewey approve 29485; Sage gate passed) 2026-10-20 14:21:56 -05:00
jason.woltje a4a42dbac2 plans: stack-release --sso brief (#1698, Launchpad, reviewer Webby, row 192 F3; chained on 192) 2026-10-20 14:18:08 -05:00
jason.woltje a31f1a0f8b plans: row 198 brief records Mos's call, in-process board reads behind the Console's auth (mos-1019) 2026-10-20 14:17:03 -05:00
jason.woltje 992650232f plans: agent credentials and git identity brief (MVP T1 item 6, #1697, Megavolt, reviewer Duckworth); Megavolt seat dir 2026-10-20 14:14:33 -05:00
33 changed files with 1605 additions and 436 deletions
+3
View File
@@ -22,6 +22,9 @@ not fleet seats. Keep changes in `/mnt/storage/src/mosaic-stack`; do not modify
| Gander | Builder, the human boundary across uids (P19 333, #1694) | T3 session (Codex, gpt-6.1-sol), started by Sage under mos-0977 | none, T3 only |
| Herb | Builder, a second gate lane (#1695) | T3 session (Codex, gpt-6.1-sol), started by Sage under mos-0977 | none, T3 only |
| Bushroot | Builder, retire the control board writer on 7331 (#1696) | T3 session (Claude, claude-haiku-5-5), started by Sage under mos-1008 | none, T3 only |
| Megavolt | Builder, MVP T1 item 6 (agent credentials and git identity, #1697) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
| Quackerjack | Builder, federation R5 and R2 (#414) | T3 session (Claude, claude-haiku-5-5), started by Sage under mos-1023 | none, T3 only |
| Gizmoduck | Federation design (#414) and builder of FED R1 | T3 session (Claude, Opus 5.5, thread 9312a896) | none, T3 only |
| Webby | Reviewer: Case 2 designs, rows 192 and 194 | T3 session (Claude, Opus 5.5, thread 03086006), started by Mos (mos-0981) | none, T3 only |
| Duckworth | Reviewer: federation designs, row 193 | T3 session (Claude, Opus 5.5, thread ffceafec), started by Mos (mos-0977) | none, T3 only |
| Sage | Project lead: coordination, review, integration; earlier DYOR strategy records retained | T3 session (Claude Code); Pi launcher `zai/glm-5.3:high` retained | `agents/sage/launch.sh` |
-292
View File
@@ -1,292 +0,0 @@
# Row 198 design: retire the control board writer on 7331
Gitea #1696. Brief `docs/plans/2026-10-20_retire-control-board.md` (blob 11977a5cb, commit a31f1a0f8, Mos's
call mos-1019). Owner Bushroot, reviewer Webby, gate owner Sage. Inventory: `agents/bushroot/work/queue-198/INVENTORY.md`
(004f133bd, sha256 76b630ce...877).
Base: brief commit a31f1a0f8 is on origin `refactor` (head aa1bd209e). This branch rebases on `refactor` after
row 192 lands (Sage's sequencing, ruling 4).
## Revision 5 (Webby REVIEW-r4.md, origin/webby/reviews 5a02ee395, on rev 4 9af75dc72; Mos mos-1045)
- **B1 (r4, the `--tester-port` swap)**: reverted. `parses_flag` is for `--board` only. The `--tester-port` grep at
`stack-release.sh:670` stays. The `--tester-port` test row I added in rev 4 is removed.
- **N1 (r4, stale mark)**: fixed. A write counter guards `index()` coalescing. `markSeen()` and `reply()` start a fresh
scan and never join one. The new `board.test.mjs` case is in the new-tests table, and mutant M15 is in the table.
- **Sequencing (sage-1386)**: row 197 lands before 198. The rebase is noted under Open points.
## Revision 4 (Webby REVIEW-r3.md, origin/webby/reviews c9d65664a, on rev 3 7e2ead7f3)
- **B1** (blocking child calls): fixed. The board's child calls become async. `createBoard` takes an async `exec`,
and `index()`, `markSeen()` and `reply()` return promises. The sync `scan` and `replyToRow` stay for the CLI and
their existing tests. See decision 10, the new B1 test and mutant M14.
- **N1** (fallback copy): fixed. Decision 8 now says which copy gets the 7331 fallback.
- **N2** (flag check matched a refusal): fixed. The parse check matches the parser branch, not the flag name. See
decision 8. M12's target now names `--board` only in a refusal.
## Revision 3 (Sage rulings sage-1372, on rev 2 80fd45521)
- Ruling 1: `/api/seen` goes under `--send`, the same as `/api/reply`. Mos agrees (mos-1023 item 5). Both POSTs sit
above `serve.mjs:558` (decision 5).
- Ruling 2: the board context keeps the writer's read-only fleet default (decision 9).
- Ruling 3: a rollback to a copy with no recorded `--board` falls back to 7331, which is dark, as it is today (decision 8).
- Ruling 5: loopback stays read-only. No new gate in this row. The cross-uid loopback gate is row 196's. Sage took
this to Mos (sage-1370). If Mos rules otherwise, Sage says so and this design changes.
- Ruling 6, correction: neither the live pointer nor the live unit carries `--board`. Sage checked the live
`ExecStart`: `cli.mjs serve --checkout /mnt/storage/src/mosaic-stack --port 7332 --public-host mosaic.woltje.com
--auth-header X-authentik-username --allow-user jason.woltje --send --tester-port 7333`. Rev 2 said otherwise, and
that was wrong. Decisions 6 and 8 are rewritten. The B2 guard stays. `webui-service.sh` lines 44, 71, 110 and 163
still take and emit `--board`, so they are in the retirement.
- Ruling 7: base. Brief commit a31f1a0f8 is on origin `refactor` at aa1bd209e.
## Revision 2 (Webby REVIEW.md, origin/webby/reviews 9afb947c7)
Rev 1 was c8fe23a37. Rev 2 answered B1-B3 and N1-N5 (80fd45521). Changes are marked where they land.
- **B1** (pinned copy): fixed. `packages/control-board/src` and `tools/tmux/agent-send.sh` join `COPY_PATHS`. The
copy test starts the Console from a fresh copy and expects 200 on `/api/board`. See decision 7.
- **B2** (re-pin carries `--board`): fixed. `stack-release.sh` drops `--board` when the target's `cli.mjs` lacks it,
as `console_for_target` already does for `--tester-port`. The plan lists the drop. See decision 8.
- **B3** (POST placement): fixed. The board POST branches sit above the refusal at `serve.mjs:558`, after the
header and origin checks, next to bus message and chat. Loopback cannot reply or mark seen. See decision 5 and mutant M10.
- **N1**: the board context uses the writer's default fleet source. See decision 9.
- **N2**: the M2 check covers all of `packages/control-board/src`, not one file.
- **N3**: the lines that change are listed by file in "Test line changes" below.
- **N4**: the README records that loopback board reads stay open to any local process until Mos rules.
- **N5**: row 198 lands after row 192. The board route tests rerun on the rebased tree.
## Decisions
1. **The Console calls the board's read code in-process.** A new module `packages/control-board/src/board.mjs`
holds the route logic that `createServer` runs today: rescan, mark seen, reply and conversation read. It
imports no `node:http` and opens no socket. Its methods are async (decision 10).
2. **No listener anywhere in the board package.** `createServer` and `startServer` are deleted from
`serve.mjs`. `serve.mjs` keeps its pure exports (`replyToRow`, `isLoopbackHost`, `foreignRequest`,
`conversationResponse`, `REFUSAL_STATUS`, the reply constants). That keeps `packages/conversation/tests/flows.test.mjs`
unchanged. The file name `serve.mjs` now misleads. Renaming it is a separate follow-up and is not in this row.
3. **The writer's CLI refuses `serve`.** `packages/control-board/src/cli.mjs` keeps `scan`. `serve` exits 2 with a
message that points to the Console. The default port 7331 goes away.
4. **The Console gets the board by context, not by URL.** `startServer` in `packages/webui/src/serve.mjs` takes
`board` as a context object (or `null` when no board is configured), not a URL. `DEFAULT_BOARD`, `boardURL`
and the proxy `fetch` are removed.
5. **`/api/reply` and `/api/seen` are gated by `--send`, and sit above the `:558` refusal.** Today the loopback Console
forwards both POSTs with no `--send` check. That is a real gap, and this row closes it. Placement matters.
In preview mode, `serve.mjs:558` refuses every non-GET before the board block, so a board branch below it never
runs. In loopback mode, `--send` cannot be set at all (`:479`). So both POST branches go after the header and
origin checks (`:493-516`), beside `/api/bus/message` (`:550`) and chat (`:554`), and require
`send !== null && named`. The result: `/api/reply` is the first board write the live Console has ever allowed.
Mos ruled it in (mos-1019), and mos-1023 item 5 confirms `/api/seen` too. In loopback mode, reply and mark-seen
refuse with 403 `read-only`. Board reads still work there. `/api/seen` writes marks under the data root, and without
the gate it would be the only preview-mode POST that passes without `--send`.
6. **The retirement removes `--board` from the Console scripts.** No installed unit or live pointer carries `--board`
(Sage checked, rev 3). So no live unit needs a flag change to start. The flag goes from `packages/webui/src/cli.mjs`
(`:14`, `:18`, `:32`) and `scripts/webui-service.sh` (lines 44, 71, 110, 161-163). An old `--board` argument refuses
with the usage error (exit 2). A re-pin with the flag removed is an ordinary re-pin. Cutover needs no unit edit.
7. **The pinned copy carries the board code (B1).** The live Console runs from `copies/<sha>`. `COPY_PATHS` in
`scripts/stack-copy.sh` (lines 23-33) lacks `packages/control-board/src` and `tools/tmux`. Without them a
Console from a copy fails at import, and `reply` cannot find `agent-send.sh`. Both paths join `COPY_PATHS`.
`tools/tmux` goes in whole, the same way `packages/plugins` does, because `agent-send.sh` may load its own
siblings. The `control-board/src` closure is `conversation/src`, `discord/src` and `seat/src`, and those are already in
the list.
8. **Re-pin and rollback drop `--board` when the target cannot parse it (B2 guard).** The pointer's console flags
(`P_CONSOLE`, `stack-release.sh:128`) pass through `:676` into `RUN_CONSOLE`, and `:459` passes them to
`webui-service.sh install`. The live pointer has no `--board` (rev 3), so this is a guard for a pointer that
carries one, such as an older pointer or a rollback target. Rule: keep `--board` only when the target's
`packages/webui/src/cli.mjs` parses it. Otherwise drop it and report the drop in the plan. That is the same shape
as `console_for_target` and `--tester-port` (`:667`, `:687-690`). Rollback to a copy that parses `--board` keeps
the recorded flag. A copy that does not parse it (a new copy) gets the flag dropped and starts with no board URL,
so its board is in-process or null, never 7331. The 7331 fallback applies only to an older copy that parses
`--board` and has no recorded flag. That copy starts with its own default, 7331, which is dark, the same as today
(Sage ruling 3).
**Parse check (N2, rev 5).** "Parses it" means the target's `cli.mjs` holds the parser branch, `flag === '--board'`,
not the flag name. The name also appears in the usage text and in refusal messages, so a plain `grep` would count
a copy that refuses the flag as one that accepts it. One helper, `parses_flag FILE FLAG`, greps for
`flag === 'FLAG'`. It is used for `--board` only, in `console_for_target` and the rollback path. The existing
`--tester-port` grep at `stack-release.sh:670` stays as it is (Webby REVIEW-r4). Changing it would break the
`stack-release.test.mjs:265` fixtures, which write a comment, not a parser line. No copy names `--tester-port` in a
refusal today, so the gap exists only for `--board`.
9. **The board context uses the writer's default fleet source (N1).** `--fleet` defaults to `~/.mosaic/fleet/agents`,
as the writer did (`cli.mjs:14`). The Console reads it and never writes it. Whether the fleet is retired
and the default becomes `none` is a later decision. This row keeps the board's contents unchanged.
10. **The board's child calls are async (B1).** The Console runs the board in its own process. A blocking child call
would stall every route, the event stream, `/healthz` and the tester listener, which shares the process
(webui `cli.mjs:91`). Rule: no `spawnSync` on any path that `createBoard` reaches.
- `scan.mjs` gains `tmuxInspectAsync` (same args, same 5 s timeout) and `scanAsync`. The sync `tmuxInspect` and
`scan` stay for the CLI. The parse of the tmux output is one pure function that both paths call, so they cannot
drift.
- `serve.mjs` gains `replyToRowAsync` (same 15 s timeout, same env, same result shapes). The sync `replyToRow`
stays for its existing tests. The reply trailer and status mapping are shared pure code.
- The default async `exec` is `spawn` with a timeout that kills the child. A timeout or a spawn failure maps to
`{ error }`, the same shape the sync path returns. Tests inject `exec`.
- `index()` coalesces concurrent calls, but only onto a scan that started after the last write (rev 5, Webby N1).
The board keeps a write counter. `markSeen()` and `reply()` bump it, then start a fresh scan and never join one.
`index()` joins an in-flight scan only when that scan started at the current counter value. So an answer never
omits a mark that was set before it was asked for. This coalescing is my choice to limit tmux calls under
parallel reads; the design has no other constraint on it.
- `conversation(url)` spawns nothing. It reads files on the event loop, as the writer did. Not changed in this row.
## Open points for Sage and Mos
- **Auth on loopback (Sage ruling 5, taken to Mos as sage-1370).** The Console has two modes. In preview mode, a named
request needs the proxy header and proof, and the board routes inherit that. In loopback mode (no `--public-host`),
there is no sign-in. Loopback stays read-only with no new gate in this row. Board reads stay open to any local
process, as they are today. Reply and mark-seen cannot run there, because `--send` needs preview mode. The
cross-uid loopback gate is row 196's. If Mos rules otherwise, Sage says so and this design changes.
- **Row 192 overlap (Launchpad, SSO on `webui/src/serve.mjs`).** My edits touch these regions of
`packages/webui/src/serve.mjs`:
- lines 12-27 (`DEFAULT_BOARD`, `boardURL`)
- line 466 (`startServer` signature, `board` option)
- lines 473 (`upstream`)
- lines 556-580 (board proxy block)
- lines 598-610 (`/api/config` `board` field)
I do not edit the preview and proof gate (lines ~486-505) or the auth-header checks. The board branches go
after those checks, in the same order as the bus and chat branches. Sage should sequence 192 and 198 so the
second one rebases on the first. I will not touch the SSO code.
- **Cutover.** The new Console code reaches the live Console through a copy and re-pin. The restart is a human
step (`console-holds-human-authority`). No unit flag changes (decision 6). The live unit stays up until then.
- **Sequencing (ruling 4, sage-1386).** Sage sequences 198 after 192 and after row 197 (Herb). I rebase once, onto the
tree with 192 and 197 landed, and rerun the board route tests there. 197 replaces closed-endpoint ports with dynamic
allocations. Its overlap with this row is `packages/webui/tests/serve.test.mjs:100` and
`packages/webui/tests/preview.test.mjs:208`. My rewrites of those lines (the dead-board 503 change at serve.test.mjs:79
and :105, and preview.test.mjs:219 in the N3 list) are written against 197's dynamic ports, not the old fixed ones.
I do not edit either overlap line before 197 lands. The manifest goes to review only after the rebase.
## File-by-file
### Board package
| File | Change |
|---|---|
| `packages/control-board/src/board.mjs` | **New.** `createBoard({ specs, boardDir, seatsDir, discordDataRoot, isAlive, now, agentSend, exec, conversationReader })`. Returns `{ index(), markSeen(body), reply(body), conversation(url) }`. `index`, `markSeen` and `reply` return promises and use the async paths (decision 10). Each method is the body of a route that `createServer` runs now. No `node:http`, no listener. |
| `packages/control-board/src/scan.mjs` | Adds `tmuxInspectAsync` and `scanAsync`, and one shared pure parse. Sync `tmuxInspect` and `scan` stay for the CLI. |
| `packages/control-board/src/serve.mjs` | Delete `createServer` (lines 229-310), `startServer` (312-322), `page` loading (`loadPage`, 224-227), and the `node:http` import. Keep the pure exports and the sync `replyToRow`. Add `replyToRowAsync` (decision 10). Remove the `7331` default. |
| `packages/control-board/src/cli.mjs` | `serve` exits 2 with `refused: the control board no longer serves HTTP. Use the Console: node packages/webui/src/cli.mjs serve`. The `--port` and `--host` flags are removed. `scan` is unchanged. Usage text is updated. |
| `packages/control-board/src/page.html` | Delete. The Console serves its own `index.html`. |
| `packages/control-board/README.md` | Remove the `serve` section (lines ~107, 127), the port default and the start instructions. Keep the reply-convention text (lines 144-151). It names no start command. |
| `packages/control-board/package.json` | The `mosaic-board` bin keeps `scan` only. Description text updated. |
### Console
| File | Change |
|---|---|
| `packages/webui/src/serve.mjs` | Remove `DEFAULT_BOARD`, `boardURL` and the proxy `fetch`. `startServer({ board })` takes a context object or `null`. Board routes call `board.index()`, `board.markSeen()`, `board.reply()` or `board.conversation()` directly. If `board` is `null`, the board routes answer 503 with `{ error: 'no board configured' }`. `/api/reply` and `/api/seen` need `send !== null && named`, the same gate as the other writes. Without it they answer 403 `read-only`. `/api/config` returns `board: null` and no URL. The app reads `config.board` only for the footer text, so a `null` value needs a small change in `app.js:468`. |
| `packages/webui/src/cli.mjs` | Remove `--board` and its usage text. Build the board context from the config, `--checkout` (or cwd, as the writer did), and the seats and board dirs. Failure to read the system config gives `board: null`, not a refusal. The Console still starts, as it does for the bus today. |
| `packages/webui/src/public/app.js` | Line 468: keep the footer, show `Board: in-process` (or hide it). Lines 365, 468 and the 502 message text (`Board unreachable ... Check the board server`) are reworded. The text is not a contract. |
| `packages/webui/src/public/index.html` | No change. |
| `packages/webui/README.md` | Remove the start line (10), the `--board` line (15), the test command that lists `control-board/tests/` (766, keep only the in-process tests), and the rollback line (881). |
| `packages/webui/systemd/mosaic-webui.service.in` | No change in the template. The `@FLAGS@` set loses `--board`. |
| `scripts/webui-service.sh` | Remove `--board` from the usage (44, 71), the parser (110), the validation (161-162) and the flag (163). An old `--board` argument refuses with the usage error. |
| `scripts/stack-copy.sh` | **B1.** `COPY_PATHS` (23-33) adds `packages/control-board/src` and `tools/tmux`. |
| `scripts/stack-release.sh` | **B2.** `console_for_target` (around 664-692) drops `--board` when the target's `cli.mjs` does not parse it. The plan line names the drop. Rollback keeps the recorded flag when the target parses it. |
| `packages/webui/README.md` | Also records (N4) that loopback board reads stay open to any local process until Mos rules. |
| `packages/webui/src/public/bus.js` | No change. The `#/` board link is the Console's own page, not 7331. |
### Tests (webui)
Fixture plan. "Dead board" means a context whose `index()` throws, which replaces `board: 'http://127.0.0.1:9'`.
| File | Change |
|---|---|
| `packages/webui/tests/fixture.mjs` | Call `createBoard` directly. Pass the context to `startServer`. No port 0 board server. |
| `packages/webui/tests/history-fixture.mjs` | Same as fixture.mjs. |
| `packages/webui/tests/discord.test.mjs` | Same. Board server removed. |
| `packages/webui/tests/relaunch.test.mjs` | Same. |
| `packages/webui/tests/return-flow.test.mjs` | Same. |
| `packages/webui/tests/tester-fixture.mjs` | `board: 'http://127.0.0.1:9'` becomes the dead board. |
| 16 files with `board: 'http://127.0.0.1:9'` (bus, chat, instances-browser, limits-browser, messages, pinned-copy, preview, proof-cli, proof, routes-browser, send, settings-form, tester-cli, tester-listener, usage-browser, views) | Mechanical: dead board. Assertions that expect a 502 `Board unreachable` string are updated to the new 503 `no board configured` or the dead-board error text. Sage needs to confirm this count before the edit. |
| `packages/webui/tests/serve.test.mjs` | Lines 17-19 (`boardURL` checks) are removed. Add the new board route tests (below). |
| `packages/webui/tests/reads.test.mjs` | Line 188: the `board` setting becomes `null`. |
| `packages/webui/tests/service.test.mjs` | Lines 225-394: unit `ExecStart` text loses `--board`. The `--board` refusal cases (250, 260, 394) become refusal of the old flag. |
| Browser tests (`browser-edge`, `bus-browser`, `chat-browser`, `docs-browser`, `editor-browser`) | Fixture context only. No change to the browser assertions. |
### Tests (control board)
| File | Change |
|---|---|
| `packages/control-board/tests/serve.test.mjs` | 32 tests use HTTP. Move the route assertions to `board.test.mjs`, which calls `createBoard` directly. Keep the `replyToRow` tests here. |
| `packages/control-board/tests/discord.test.mjs` | Two HTTP uses (lines 123 and the `startServer` call). Call `createBoard`. |
| `packages/control-board/tests/scan.test.mjs`, `attention*.test.mjs`, `relaunch.test.mjs` | No change. They test `scan`, not HTTP. |
| `packages/control-board/tests/retired.test.mjs` | **New.** Three checks, all in-process, no socket. (1) `cli.mjs serve` exits 2 and prints the Console pointer. (2) The module graph has no `node:http` import and no `listen(` call in `packages/control-board/src`. (3) A grep over the start paths (`scripts/`, `*.service*`, `packages/*/README.md`, `package.json` scripts) finds no `control-board.*serve` or `7331` starter. |
### New tests for the acceptance items
| Test | Where | Assertion |
|---|---|---|
| Board view answers with nothing on 7331 | `packages/webui/tests/board-inprocess.test.mjs` (new) | Console with a context returns 200 on `/api/board` with a stubbed `globalThis.fetch` that throws on any call. Zero fetch calls. |
| Every board route refuses without the Console's gate | same file | In preview mode, `/api/board`, `/api/seen`, `/api/reply`, `/api/conversations` each return 401 without the header and 403 with a foreign Host. |
| `/api/reply` refuses without `--send` | same file | Preview mode, no `send`. POST `/api/reply` returns 403 `read-only`. `replyToRow` is never called (an injected `exec` counts calls). |
| `/api/seen` refuses without `--send` | same file | Same shape. No mark file written. |
| `/api/reply` runs with `--send` | same file | Positive control: with `send` and the sign-in header, the injected `exec` is called once with the expected argv. |
| No Console proxy left | `packages/webui/tests/board-inprocess.test.mjs` | `DEFAULT_BOARD` is not exported. `boardURL` is not exported. Grep check: no `7331` in `packages/webui/src`. |
| markSeen answer includes its own mark (N1, rev 5) | `packages/control-board/tests/board.test.mjs` (new) | Hold a scan in flight with an injected exec that resolves late. Call `index()`, then `markSeen()` before the scan resolves. The `markSeen()` answer shows the mark. A mutant that joins the pre-write scan must fail it. The same check covers `reply()`. |
| Board does not block the Console (B1) | same file | Injected async `exec` for the tmux check resolves after 2 s. While `/api/board` is pending, `/healthz` answers within 200 ms. Mutant M14 uses an `exec` that blocks the thread for 2 s with `Atomics.wait`, and this test must kill it. |
## Mutants
Each mutant runs on a scratch copy of the tree (not the live checkout), with the new tests. The test that
must kill it is listed. A mutant that survives means the test is weak.
| # | Mutant | Must be killed by |
|---|---|---|
| M1 | `cli.mjs serve` calls `startServer` again on 7331 | `retired.test.mjs` (1) |
| M2 | `serve.mjs` restores `createServer` or `startServer` and exports them | `retired.test.mjs` (2) |
| M3 | Console `DEFAULT_BOARD` is restored and the proxy `fetch` returns | `board-inprocess.test.mjs` zero-fetch case |
| M4 | `/api/reply` skips the `send` check | `/api/reply refuses without --send` |
| M5 | `/api/seen` skips the `send` check | `/api/seen refuses without --send` |
| M6 | The board block runs before the preview and header gate | "every board route refuses" (401 case) |
| M7 | A board route accepts a foreign Host | "every board route refuses" (403 case) |
| M8 | `scripts/webui-service.sh` re-accepts `--board` | `service.test.mjs` refusal case |
| M9 | A README or script again tells people to run `control-board serve` | `retired.test.mjs` (3) |
| M10 | The `/api/reply` and `/api/seen` branches move below the `:558` refusal | The positive control "`/api/reply` runs with `--send`" (403 from `:558`) |
| M11 | `COPY_PATHS` drops `packages/control-board/src` or `tools/tmux` | Fresh-copy test in `pinned-copy.test.mjs`: `/api/board` must answer 200 |
| M12 | `console_for_target` keeps `--board` for a target that does not parse it | `stack-release.test.mjs`: re-pin plan lists the drop; install argv has no `--board`. The target `cli.mjs` names `--board` only in a refusal message and has no parser branch (N2). A grep-based check keeps the flag and this test kills it. |
| M15 | `index()` joins a scan that started before the last write (N1) | the `markSeen` answer case in `board.test.mjs` |
| M13 | Rollback drops a recorded `--board` from a target that parses it | `stack-release.test.mjs`: rollback keeps the flag |
| M14 | The board's tmux check or reply runs through a blocking call (`spawnSync`, or an `exec` that blocks the thread) | `board-inprocess.test.mjs` B1 case: `/healthz` must answer within 200 ms while `/api/board` is pending |
Mutants M11 to M13 run in the cli suite, which needs Sage's slot. M10 and M14 run in one webui test file each.
## Test additions (B1, B2)
| Test | File | Assertion |
|---|---|---|
| Fresh-copy Console answers the board | `packages/webui/tests/pinned-copy.test.mjs` (extend) | `stack-copy.sh` builds a copy at a fixed sha in a temp dir. Starts the Console from it on port 0. `GET /api/board` returns 200 JSON. `agent-send.sh` resolves inside the copy. |
| Re-pin drops `--board` | `packages/cli/tests/stack-release.test.mjs` (extend) | Pointer records `--board`. Target `cli.mjs` lacks the parser branch, and names `--board` only in a refusal (the N2 fixture). The plan names the drop. The install argv has no `--board`. |
| Rollback keeps `--board` | same | Rollback to a copy with `flag === '--board'` in its `cli.mjs` keeps the recorded flag. |
## Test line changes (N3)
Board-related lines that change, by file:
- `packages/webui/tests/serve.test.mjs:79`: `/api/board` on a dead board, 502 becomes 503.
- `packages/webui/tests/serve.test.mjs:105`: 502 with the board URL in the body. Becomes 503 with no URL.
- `packages/webui/tests/preview.test.mjs:219`: dead board, 502 becomes 503. `board.body.board` is no longer a URL.
- `packages/webui/tests/send-browser.test.mjs:89`: a `502 board unreachable` string. Verify at edit time. Likely a bus status, which stays.
- `packages/webui/tests/serve.test.mjs:17-19`: `boardURL` checks, removed.
Unchanged, not board: `bus.test.mjs:226-236`, `chat.test.mjs:171-179`, `instances.test.mjs:163-168`,
`limits-route.test.mjs:35`, `project-file.test.mjs:128`, `routes.test.mjs:170`, `send.test.mjs:230`,
`tester-cli.test.mjs:57`, `tester-listener.test.mjs:113`, `usage.test.mjs:24`, `board-held.test.mjs:204` (page text).
Dead-board fixture files (16 webui plus `tester-fixture.mjs`) match Webby's count.
## Gate
Mutant runs use single test files, which run freely. Any suite (control-board, webui, cli, every
`scripts/test-*.sh`) needs Sage's slot.
Webby approves INVENTORY.md (already sent), then this DESIGN.md, then the manifest. Sage's gate runs the
control-board and webui suites, the cli suite and every `scripts/test-*.sh`. All must be green, and mutants
M1-M15 killed.
## Stop points
- If Mos rules that loopback board reads need a gate, this design changes. Sage tells me, and I stop and report.
- If the Console's auth model changes under row 192, the board block in section "Console" changes with it.
- The Console restart and unit re-render are Jason's or Mos's steps. I do not run them.
## Boundaries kept
No change to the live units, `~/.mosaic-dev`, `~/.config/mosaic-dev`, slot files, `~/.mosaic`, or any live port.
No request to 7331 or 7332 at any point.
-125
View File
@@ -1,125 +0,0 @@
# Row 198 inventory: control board writer and port 7331
Gitea #1696. Brief: `docs/plans/2026-10-20_retire-control-board.md`. Owner Bushroot, reviewer Webby, gate owner Sage.
Scope: tracked files at `bushroot/row198` (base `refactor`). Excludes `v1/` and other seats' `agents/*/work/` packets.
Method: `git grep -I` for `control-board`, `control_board`, `controlboard` and `7331`, then a read of each code hit.
## Classes
- **starts**: runs `packages/control-board` `serve`, or a unit, script or default that does.
- **imports**: loads code from `packages/control-board` at run time or test time.
- **mentions**: text only (docs, comments, fixture strings, history).
## Result
**Live dependency found. Stop before any removal.** The Console (`packages/webui`) proxies its board reads and
writes to the writer on 7331. The Console's board view, `/api/board`, `/api/seen`, `/api/reply` and the
conversation routes all forward to `DEFAULT_BOARD = http://127.0.0.1:7331`. The Console has no board without
a writer on that port. Mos decides before anything is removed.
Nothing in the repository runs `control-board serve` from a unit. The only starter is the manual command in
`packages/control-board/README.md` and `packages/webui/README.md`. `ss -ltnp` showed nothing on 7331 at
2026-10-20T19:05:09Z (brief).
## Hits
### Starts the writer, or defaults to its port
| File | Line | Class | What it does |
|---|---|---|---|
| `packages/control-board/src/cli.mjs` | 4, 14 | starts | `serve` subcommand; default `port: 7331` |
| `packages/control-board/src/serve.mjs` | 312 | starts | `startServer` default `port = 7331` (the writer itself) |
| `packages/control-board/README.md` | 107, 127 | starts | Tells people to run `serve` and open 7331; documents the default |
| `packages/webui/README.md` | 10 | starts | Tells people to run `node packages/control-board/src/cli.mjs serve` |
| `packages/webui/README.md` | 15, 881 | depends | Console reads the board at 7331; rollback uses "the unchanged board at 7331" |
| `packages/webui/src/cli.mjs` | 14, 18 | depends | `--board` default is `DEFAULT_BOARD` (7331) |
| `packages/webui/src/serve.mjs` | 12, 473, 561, 578, 610 | **depends (live)** | `DEFAULT_BOARD`; proxies `GET /api/board`, `GET /api/conversation(s)`, `POST /api/seen`, `POST /api/reply` upstream; returns 502 if the writer is absent |
| `packages/webui/src/public/app.js` | 468, 365 | depends | Page reads `/api/config` board, then `/api/board` through the Console proxy |
| `packages/webui/src/public/bus.js` | 1037, 1088, 1100 | depends | Routes `#/` to the board page; bus pages link to it |
| `scripts/webui-service.sh` | 89-163 | depends | Renders the Console unit with `--board URL`; default comes from `serve` |
| `packages/webui/systemd/mosaic-webui.service.in` | 29 | depends | Unit runs the Console (`webui/src/cli.mjs serve`), not the writer; flags carry `--board` |
| `scripts/stack-copy.sh` | 88 | mentions | Checks the Console `ExecStart` string; no writer reference |
| `scripts/stack-release.sh` | 1372 | mentions | Checks the Console command line; no writer reference |
### Imports code from `packages/control-board`
| File | Line | Class | What it imports |
|---|---|---|---|
| `packages/webui/tests/fixture.mjs` | 6 | imports | `startServer` from `control-board/src/serve.mjs` (test fixture for the Console) |
| `packages/webui/tests/history-fixture.mjs` | 8 | imports | same |
| `packages/webui/tests/discord.test.mjs` | 6 | imports | same |
| `packages/webui/tests/relaunch.test.mjs` | 6 | imports | same |
| `packages/webui/tests/return-flow.test.mjs` | 13 | imports | same |
| `packages/conversation/tests/flows.test.mjs` | 15 | imports | `replyToRow` from `control-board/src/serve.mjs` |
The webui tests run the writer's server in-process. They do not start the writer on 7331 (the tests bind
their own port), but they need `serve.mjs`. Any removal or refusal in `serve.mjs` breaks these tests unless
the writer's read-only code moves first.
`packages/webui/src/` (non-test code) does not import `control-board`. Only the tests do.
### Tests that name the writer or its files
| File | Line | Class | Note |
|---|---|---|---|
| `packages/control-board/tests/serve.test.mjs` | 37, 744-767 | imports | Tests the writer: reply runner, `REPLY_TRAILER`, `-S <host>:control-board` |
| `packages/control-board/tests/scan.test.mjs` | 47, 306, 353 | mentions | Tmp dir names and comments |
| `packages/webui/tests/serve.test.mjs` | 18-19, 35 | mentions | `boardURL` checks on port 7331; reply trailer string |
| `packages/webui/tests/reads.test.mjs` | 188 | mentions | Expected `board` setting value 7331 |
| `packages/webui/tests/service.test.mjs` | 225-394 | mentions | Unit `ExecStart` text includes `--board http://127.0.0.1:7331` |
| `packages/plugins/tests/harness.test.mjs` | 173 | mentions | Path string `packages/control-board/src/scan.mjs` |
| `packages/ledger/tests/ledger.test.mjs` | 80, 233-327 | mentions | Sender name `control-board` as a test fixture |
| `packages/queue/tests/fixtures/genesis-render.md` | 6-11 | mentions | Fixture text |
| `packages/conversation/tests/reader.test.mjs` | 2 | mentions | Comment |
| `packages/conversation/README.md` | 207 | mentions | Says the control-board suite serves the routes |
### Code that reads the sender name, not the writer
| File | Line | Class | Note |
|---|---|---|---|
| `packages/ledger/src/ledger.mjs` | 120 | mentions | Classifies `control-board` as sender kind `board`. No import. |
### Package and script metadata
| File | Line | Class | Note |
|---|---|---|---|
| `packages/control-board/package.json` | 2, 5, 9 | starts | Declares `bin: mosaic-board -> src/cli.mjs` (a `mosaic-board serve` entry point) |
| `package.json` (root) | - | none | No script names the board or 7331 |
| `scripts/mosaic` | 3 | mentions | Comment only |
| `packages/seat/package.json` | 5 | mentions | Description: "the control board reads" registration |
| `packages/seat/README.md` | 139 | mentions | Says the board reads `registration.json` |
| `packages/plugins/README.md` | 181 | mentions | Names `scan.mjs` in an exception note |
### Docs (mention only)
- `AGENTS.md` lines 144-150: reply convention for control-board attention. Names the package README. Does
not tell anyone to start 7331. The brief says to change it only if it does.
- `docs/SESSIONS.md` (32), `BUILD-LOG.md` (76), `docs/plans/CURRENT.md` (8), `docs/plans/QUEUE.md` (12),
`docs/plans/queue.json` (45), `docs/plans/2026-09-12_control-board-mvp.md` (21),
`docs/plans/2026-10-*` and other plan pages, `docs/plans/reviews/*`, `docs/design/*`.
- `docs/plans/CURRENT.md:171` says "Start it with `node packages/control-board/src/cli.mjs serve`". That is
an instruction to start the writer. Update it when the writer is retired.
- `docs/plans/2026-10-18_containerized-install-design.md:927` and `-rows.md:213` cite `serve.mjs` line numbers.
- `docs/plans/2026-10-19_seen-click-intent.md:29` cites `serve.mjs:257` (`/api/seen`).
- `BUILD-LOG.md` is append-only. Leave it alone.
- `agents/bushroot/README.md`, `agents/README.md`, `docs/design/README.md`, `docs/design/IMPLEMENTING.md`:
mention only.
- `packages/webui/third_party/editor/PROVENANCE.json`: mention only (the vendored editor's clipboard, not the board).
- `packages/webui/src/public/*` clipboard, board-view and CSS names: unrelated to the writer.
### Excluded
- `v1/*`: archived.
- `agents/*/work/*`: other seats' packets (darkwing, filbert, rocko and others). Not read for this inventory.
## Open items for Mos
1. The Console's board view and proxy routes need a writer on 7331 or an equivalent read path. Retiring the
writer means choosing a replacement first, or accepting that the board view goes dark.
2. Four plan pages and `CURRENT.md:171` still tell people to start the writer.
3. The webui tests and `conversation/tests/flows.test.mjs` import `serve.mjs`. Any change to it needs those
tests moved first.
## Sha256
Recorded in the send message after commit. This file is the inventory; the commit carries it.
+9
View File
@@ -0,0 +1,9 @@
# Gizmoduck
Design and builder seat, Claude Opus 5.5, a T3 thread (9312a896). Author of the federation design
(`agents/gizmoduck/work/federation/DESIGN.md` on `gizmoduck/federation-design`, #414). Builds FED R1, the
access.json loader and validator, under Mos's order mos-1023 item 1.
No launcher in this directory. The seat works in its own worktree and has no credentials of its own; until
the credentials row settles per-agent identity, it pushes only its own `gizmoduck/*` branches through the
repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
+100
View File
@@ -0,0 +1,100 @@
# Row 201 round 1 (#1699): FED R1, the access.json loader and validator
Gizmoduck, 2026-10-20. Reviewer Duckworth, gate owner Sage. Federation design #414.
- Brief: `docs/plans/2026-10-20_federation-r1-r2-r5.md`, section "Brief: FED R1", at a2af7ab49.
- Design: `agents/gizmoduck/work/federation/DESIGN.md` section 3.5, r4.9 a1daa3c07 on
`gizmoduck/federation-design`.
- Base: ae3c36f24 (`origin/refactor` when I branched, contains the brief). Branch `gizmoduck/fed-r1`.
- Candidate: the commit that adds this file. `candidate-manifest.sha256` lists the three code files.
## Interface
This is what R5 and row 193a build against. Any change to it in review goes to Sage before it lands.
Morgana confirmed it (morgana-0014) and Sage accepted it with `ssoSubjects` required (sage-1399). Sage
relays it to Quackerjack.
| Export | Shape |
|--------|-------|
| `LEVELS` | frozen `["collaborator"]`, the one allowed level set. 193a widens it with the single line `["collaborator", "viewer"]` |
| `accessFilePath(dir = configDir())` | `<dir>/access.json` |
| `checkAccess(doc, { providers, peers, businesses, ssoSubjects })` | pure, returns a frozen deep copy, throws `BusinessError` exit 2 naming the key path |
| `loadAccess(file, context, { uid = process.getuid() } = {})` | one no-follow read through `readJsonFile`, then the stat checks, then `checkAccess`. `uid` is a test seam |
| `covers(grant, { business, project, instance }, now, { level } = {})` | boolean. `now` must be a valid `Date`, otherwise TypeError |
The context: `providers` is an array of provider ids, `peers` an array of peer ids (never `local`),
`businesses` maps a business id to `{ human, instances }`, and `ssoSubjects` is an array of
`{ provider, sub }`, the owner's `console.sso.subjects`. A missing or malformed context refuses.
All four are exported from `src/index.mjs` too.
## Rules, code and tests
DESIGN 3.5 rule, where `access.mjs` enforces it, and the test that shows the refusal names the key path.
| 3.5 rule | access.mjs | access.test.mjs test | Mutant |
|----------|-----------|----------------------|--------|
| regular file, not a symlink | 167 (`readJsonFile`, util.mjs:66-89) | "loadAccess refuses a missing file, a symlink and a directory" | M03 |
| owned by the instance's uid | 168 | "loadAccess refuses another owner and group or other write" | M01 |
| no group or other write | 169 | same | M02 |
| unknown keys refuse | 147, 71, 83, 121 | "top level", "users: unknown keys", "users: subjects", "grants: unknown keys" | M05 M10 M16 M22 |
| `accessVersion` is 1 | 148 | "top level" | M06 |
| `revision` positive integer | 149 | "top level" | M07 |
| user id matches the id rule | 72 | "users: unknown keys and the id rule" | M11 |
| never `human` or `tester` | 73 | "users: human, tester and any business's human" | M12 |
| never any business's `human` | 74 | same, both businesses | M13 |
| provider is a given provider | 84-86 | "users: subjects, provider, sub" | M17 |
| a pair appears once in the file | 90 | same, within one user and across two | M20 |
| a pair is never in `console.sso.subjects` | 89 | same | M19 |
| `home` is a given peer or `local` | 75-77 | "users: home is a given peer or local" | M14 |
| grant names a declared business | 126-128 | "grants: unknown keys, id, user and business" | M26 |
| `projects` is `["*"]` or UUIDs | 98-107, 130-132 | "grants: projects" | M27 M28 M29 M30 |
| `instances` is `["*"]` or declared instances | 98-107, 133-135 | "grants: instances" | M31 |
| `level` is `collaborator` | 136 | "grants: any level other than collaborator" (missing, null, wrong case) | M32 |
| `expires` optional ISO time | 111-117, 137 | "grants: expires is optional ISO time" | M33 M34 |
| past `expires` is absent | covers 179 | "covers: an expired grant doesn't match, at and after the instant" | M38 M39 |
`covers`: `*` and listed ids match (180-182), an unlisted project, instance or business doesn't, an
expired grant doesn't (179), and a level filter rejects another level (183). Mutants M38 to M44.
## Deviations from the brief
1. Expiry. The brief's test list says expiry refuses. DESIGN 3.5 line 257 says a grant past `expires` is
treated as absent. Sage ruled the design wins (sage-1399), so a past `expires` passes `checkAccess`
and `covers` returns false at and after the instant. A malformed `expires` still refuses.
2. The brief lists "a grant disjoint from the given subjects". 3.5 has no rule that ties a grant to a
subject, and a grant names a user, not a subject. I read it as the 3.5 lines 248-249 subject rules:
a `(provider, sub)` pair that is also in `ssoSubjects` refuses, and so does a pair that appears twice.
Duckworth, say if you meant something else.
## Rules I added beyond 3.5
Each one is fail closed and has a test and a mutant.
- User ids and grant ids are unique (154, 123; M21, M24). A grant's `user` names a user in the file
(125; M25). Without these, a grant could point at nobody or two users could share an id.
- `projects` and `instances` are non-empty, distinct, and `*` never mixes with ids (98-106).
- `expires` must be UTC with a `Z` and must round-trip (`2026-02-30T00:00:00Z` refuses). `Date.parse`
accepts that date and rolls it to March 2, so the regex alone wasn't enough.
- The context is checked too (43-67; M35 to M37). `peers` can't name `local`, because that would make a
peer user look local.
- `covers` throws a TypeError for a `now` that isn't a valid Date (M44), since a bad clock would
otherwise make every grant look unexpired.
## Checks
- `node --test packages/business/tests/access.test.mjs`: 24 pass, 0 fail (single file, no slot).
- Morgana's `access-levels.test.mjs` at 08bb4d827, as a cross-check, not part of the candidate. On this
candidate it passes 3 of 4, and the one failure is the viewer case 193a adds. In a scratch copy with
`LEVELS` widened to `["collaborator", "viewer"]` it passes 4 of 4.
- `mutants.py`: 44 mutants, one per refusal and covers branch. All 44 anchors match once and every
mutant passes `node --check`. **Not run yet**: mutants need Sage's slot. Run as
`python3 -I agents/gizmoduck/work/queue-201/mutants.py <checkout> <empty scratch dir>`. It runs a control
first, puts each run in its own process group with a 60 s timeout, and exits 1 if any mutant survives.
- `node tools/unslop-hook/unslop-check.js` is clean on all three code files and this file.
- No test reads or pins `src/index.mjs` by hash (grep of packages/*/tests and scripts/test-*.sh).
- Not run: the business suite and `scripts/test-*.sh`. Both need Sage's slot.
## Boundaries kept
Nothing touched `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or any access.json. Tests write
only under `mkdtemp` scratch dirs.
+1
View File
@@ -0,0 +1 @@
ae3c36f24883e870541158d3ade1f5d3ef2ab856
@@ -0,0 +1,3 @@
74ac7e9ed38c230e60e7f7ece373104446c362b6fce0a4f92ca2210a981dc4f4 packages/business/src/access.mjs
7ee59b6e792fb47048fd06d227b65467ef71dabfcbdabdc0527b780656323d20 packages/business/src/index.mjs
3bec097f55909b31faf2bbc62c81e1d4d0290f50e696d3d3d367e77d82c5b238 packages/business/tests/access.test.mjs
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""Row 201 (FED R1) mutants: one per refusal in packages/business/src/access.mjs.
Usage: mutants.py <checkout> <scratch-dir>
Copies packages/business and packages/plugins from <checkout> into
<scratch-dir>, applies one mutant at a time (each anchor must match exactly
once), runs `node --test tests/access.test.mjs` in its own process group with
a timeout, and expects it to fail. Prints one line per mutant and exits 1 if
any survives, an anchor misses, or the unmutated control fails. Needs Sage's
slot. Writes nothing outside <scratch-dir>.
"""
import os, shutil, signal, subprocess, sys
MUTANTS = [
# loadAccess file checks
("M01 owner check", 'if (stat.uid !== uid) refuse(', 'if (false) refuse('),
("M02 group/other write check", 'if ((stat.mode & 0o022) !== 0) refuse(`access file', 'if (false) refuse(`access file'),
("M03 one-fd nofollow read", 'const document = readJsonFile(file, "access file", (stat) => {',
'const document = JSON.parse(readFileSync(file, "utf8")); ((stat) => {'),
# top level
("M04 document object", 'if (!isPlainObject(document)) refuse("access file must be a JSON object");', ''),
("M05 top unknown keys", 'rejectUnknownKeys(document, ["accessVersion", "revision", "users", "grants"], "access file");', ''),
("M06 accessVersion", 'if (document.accessVersion !== 1) refuse(', 'if (false) refuse('),
("M07 revision", 'requirePositiveInt(document.revision, "revision");', ''),
("M08 users array", 'requireArray(document.users, "users").forEach(', '(document.users ?? []).forEach('),
("M09 grants array", 'requireArray(document.grants, "grants").forEach(', '(document.grants ?? []).forEach('),
# users
("M10 user unknown keys", 'rejectUnknownKeys(user, ["id", "home", "subjects"], at);', ''),
("M11 user id rule", 'requireId(user.id, `${at}.id`);', ''),
("M12 human/tester", 'if (RESERVED_USERS.includes(user.id)) refuse(', 'if (false) refuse('),
("M13 business human", 'if (humans.has(user.id)) refuse(', 'if (false) refuse('),
("M14 home", 'if (user.home !== LOCAL_HOME && !ctx.peers.has(user.home)) {', 'if (false) {'),
("M15 subjects non-empty", 'if (subjects.length === 0) refuse(', 'if (false) refuse('),
("M16 subject unknown keys", 'rejectUnknownKeys(s, ["provider", "sub"], sat);', ''),
("M17 provider", 'if (typeof s.provider !== "string" || !ctx.providers.has(s.provider)) {', 'if (false) {'),
("M18 sub", 'requireText(s.sub, `${sat}.sub`);', ''),
("M19 owner subject", 'if (ctx.owners.has(key)) refuse(', 'if (false) refuse('),
("M20 duplicate subject", 'if (seenSubjects.has(key)) refuse(', 'if (false) refuse('),
("M21 duplicate user id", 'if (users.has(user.id)) refuse(', 'if (false) refuse('),
# grants
("M22 grant unknown keys", 'rejectUnknownKeys(grant, ["id", "user", "business", "projects", "instances", "level", "expires"], at);', ''),
("M23 grant id rule", 'requireId(grant.id, `${at}.id`);', ''),
("M24 duplicate grant id", 'if (seenGrants.has(grant.id)) refuse(', 'if (false) refuse('),
("M25 grant user", 'if (typeof grant.user !== "string" || !users.has(grant.user)) refuse(', 'if (false) refuse('),
("M26 grant business", 'if (typeof grant.business !== "string" || !ctx.businesses.has(grant.business)) {', 'if (false) {'),
("M27 scope non-empty", 'if (list.length === 0) refuse(`${where} must not be empty`);', ''),
("M28 scope * alone", 'if (list.length !== 1) refuse(', 'if (false) refuse('),
("M29 scope duplicate", 'if (seen.has(entry)) refuse(', 'if (false) refuse('),
("M30 project UUID", 'if (typeof p !== "string" || !UUID.test(p)) refuse(', 'if (false) refuse('),
("M31 declared instance", 'if (typeof x !== "string" || !business.instances.has(x)) refuse(', 'if (false) refuse('),
("M32 level", 'if (!LEVELS.includes(grant.level)) refuse(', 'if (false) refuse('),
("M33 expires format", 'if (typeof value !== "string" || !ISO_TIME.test(value) || Number.isNaN(Date.parse(value))) {', 'if (typeof value !== "string") {'),
("M34 expires real time", 'if (iso.slice(0, 19) !== value.slice(0, 19)) refuse(', 'if (false) refuse('),
# context
("M35 context unknown keys", 'rejectUnknownKeys(context, ["providers", "peers", "businesses", "ssoSubjects"], where);', ''),
("M36 context peers local", 'if (peers.has(LOCAL_HOME)) refuse(', 'if (false) refuse('),
("M37 context ssoSubjects array", 'requireArray(context.ssoSubjects, `${where}.ssoSubjects`).forEach(', '(context.ssoSubjects ?? []).forEach('),
# covers
("M38 covers expiry", 'if (grant.expires !== undefined && now.getTime() >= Date.parse(grant.expires)) return false;', ''),
("M39 covers expiry boundary", 'now.getTime() >= Date.parse(grant.expires)', 'now.getTime() > Date.parse(grant.expires)'),
("M40 covers business", 'if (grant.business !== business) return false;', ''),
("M41 covers project", 'if (!(grant.projects[0] === ALL || grant.projects.includes(project))) return false;', ''),
("M42 covers instance", 'if (!(grant.instances[0] === ALL || grant.instances.includes(instance))) return false;', ''),
("M43 covers level", 'if (level !== undefined && grant.level !== level) return false;', ''),
("M44 covers now check", 'if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw', 'if (false) throw'),
]
TIMEOUT = 60
def run(cwd):
proc = subprocess.Popen(["node", "--test", "tests/access.test.mjs"], cwd=cwd,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True)
try:
out, _ = proc.communicate(timeout=TIMEOUT)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, signal.SIGKILL)
proc.communicate()
return None, "timeout"
return proc.returncode, out.decode(errors="replace")
def main():
if len(sys.argv) != 3:
sys.exit("usage: mutants.py <checkout> <scratch-dir>")
checkout, scratch = (os.path.abspath(a) for a in sys.argv[1:])
if not os.path.isfile(os.path.join(checkout, "packages/business/src/access.mjs")):
sys.exit(f"no packages/business/src/access.mjs under {checkout}")
if os.path.exists(scratch) and os.listdir(scratch):
sys.exit(f"scratch dir not empty: {scratch}")
for pkg in ("business", "plugins"):
shutil.copytree(os.path.join(checkout, "packages", pkg), os.path.join(scratch, "packages", pkg))
pkg = os.path.join(scratch, "packages/business")
target = os.path.join(pkg, "src/access.mjs")
original = open(target).read()
code, out = run(pkg)
if code != 0:
print(out)
sys.exit(f"control failed (exit {code}): the unmutated tests must pass")
print("control: pass")
bad = 0
for name, old, new in MUTANTS:
n = original.count(old)
if n != 1:
print(f"{name}: ANCHOR {n}")
bad += 1
continue
mutated = original.replace(old, new)
if name.startswith("M03"):
mutated = 'import { readFileSync } from "node:fs";\n' + mutated
open(target, "w").write(mutated)
code, _ = run(pkg)
open(target, "w").write(original)
if code == 0:
print(f"{name}: SURVIVED")
bad += 1
else:
print(f"{name}: killed ({'timeout' if code is None else f'exit {code}'})")
print(f"mutants {len(MUTANTS)}, not killed {bad}")
sys.exit(1 if bad else 0)
if __name__ == "__main__":
main()
+8
View File
@@ -0,0 +1,8 @@
# Megavolt
Builder seat, Claude Opus 5.5, started by Sage under mos-0974 and mos-0977 item 5 for MVP T1 item 6, the
credential store per agent with its own git identity (#1697).
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `megavolt/*`
branches through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
+92
View File
@@ -0,0 +1,92 @@
# Row 204 (193a) build: the viewer level on R1's access file
Morgana (Opus 5.5), 2026-10-20. Queue row 204, Gitea #1702 (parent row 193, #1691), design
`agents/morgana/work/queue-193/DESIGN.md` r5 (e2d161e2e on morgana/row193, approved by Duckworth,
duckworth-0018). Go: sage-1373 item 3, sage-1420. Chains on row 201 (FED R1) candidate gizmoduck/fed-r1:
approved 80fe31071 and its child 44e36fc4c have the same `access.mjs` (44e36fc4c only drops a `.pyc`),
so the branch sits on 44e36fc4c (sage-1420 item 3). Lands after row 201 and rebases onto its landing.
Manifest: `candidate-manifest.sha256` in this directory. Base: `base.txt`.
## What it is
Two changes to R1's `packages/business/src/access.mjs`:
1. **Viewer level** (design 4.2). `LEVELS` becomes `["collaborator", "viewer"]`, most to least. R1's level
check already names `grants[<i>].level`, so the refusal path doesn't move.
2. **`covers` level required** (Duckworth's N1 on R1, sage-1420 item 2). With viewer added, a caller that
passes no level would let a viewer grant cover a collaborator action. Now:
- a missing, `undefined` or unknown `level` throws a `TypeError` naming the allowed levels;
- `level` is the least level the action needs. `{level: 'viewer'}` means read, and any grant covers it.
`{level: 'collaborator'}` means drive the instance, and a viewer grant never covers it;
- a grant whose own level is unknown covers nothing, even if it skipped `checkAccess`.
## Interface change for callers (R3, R4, R5 and row 193b1)
`covers(grant, target, now, {level})` must pass `level`. There is no "any level" call any more. Where FED
r4.5 and row 193's design 9 say `{}` (share reads, `project.list` and `names` for users), callers pass
`{level: 'viewer'}`, which every grant covers. Collaborator actions pass `{level: 'collaborator'}` as before.
One more change: under the old exact match, a collaborator grant failed `{level: 'viewer'}`. Under the least
level it covers it. No caller asked for exact viewer.
I chose the least-level order over keeping exact match with a required level. Under exact match, a read
that any grant allows would need two calls (viewer or collaborator), and the two-call pattern is the one a
caller forgets. The cost: `LEVELS` order is now meaningful, and the source comment says so.
## Files
- `packages/business/src/access.mjs`: `LEVELS`, its comment, and `covers`'s level check and comment.
- `packages/business/tests/access.test.mjs` (R1's): four places assumed collaborator only or exact match.
- The `LEVELS` test expects both levels.
- The refused-level test drops `viewer` from its bad list and adds `Viewer`. Its two regexes read
`must be one of collaborator, viewer`.
- The 11 `covers` calls that passed no level now pass `READ` (`{level: 'viewer'}`).
- The old "no filter accepts any" test becomes the least-level test.
- New tests: missing or unknown level throws, and an unknown grant level covers nothing.
- The `now` tests pass `READ` so they still reach only the Date check.
- `packages/business/tests/access-levels.test.mjs` (new): the row 193a tests.
No other test or script reads either source or test path (grep over `packages/*/tests` and `scripts/`).
`index.mjs` is unchanged: it already exports `LEVELS` and `covers`.
## Evidence
From `packages/business`, each file alone with `node --test --test-reporter=tap <file>`, 2026-10-20:
- `access-levels.test.mjs`: tests 5, pass 5, fail 0.
- `access.test.mjs`: tests 26, pass 26, fail 0.
Mutants: `mutants.py` in this directory. It runs from the worktree root, backs up `access.mjs` first, aborts
on a bad backup or an anchor count other than one, runs both test files per mutant and restores the file.
Log: `mutants.log`. Result: killed 7/7, file restored. Sage gave the go for this run (sage-1420 item 1).
| Mutant | Killed by |
|---|---|
| A1 levels collaborator only | 11 tests across both files |
| A2 owner added | levels 2; access 3, 13, 19, 24 |
| A3 covers ignores level | levels 3; access 23 |
| A4 order inverted (collaborator ask passes a viewer grant) | levels 3; access 20, 22, 23 |
| A5 level optional again (N1) | levels 4; access 24 |
| A5b level optional, no level means any (the code sets viewer, the least level) | levels 4; access 24 |
| A6 unknown grant level guard dropped | access 25 |
R1's own `agents/gizmoduck/work/queue-201/mutants.py` (44 mutants) is not rerun here. Its M43 anchor
(`if (level !== undefined && grant.level !== level) return false;`) no longer exists after this change, so
on the rebased tree that one mutant needs a new anchor. M32's anchor is still unique. That is for R1's gate on landing order. 193a's own mutants
above cover the changed lines.
## Review
Round 1 approved by Duckworth (duckworth-0037, REVIEW-r1.md at da03f5bf5 on duckworth/reviews) on manifest
d38de8c9add04ab29f31b5f010e32a2c1201c0420f5b99e31299868a0a29f75a. Carry items, none blocking:
- N1: R5 (row 202) takes `LEVELS` for `access grant --level`. Whichever of 202 and 204 lands second updates
R5's usage line and README to name viewer and adds one viewer grant test.
- N2: R1's M43 anchor is gone on this tree (see Evidence).
- N3: the A5b label above now matches `mutants.py` and `mutants.log`.
## Earlier history
Started before R1 existed against FED r4.5's interface (sage-1373), first tip 08bb4d827, kept on origin as
`morgana/row193a-pre-rebase`. The rebase onto R1 was pushed with `--force-with-lease`, which broke AGENTS.md
invariant 8. Reported in morgana-0016. Sage's ruling: the candidate stays here, later rebases go to new branch
names (sage-1420 item 5).
+1
View File
@@ -0,0 +1 @@
44e36fc4c6e44dc9c10adcdfa5cc3d124fa1c80a
@@ -0,0 +1,3 @@
0c1d9a126ee9e3cd21f946350387bbcd123c95a74ba22a6e230a7a4740b9b2ae packages/business/src/access.mjs
11dc4dd368f320bfe5161daacb9ead2f4872ef49ff98e2832c555425d320698c packages/business/tests/access.test.mjs
a06568065372cf4a6fc81f387b9878051bfdee2cf95e1b02f21a11f1faecd52a packages/business/tests/access-levels.test.mjs
@@ -0,0 +1,35 @@
KILLED A1 levels collaborator only
access-levels.test.mjs: 1 - a grant's level is collaborator or viewer
access-levels.test.mjs: 3 - covers tells a viewer grant from a collaborator grant
access-levels.test.mjs: 5 - a viewer grant still needs its business, project, instance and expiry to cover
access.test.mjs: 3 - LEVELS is the single allowed level set, collaborator and viewer (row 193a)
access.test.mjs: 13 - grants: any level other than collaborator or viewer refuses, naming grants[i].level
access.test.mjs: 20 - covers: * and listed ids match
access.test.mjs: 21 - covers: an unlisted project, instance or business doesn't match
access.test.mjs: 22 - covers: an expired grant doesn't match, at and after the instant
access.test.mjs: 23 - covers: level is the least level asked for (row 193a)
access.test.mjs: 24 - covers: a missing or unknown level throws (row 193a, N1)
access.test.mjs: 25 - covers: a grant with an unknown level covers nothing (row 193a)
KILLED A2 owner added
access-levels.test.mjs: 2 - any other level refuses with the key path named
access.test.mjs: 3 - LEVELS is the single allowed level set, collaborator and viewer (row 193a)
access.test.mjs: 13 - grants: any level other than collaborator or viewer refuses, naming grants[i].level
access.test.mjs: 19 - loadAccess refuses bad JSON and a bad document with exit 2
access.test.mjs: 24 - covers: a missing or unknown level throws (row 193a, N1)
KILLED A3 covers ignores level
access-levels.test.mjs: 3 - covers tells a viewer grant from a collaborator grant
access.test.mjs: 23 - covers: level is the least level asked for (row 193a)
KILLED A4 order inverted (collaborator ask passes a viewer grant)
access-levels.test.mjs: 3 - covers tells a viewer grant from a collaborator grant
access.test.mjs: 20 - covers: * and listed ids match
access.test.mjs: 22 - covers: an expired grant doesn't match, at and after the instant
access.test.mjs: 23 - covers: level is the least level asked for (row 193a)
KILLED A5 level optional again (N1)
access-levels.test.mjs: 4 - covers refuses a call with no level, so a viewer grant can't slip past a collaborator check
access.test.mjs: 24 - covers: a missing or unknown level throws (row 193a, N1)
KILLED A5b level optional, no level means any
access-levels.test.mjs: 4 - covers refuses a call with no level, so a viewer grant can't slip past a collaborator check
access.test.mjs: 24 - covers: a missing or unknown level throws (row 193a, N1)
KILLED A6 unknown grant level guard dropped
access.test.mjs: 25 - covers: a grant with an unknown level covers nothing (row 193a)
killed 7/7; restored: True
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env python3
# Row 204 (193a) mutants on packages/business/src/access.mjs. Run from the row193a worktree root.
import os, shutil, subprocess, sys
SRC = os.path.abspath("packages/business/src/access.mjs")
BAK = "/mnt/storage/scratch/morgana-r204/access.mjs.orig" # any scratch path outside the tree
TESTS = ["tests/access-levels.test.mjs", "tests/access.test.mjs"]
CMP = " if (!LEVELS.includes(grant.level) || LEVELS.indexOf(grant.level) > LEVELS.indexOf(level)) return false;"
MUTANTS = [
("A1 levels collaborator only", 'export const LEVELS = Object.freeze(["collaborator", "viewer"]);', 'export const LEVELS = Object.freeze(["collaborator"]);'),
("A2 owner added", 'export const LEVELS = Object.freeze(["collaborator", "viewer"]);', 'export const LEVELS = Object.freeze(["collaborator", "viewer", "owner"]);'),
("A3 covers ignores level", CMP, " if (!LEVELS.includes(grant.level)) return false;"),
("A4 order inverted (collaborator ask passes a viewer grant)", CMP, CMP.replace("> LEVELS.indexOf(level)", "< LEVELS.indexOf(level)")),
("A5 level optional again (N1)", " if (!LEVELS.includes(level)) throw", " if (level !== undefined && !LEVELS.includes(level)) throw"),
("A5b level optional, no level means any", " if (!LEVELS.includes(level)) throw", " if (level === undefined) level = \"viewer\";\n if (!LEVELS.includes(level)) throw"),
("A6 unknown grant level guard dropped", CMP, " if (LEVELS.indexOf(grant.level) > LEVELS.indexOf(level)) return false;"),
]
shutil.copyfile(SRC, BAK)
orig = open(BAK).read()
if orig != open(SRC).read(): sys.exit("backup mismatch, abort")
killed = 0
try:
for name, a, b in MUTANTS:
if orig.count(a) != 1: sys.exit(f"{name}: anchor count {orig.count(a)}, abort")
open(SRC, "w").write(orig.replace(a, b))
failing = []
for t in TESTS:
p = subprocess.run(["node", "--test", "--test-reporter=tap", t], cwd="packages/business", capture_output=True, text=True, timeout=60)
failing += [f"{t.split('/')[-1]}: {l[7:]}" for l in p.stdout.splitlines() if l.startswith("not ok")]
status = "KILLED" if failing else "SURVIVED"
killed += bool(failing)
print(f"{status} {name}")
for f in failing: print(f" {f}")
finally:
shutil.copyfile(BAK, SRC)
print(f"killed {killed}/{len(MUTANTS)}; restored: {open(SRC).read() == orig}")
+10
View File
@@ -0,0 +1,10 @@
# Quackerjack
Builder seat, Claude Haiku 5.5 at high effort, started by Sage under mos-1023 item 1 and Q177 for federation
rows R5 (`mosaic access list, grant, revoke`) and R2 (the `federation` config key), per Gizmoduck's design
section 7 (#414).
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own
`quackerjack/*` branches through the repository credential helper (mos-0981), never `refactor`, `main` or
another seat's branch.
+36
View File
@@ -0,0 +1,36 @@
# Row 189 gate timing (for row 197, Herb)
Sage, 2026-10-20T19:25:04Z. Gate on 0c177e9ef (base 0217b71c8), one lane. The slot was mine. Seats may have run single test files at the same time, which the slot rules allow, and I did not record that.
Start 2026-10-20T19:09:37Z, end 2026-10-20T19:20:36Z. Total wall time: 659 s.
Wall time per suite is the gap between one suite's log mtime and the one before it (the first suite starts at start.txt), so it includes node start-up. node's duration_ms is listed where the suite prints it. Scripts print no duration.
| Suite | Wall s | duration_ms s | Ended (UTC) |
|---|---|---|---|
| pkg-bus | 3 | 3.8 | 19:09:40 |
| pkg-business | 3 | 2.2 | 19:09:43 |
| pkg-cli | 388 | 388.5 | 19:16:11 |
| pkg-control-board | 1 | 0.8 | 19:16:12 |
| pkg-conversation | 35 | 35.4 | 19:16:47 |
| pkg-discord | 3 | 2.6 | 19:16:50 |
| pkg-harness | 12 | 11.6 | 19:17:02 |
| pkg-ledger | 13 | 13.5 | 19:17:15 |
| pkg-mosaic | 3 | 2.3 | 19:17:18 |
| pkg-plugins | 0 | 0.3 | 19:17:18 |
| pkg-project | 1 | 1.2 | 19:17:19 |
| pkg-runs | 1 | 0.8 | 19:17:20 |
| pkg-seat | 6 | 6.2 | 19:17:26 |
| pkg-tasks | 1 | 1.2 | 19:17:27 |
| pkg-webui | 58 | 57.9 | 19:18:25 |
| script-auth | 1 | - | 19:18:26 |
| script-conductor | 5 | - | 19:18:31 |
| script-config | 2 | - | 19:18:33 |
| script-discord | 8 | - | 19:18:41 |
| script-extension-package | 1 | - | 19:18:42 |
| script-foundation | 40 | - | 19:19:22 |
| script-land-work | 11 | - | 19:19:33 |
| script-queue | 21 | - | 19:19:54 |
| script-release | 11 | - | 19:20:05 |
| script-task | 31 | - | 19:20:36 |
Source: /mnt/storage/scratch/sage-g189/out/*.log mtimes and gate.log. The cli suite is about 59% of the run, and webui plus conversation add about 14%.
+86
View File
@@ -0,0 +1,86 @@
# Row 189: `stack-release.sh activate --no-send`
Gitea #1687. Vikunja P19 #413 (Q175 interim, #412). Brief: docs/plans/2026-10-20_activate-no-send.md.
Rulings: mos-0972 (the Console runs without `--send` until Q175 phase 2), mos-0973 (go; Sage builds,
Dewey reviews; gate is the cli suite plus every scripts/test-*.sh). Base: def2e7932 (refactor, the
brief commit on 9ae8c73ac).
## What it does
`activate ID BUSINESS --no-send` drops `--send` from `RUN_CONSOLE` in a new `send_for_target`, which
`console_for_target` calls after `proof_for_target`. `install_stack` renders the unit from
`RUN_CONSOLE` and `write_pointer` writes the pointer from it, so both lose the flag and `flags_drift`
stays quiet afterwards. Only `activate` takes the flag; the parser sends every other op to usage
(exit 4).
`check_target` lets a `--no-send` activate name the pointer's copy only when the pointer's Console
flags hold `--send`. That run is a live-copy re-pin, the same path row 187 uses for the proof pair:
the plan prints the live-copy note, the run stops both units, backs up the stopped store and starts
them again. A plain same-copy activate, or a same-copy `--no-send` once `--send` is gone, still
refuses `already-active`.
`--plan` prints one `send:` line after the `proof:` line, for activate and rollback:
- `send: dropped; --no-send was given`
- `send: off; --no-send was given and the pointer holds no --send`
- `send: carried` (the pointer's `--send` goes on unchanged)
- `send: off`
The steps log gets `drop --send; --no-send was given`, or `--no-send was given and the pointer holds no
--send, so there is nothing to drop`.
Rollback and pm-on carry the pointer's flags as they are, so once `--no-send` has run, nothing adds
`--send` back. There is no re-enable flag (mos-0973). Q175 phase 2 changes the Console flags in its
own row.
docs/TOOLS.md: `[--no-send]` in the usage block and one paragraph after the proof paragraph.
## Tests (packages/cli/tests/stack-release.test.mjs, T55)
1. Seeded preview pointer with `--send --tester-port 7333`: a same-copy `activate B acme --no-send`
passes. The pointer and the unit ExecStart lack `--send` and keep the other flags in order, status
says the unit flags match, the steps log has the drop line, the log line has from = to = B and the
backup sum equals the stopped store's, and the units stop and start in order.
2. Plain same-copy activate, with `--tester-port` and with `--no-proof`, refuses `already-active`. So does
a same-copy `--no-send` once `--send` is gone, and on a pointer that never held it.
3. The four `send:` lines, the live-copy note on the same-copy plan only, and the Console line right
after `send:` without `--send`. The run on a pointer with no `--send` logs the nothing-to-drop line
and no drop line.
4. After `--no-send` on C1, a rollback to B leaves `--send` out of the pointer and the unit, the steps
log never names `--send`, and status matches. pm-on after `activate --pm-after-harness --no-send`
keeps it off.
5. `--no-send` on rollback (with and without `--plan`), pm-on, status, adopt and rehearse is a usage
error with the usage text, nothing written, started or logged. The usage text shows `[--no-send]`.
6. A pointer holding both the proof pair and `--send`: the plans for `--no-proof`, `--no-send` and both
name what each drops and carries. `--no-send` keeps the pair, `--no-send` with a new pair replaces it,
`--no-proof` keeps `--send`, and both together leave neither. Status matches after each.
`planTail` gains `'send: off'` after `'proof: off'`, because every plan now prints the line. T36 takes
`out.slice(-n)` around `planTail`, so its four slice counts grow by one.
## Mutants
mutants.sh in this packet, run on the candidate. Each mutates scripts/stack-release.sh and runs T55
plus T1 and T2.
- M1: the same-copy exception ignores `--no-send`.
- M2: the same-copy exception allows `--no-send` without `--send` in the pointer.
- M3: with `--no-send`, the pointer is written from `P_CONSOLE`, so it keeps `--send` while the unit
drops it.
- M4: rollback re-adds `--send`.
- M5: the parser accepts `--no-send` for rollback.
Results: mutants-run.log, all five killed (M1 5 failures, M2 2, M3 7, M4 2, M5 2).
## Deviations from the brief
- The brief's line numbers were taken before row 163 landed. The candidate corrects them to the
9ae8c73ac lines (check_target :249-256, flags_drift :646-657, console_for_target :664-693, adopt
:828).
- Test 5 also covers rehearse and `rollback --plan`, which the brief did not list.
## Outbound calls
None. The change edits an array before the existing unit render and pointer write. Every test uses
the stand-in systemctl, units, stores and data root the file already builds; no test touches
`~/.mosaic-dev`, the live units or a slot file.
@@ -0,0 +1,5 @@
M1 KILLED fail=5
M2 KILLED fail=2
M3 KILLED fail=7
M4 KILLED fail=2
M5 KILLED fail=2
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Row 189 mutants M1-M5: each mutates scripts/stack-release.sh in the sage-b189 worktree, runs T55 and
# the plan-tail tests, and restores the file from a checked backup.
set -uo pipefail
WT=/mnt/storage/scratch/sage-b189
OUT=/mnt/storage/scratch/sage-b189-mut/out
S=$WT/scripts/stack-release.sh
mkdir -p "$OUT" || exit 1
cp -p "$S" "$OUT/stack-release.sh.orig" || { echo "backup failed"; exit 1; }
ORIG=$(sha256sum "$S" | cut -d' ' -f1)
[ "$(sha256sum "$OUT/stack-release.sh.orig" | cut -d' ' -f1)" = "$ORIG" ] || { echo "backup differs"; exit 1; }
restore() { cp -p "$OUT/stack-release.sh.orig" "$S"; [ "$(sha256sum "$S" | cut -d' ' -f1)" = "$ORIG" ] || { echo "RESTORE FAILED"; exit 1; }; }
trap restore EXIT
mutate() {
case "$1" in
M1) sed -i '/{ \[ "\$NO_SEND" = 1 \] && \[\[ " \${P_CONSOLE\[\*\]} " == \*" --send "\* \]\]; } ||/d' "$S" ;;
M2) sed -i 's/{ \[ "\$NO_SEND" = 1 \] && \[\[ " \${P_CONSOLE\[\*\]} " == \*" --send "\* \]\]; } ||/{ [ "$NO_SEND" = 1 ]; } ||/' "$S" ;;
M3) sed -i 's/^ write_pointer "\$TO" "\$RUN_PM" "\$RUN_PENDING" "\$P_CLAUDE" "\$T_SCHEMA" "\${RUN_CONSOLE\[@\]}"$/ if [ "$NO_SEND" = 1 ]; then write_pointer "$TO" "$RUN_PM" "$RUN_PENDING" "$P_CLAUDE" "$T_SCHEMA" "${P_CONSOLE[@]}"; else write_pointer "$TO" "$RUN_PM" "$RUN_PENDING" "$P_CLAUDE" "$T_SCHEMA" "${RUN_CONSOLE[@]}"; fi/' "$S" ;;
M4) sed -i 's/^ SEND_PLAN="send: off"$/ SEND_PLAN="send: off"; [ "$OP" != rollback ] || RUN_CONSOLE+=(--send)/' "$S" ;;
M5) sed -i 's/--no-send) \[ "\$OP" = activate \] || usage;/--no-send) { [ "$OP" = activate ] || [ "$OP" = rollback ]; } || usage;/' "$S" ;;
esac
}
for m in M1 M2 M3 M4 M5; do
restore
mutate "$m"
if [ "$(sha256sum "$S" | cut -d' ' -f1)" = "$ORIG" ]; then echo "$m NOT-APPLIED"; continue; fi
bash -n "$S" || { echo "$m SYNTAX"; continue; }
diff "$OUT/stack-release.sh.orig" "$S" > "$OUT/$m.diff"
(cd "$WT" && TMPDIR=/mnt/storage/scratch/t189 timeout 900 node --test --test-reporter=tap --test-name-pattern='T55|T1 |T2 ' packages/cli/tests/stack-release.test.mjs) > "$OUT/$m.log" 2>&1
fails=$(awk '/^# fail /{print $3}' "$OUT/$m.log")
if [ "${fails:-0}" -gt 0 ]; then echo "$m KILLED fail=$fails"; else echo "$m SURVIVED"; fi
done
+1
View File
@@ -1363,3 +1363,4 @@ are never rewritten or removed; corrections are new entries.
2026-10-20T18:41:20Z | Filbert (Opus 5.5, 048f3c63) | Q175 SSO design D1/D2 check (DESIGN.md blob eba861f5d2bb at d55d86bb0), static git diff 817e8f51b..d55d86bb0 | ack: D1 and D2 match; design approved; Mos C1 sequence confirm still open; no Gitea/queue record
2026-10-20T18:57:10Z | Filbert (Opus 5.5, 048f3c63) | row 190 design review r2 (#1688, DESIGN.md sha256 5493c9df3ec0) | CHANGES to Darkwing, cc Sage: B1 claim check on MOSAIC_CONFIG-only selection, B2 shared prefix drop-in dirs; N1-N7 notes; static read, no slot, no Gitea or queue record
2026-10-20T19:00:04Z | Filbert (Opus 5.5, 048f3c63) | row 190 design review r3 (#1688, DESIGN.md sha256 98236a1aff98) | APPROVE to Darkwing, cc Sage: B1 and B2 closed with M13 and M14, N7 settled by Sage's 48ebee0ff; static read, no slot; manifest round gets a queue review record (sage-1337)
2026-10-20T19:57:00Z | Gizmoduck (Opus 5.5, 9312a896) | row 201 FED R1 (#1699, #414) round 1: packages/business/src/access.mjs, index export, tests/access.test.mjs, packet agents/gizmoduck/work/queue-201 | 24/24 single file; 44 mutants written, not run (needs Sage's slot); expiry per sage-1399; to Duckworth for manifest review
+9 -1
View File
@@ -368,7 +368,7 @@ reports checkout or copy identity and refactor drift, and writes nothing.
```
scripts/stack-release.sh adopt BUSINESS
scripts/stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-port N]
[--proof-header NAME --proof-file PATH | --no-proof] [--timeout S] [--plan]
[--proof-header NAME --proof-file PATH | --no-proof] [--no-send] [--timeout S] [--plan]
scripts/stack-release.sh pm-on BUSINESS [--timeout S]
scripts/stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
scripts/stack-release.sh status BUSINESS
@@ -480,6 +480,14 @@ copies. It checks the file with `stat` and `realpath` and never reads the value.
or `off`. On the live copy it also notes that the run re-pins the pointer's copy
and only the Console flags change.
`activate ... --no-send` (row 189, mos-0972) drops `--send` from the pointer's
Console flags and the unit, so the Console serves reads and no human sends. It
may name the live copy while the pointer holds `--send`; once `--send` is gone a
same-copy `--no-send` refuses `already-active`. Later activates, `pm-on` and
rollbacks carry the pointer, so send stays off. No flag puts `--send` back.
`--plan` prints one `send:` line: `dropped`, `off` with or without the
`--no-send` note, or `carried`.
#### A target past c216f47db: the PM after the harness kind
From c216f47db on, the bus launches the PM only with a `harness` kind in the
+6 -5
View File
@@ -1,7 +1,8 @@
# Brief: activate --no-send drops the Console's --send
Sage, 2026-10-20, for Mos's ruling mos-0972 (Q175 interim, Vikunja #412). Owner Sage, reviewer Dewey, gate owner
Sage. Base: refactor 9ae8c73ac (row 163 landed). Line numbers are at that commit.
Sage. Base: refactor 9ae8c73ac (row 163 landed). Line numbers are at that commit. The candidate corrects them;
the first draft took them before row 163.
This row builds the tool only. The live restart without `--send` is a separate step afterwards, a same-copy
activate under Q173 with a backup first and a STATE report after. No test here touches `~/.mosaic-dev`, the
@@ -14,11 +15,11 @@ Mos ruled that the Console runs without `--send` until Q175 phase 2, because wit
process can forge `X-authentik-username` on 127.0.0.1:7332 and send as Jason (design section 8). On the pinned
stack, the Console flags live in the pointer (`~/.mosaic-dev/state/stack-active.json`):
- `console_for_target` (scripts/stack-release.sh:658-687) carries the pointer's flags unchanged. Only
- `console_for_target` (scripts/stack-release.sh:664-693) carries the pointer's flags unchanged. Only
`--tester-port` and the row 187 proof pair can change.
- `check_target` (:247-249) lets an activate name the pointer's copy only for a proof-pair change.
- `adopt` refuses once a pointer exists (:822).
- A hand `webui-service.sh install` without `--send` makes `flags_drift` (:640-651) refuse
- `check_target` (:249-256) lets an activate name the pointer's copy only for a proof-pair change.
- `adopt` refuses once a pointer exists (:828).
- A hand `webui-service.sh install` without `--send` makes `flags_drift` (:646-657) refuse
`unit-flags-drift` on every later activate, pm-on and rollback.
So the tool can't drop `--send` today. This row adds that one path.
@@ -0,0 +1,57 @@
# Brief: a credential store per agent, own git identity, pushes to a remote
Sage, 2026-10-20, for mos-0974 (MVP T1 item 6) and the MVP plan
(`jason-master-restart/projects/mosaic-mvp/PLAN.md` item 6, GAP-MAP G11 and G13; TARGET.md: "credentials store
available for all agents; secrets protected with RLS equivalent" and "Agent uses own credentials for git and
other actions"). Gitea #1697. Owner Megavolt (Opus 5.5, a new builder seat), reviewer Duckworth, gate owner
Sage. Base: refactor at the brief commit. Design first. The first deliverable is
`agents/megavolt/work/queue-ROW/DESIGN.md`.
This row draws a security boundary. Sage reviews the design's authority model, and Mos rules on it before any
code is written.
## What exists today (GAP-MAP, measured at 9ae8c73ac)
- G11. Credential references exist per instance for Gitea and Vikunja only
(`packages/business/src/credentials.mjs`, `packages/bus/src/credentials.mjs`, `vocabulary.mjs:103`). One
Claude env file is shared by every seat.
- G13. Agents use their own Vikunja token (`packages/tasks/src/adapter.mjs`). Git goes through the host
socket (`packages/cli/src/git-socket.mjs`). Gitea tokens are loaded but never used.
- The development repository's helper (`scripts/git-credential-mosaic.mjs`, wired in `.git/config` for
git.mosaicstack.dev) pushes as one identity. Until this row lands, seats push only their own `<seat>/*`
branches through it (mos-0981).
## What the design must settle
1. The store. Where each agent's credentials live, per instance, under the row 190 config canon. File modes,
ownership and the read path. Which process may read which entry, and how the bus enforces that (the "RLS
equivalent"). A process asking for another agent's entry is refused and logged.
2. Kinds. Gitea (git over HTTPS, and the API), Vikunja, and the model provider. Say which kinds 10-27 needs
and which wait. The model provider entry must not copy jwoltje's `claude-session.env`. If it needs a new
provider account or subscription, flag it for Jason and don't pick one.
3. Git identity and push. Each agent commits with its own name and email and pushes with its own token to a
remote it is granted. Which refs an agent may push (its own branches, never the default branch or another
agent's), and where that's enforced: the Gitea side (branch protection, token scopes) and our side. How
this fits the host git socket, or replaces it.
4. Lifecycle. Issue, rotate (`rotateBy`, `expires`), revoke and delete, and what row 194 (agent lifecycle) calls
when an agent is hired or fired. A fired agent's credentials stop working at once.
5. Who does what. Creating a Gitea user or token, or any account outside the fleet, is Jason's (Q173
exclusions). The design lists each such step as a command or UI action for Jason. Agents never mint
accounts.
6. Migration. Today's seats (jarvis helper, seat tokens) keep working until each moves. The mos-0981 push
rule ends only when Mos says so.
7. What this row closes in G11 and G13, and what stays open.
## Boundaries
- No credential value is read, printed, logged or committed. Tests use fake tokens in scratch roots and a
fake Gitea.
- No change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units, slot files, `~/.mosaic` or the live
`.git/config` of the canonical checkout.
- No request to git.mosaicstack.dev, tasks.woltje.com or any provider from a test.
## Gate
Duckworth approves DESIGN.md, Mos rules on the authority model, then Duckworth approves the manifest. The bus,
business, cli and tasks suites and every `scripts/test-*.sh` are green on Sage's gate, and the design's
mutants are killed.
@@ -0,0 +1,100 @@
# Federation build rows R1, R2 and R5 (#414)
Sage, 2026-10-20, for Mos's orders mos-1023 item 1, mos-1028 item 2 and mos-1034 item 1. The design is
Gizmoduck's `agents/gizmoduck/work/federation/DESIGN.md` at a1daa3c07 on `gizmoduck/federation-design` (r4.9,
sha256 a31cbebd070f9e3a5ca4ccb158b1747c6f5a83c3469855705b0026a581fb5c6a). Duckworth approved r4.5
(duckworth-0012), r4.6, r4.7 (duckworth-0020) and r4.8 (duckworth-0021, 8a22d0897 on `duckworth/reviews`),
and acked r4.9 (duckworth-0024). r4.7 applies Jason's Q181 hosts, r4.8 adds N22's R8 checks and r4.9 names who
runs them. None of them changes R1, R2 or R5. Section 7's table
sets each row's scope and test gate. Vikunja P19 #414. Seats push only their own `<seat>/*` branches
(mos-0981).
The other R rows are filed when their parents have candidates: R3 after R1 and row 196, R4 after R3, R5 and
row 193's viewer level (193a, mos-1019), R6 after #412 phase 1, R1, R2 and R3, R7 after R1-R6 and row 190.
R8 is a live window, not a code row. Its brief is the last section here.
## Brief: FED R1, the access.json loader and validator
Owner Gizmoduck, reviewer Duckworth, gate owner Sage. Base: refactor at this brief's commit. No parent.
`packages/business/src/access.mjs`: the access.json loader and validator from DESIGN.md section 3.5, and the
pure `covers`. Providers, peers and businesses come in as arguments. One read plus the file checks. The
exported interface is what R5 and row 193a build against, so the manifest names it, and any change to it in
review goes to Sage before it lands. Section 7 names `covers` but not the loader and validator exports or
the context shape. Morgana's 193a (508ee4d61 on `morgana/row193a`) assumes `checkAccess(doc, {providers,
peers, businesses})`, with `businesses` mapping an id to `{human, instances}`, throwing a `BusinessError` that
names the key path. Use those names, or tell Morgana and Quackerjack yours before the manifest goes to
Duckworth. Row 193a later widens the level check to `viewer` on R1's candidate (mos-1019).
Tests, in `packages/business/tests/`: every rule in 3.5 refuses with the key path named. That covers a
symlink, a non-regular file, another owner, group or other write, expiry, `*`, a grant disjoint from the given
subjects, `human` and `tester` ids, any level other than `collaborator`, and a project entry that isn't a
UUID. For `covers`: `*` and listed ids match, while an unlisted project, instance or business doesn't, an
expired grant doesn't, and a level filter rejects another level. One mutant per refusal, each killed.
Boundaries: no change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or any live access.json. Tests
use scratch roots. Single test files run freely, and suites need Sage's slot.
Gate: Duckworth approves the manifest. The business suite and every `scripts/test-*.sh` are green on Sage's
gate, and the mutants are killed.
## Brief: FED R5, mosaic access list, grant and revoke
Owner Quackerjack, reviewer Duckworth, gate owner Sage. Chained on R1 (Q177). The build starts now against
R1's exported interface as the R1 manifest names it, and R5 lands after R1 and rebases onto R1's landing.
`mosaic access list`, `grant` and `revoke`: human proof, atomic write, revision bump, a receipt line. The help
text states the `instances` limit and the provider-removal order (DESIGN.md section 7, row R5). R4 later makes
these verbs call `access.reload`, so R5 leaves no other reload path.
Tests, in `packages/cli/tests/`: tester and agent callers are refused, a concurrent write refuses on a
revision conflict, the receipt line is appended, and a bad grant is refused before anything is written. One
mutant per refusal, each killed.
Boundaries: no change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or any live access.json. The
human proof in tests uses the scratch stand-in pattern (a scratch bus verifies its own human-cli path).
Single test files run freely, and suites need Sage's slot.
Gate: Duckworth approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate,
and the mutants are killed.
## Brief: FED R2, the federation key in the system config
Owner Quackerjack, reviewer Webby, gate owner Sage. Chained on row 192 (#412 phase 1). The build starts when
row 192 has a candidate, on that candidate.
A `federation` key in `scripts/mosaic-config.mjs` with the shape in DESIGN.md section 3. A config with no
`federation` key still loads as it does today. This changes the config schema, so the manifest lists every
reader of the key.
Tests, in `scripts/test-config.sh`: a valid key loads, an unknown sub-key and a bad origin refuse, and a
config without the key loads. One mutant per refusal, each killed.
Boundaries: `~/.config/mosaic-dev/config.json` is user-authored and is not touched. Tests use scratch config
dirs only.
Gate: Webby approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate, and
the mutants are killed.
## Brief: FED R8, the hub window on 10.1.1.54
A live window, not a code row (DESIGN.md section 7, row R8, r4.9). Run by Jason and Mos. Nothing here
authorizes a step on any host; each step waits for the window's go.
Order (mos-1042 item 2). R8 runs after Bean and Chet are live in Case 2 and both Hermes gateways on 10.1.1.54
are stopped, because R8 requires that the hub host runs no other instance or agent and Q181 keeps Melanie's
Hermes on .54 until then. Case 2 go-live doesn't wait on R8. The risk, stated for 10-27: federation between
the two instances now depends on Melanie's switch-over date.
Entry checks, all before anything starts:
- R7, #412 phase 2, the hub host live (Vikunja #418) and Jason's #419 answer, as section 7 lists.
- The four hub user checks on 10.1.1.54 (N22, mos-1035 item 1): `sudo -l -U mosaic` grants nothing, `id
mosaic` shows only its own group, `~mosaic/.ssh/authorized_keys` is absent or empty, and no file under
`/etc/sudoers.d` names `mosaic`. Jason runs them as localadmin with his new key (#421 step 1), and the output
goes into the R8 record. No factory uid runs them, because R8 requires every factory root path to .54 to
fail (r4.9, mos-1037).
- The factory privilege checks, the key fingerprint comparison and the section 8 privilege path table, each
as section 7's R8 row states them.
Done when every entry check has its output recorded, Jason signs in to the hub and sends one message, Melanie
revokes, and his next request is refused.
@@ -0,0 +1,66 @@
# Row 193 build rows: 193a, 193b1, 193b2, 193c and 193d (#1691)
Sage, 2026-10-20, for Mos's go mos-1034 item 4. Row 193's design is Morgana's
`agents/morgana/work/queue-193/DESIGN.md` at e2d161e2e on `morgana/row193` (r5, sha256
dd7fcb419ed8510e9d8d74b569183386d0942af8b68eecc0bbb5e182e677b523), approved by Duckworth (duckworth-0018).
Section 8 lists the tests and mutants, and section 9 the split. The brief behind them is
`docs/plans/2026-10-20_human-users.md`. Vikunja P19 #414/#415, Gitea #1691.
Owner Morgana for every row, reviewer Duckworth, gate owner Sage. Each row chains on its parent's candidate
(Q177) and rebases onto the parent's landing. Seats push only their own `<seat>/*` branches (mos-0981).
Single test files run freely, and suites and mutant batches need Sage's slot.
Boundaries for every row: no change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units, slot files, any
live access.json or `~/.mosaic`. No request to the live Console, mosaic.woltje.com or auth.diversecanvas.com.
No user thread goes live before 193b1's message filter has landed (mos-1021 B2, FED R8).
The owner-visible records are fixed by Mos (mos-1023 item 2) and named in one user sentence, which FED R6
reuses word for word: "The business owner sees your thread's agent's decisions, inbox entries, claims, tasks
and project files, but not your messages."
## Brief: 193a, the access level check widened to viewer
Chained on FED R1. Started early on `morgana/row193a` (508ee4d61) against R1's interface as the FED brief
names it, with Sage's go (sage-1373). R1's level check accepts `viewer` and still refuses any other new
level. Tests in `packages/business/tests/` (section 8, Business), with the four mutants in Morgana's
BUILD.md. FED R4 chains on this row.
Gate: Duckworth approves the manifest. The business suite and every `scripts/test-*.sh` are green on Sage's
gate, and the mutants are killed.
## Brief: 193b1, viewer verbs, board refusals and the message filter
Chained on FED R3, FED R4 and row 196's verifier. The bus gets the viewer verb list, per-grant level
checks, board reads refused to users, and the section 5.7 `trail` and `replies` filter with `#readable`
owner-only. No schema change. The governance pin test names each item in the user sentence above. Bus tests
and mutants 1, 3, 6, 10, 11 and 12 from section 8.
Gate: Duckworth approves the manifest. The bus suite and every `scripts/test-*.sh` are green on Sage's gate,
and the mutants are killed.
## Brief: 193b2, thread shares (schema 3j)
Chained on 193b1. `thread_shares`, the share verbs, the share rules of `#readable` and `#threadRead`, and the
section 5.5 share filters. Schema 3i to 3j. The table is append-only, and row 181's restore guard and
`COUNTED` include it. Bus tests for shares and the migration, and mutants 2, 4, 7, 8 and 9 from section 8.
Going live needs a `--migrate` window with Jason. Sage takes it to Mos when this row is near.
Gate: Duckworth approves the manifest. The bus suite, the cli suite and every `scripts/test-*.sh` are green on
Sage's gate, and the mutants are killed.
## Brief: 193c, mosaic access user verbs and --level viewer
Chained on FED R5. `mosaic access user add/remove` and `grant --level viewer`, only where R5 lacks them. They
refuse tester and agent callers, write a receipt line and call `access.reload` (section 8, CLI). If R5
already has all of them, this row records that and closes with no code.
Gate: Duckworth approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate,
and the mutants are killed.
## Brief: 193d, the Console's shared list, share controls and viewer read-only
Chained on FED R6, row 192 and 193b2 (for share controls). A viewer has no composer or share control, and
with each UI check removed the bus still refuses (section 8, Webui).
Gate: Duckworth approves the manifest. The webui suite and every `scripts/test-*.sh` are green on Sage's
gate, and the mutants are killed.
+17 -2
View File
@@ -24,11 +24,26 @@ on 7331 now (`ss -ltnp`, 2026-10-20T19:05:09Z), so retiring it costs the least.
Update the docs that tell people to start it. The AGENTS.md text about the control-board reply convention
is a separate matter. Change it only if it tells someone to start 7331.
## Mos's call on the Console dependency (mos-1019)
The inventory (004f133bd) found that the Console proxies `/api/board`, `/api/seen`, `/api/reply` and the
conversation routes to `DEFAULT_BOARD` on 7331 (`packages/webui/src/serve.mjs:12`), so the board view returns
502 today. Mos took Sage's recommendation: retire the 7331 listener and its README start commands, and have
the Console call the board's read code in-process, behind the Console's own auth (Q175 SSO on 7332).
`/api/reply` stays only under `--send`. The DESIGN.md for that change comes before code, and Webby approves
it.
## Acceptance
Nothing in the stack can start a writer on 7331. A test proves it: the entry point refuses (or is gone), no
script or unit starts it, and a grep check finds no starter. Each change gets a mutant that brings the writer
back and the test kills it.
back and the test kills it. Mos added three items (mos-1019):
- The board view answers from the Console with nothing listening on 7331.
- Every board route sits behind the Console's auth.
- A test proves `/api/reply` refuses without `--send`.
Test fixtures that bind port 0 may stay if they only test the code in-process.
## Boundaries
@@ -38,5 +53,5 @@ back and the test kills it.
## Gate
Webby approves INVENTORY.md, then the manifest. The control-board and webui package suites, the cli suite and
Webby approves INVENTORY.md, then DESIGN.md, then the manifest. The control-board and webui package suites, the cli suite and
every `scripts/test-*.sh` are green on Sage's gate, and the mutants are killed.
@@ -0,0 +1,39 @@
# Brief: stack-release.sh activates a Console with --sso (Q175 phase 2 prerequisite)
Sage, 2026-10-20, for Launchpad's finding F3 on row 192 (launchpad-0001) and Q175 (#412). Gitea #1698. Owner
Launchpad, reviewer Webby, gate owner Sage. Chained on row 192's candidate (Q177): it lands after row 192 and
before the phase 2 window. Base: row 192's candidate.
## Why
The phase 2 window re-pins the Console to the phase 1 commit with `--sso --send --tester-port 7333`
(`agents/sage/work/q175-sso/DESIGN.md`, section 5). `scripts/stack-release.sh` doesn't know `--sso` today.
Its `proof_checks` (`:751`) requires `--public-host`, `--auth-header` and `--allow-user` whenever a proof pair is
carried, and the pointer's Console flags still hold the forward-auth pair (`--auth-header X-authentik-username
--allow-user jason.woltje`) that `--sso` refuses to start with. So the window can't run through the script, and
a hand-edited pointer is not an option.
## What the row does
1. `activate ID BUSINESS --sso` writes Console flags with `--sso` and without `--auth-header`, `--allow-user`
and any proof pair, and `--plan` names each flag it drops and carries, in the style of row 189's `send:`
line. The design names the full flag set the phase 2 Console starts with and checks it against row 192's
refusals.
2. Before anything stops, the script refuses a target copy whose Console doesn't support `--sso`, and refuses
when the config's `console` key or its slot file fails the checks row 192's `--sso` start runs. It reads
neither value and only uses the same stat and shape checks.
3. Rollback from an `--sso` pointer to a pre-phase-1 copy follows the design's phase 2 rollback order (remove
the `console` key first). The script refuses a rollback that would start a Console on a config it can't
read, and says which step comes first.
4. `docs/TOOLS.md` gets the flag.
## Boundaries
- No change to the live pointer, units, `~/.mosaic-dev` or `~/.config/mosaic-dev`. Tests use scratch roots
and seeded pointers, as rows 187 and 189 do.
- No request to the live Console, mosaic.woltje.com or auth.diversecanvas.com.
## Gate
Webby approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate, and the
mutants are killed (one per refusal, and one that keeps `--auth-header` in the written flags).
+190
View File
@@ -0,0 +1,190 @@
// The access file, <configDir>/access.json (federation design section 3.5,
// FED R1, #414). It names the extra users who may sign in to this instance
// and the grants that let them reach a business's threads. The validator is
// pure: providers, peers, businesses and the owner's sign-in subjects come in
// as arguments. Any failed rule refuses the whole file, which leaves every
// user with no grant (fail closed).
import { join } from "node:path";
import { refuse } from "./errors.mjs";
import { configDir } from "./business.mjs";
import { isPlainObject, requireObject, rejectUnknownKeys, requireId, requirePositiveInt, readJsonFile, deepFreeze } from "./util.mjs";
// The grant levels, most to least (row 193a, #1691): a viewer reads threads shared with it and drives
// no instance. covers() relies on this order.
export const LEVELS = Object.freeze(["collaborator", "viewer"]);
// Ids a user may never take: the bus's own principals.
const RESERVED_USERS = Object.freeze(["human", "tester"]);
const LOCAL_HOME = "local";
const ALL = "*";
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
const SUB_MAX = 255;
export function accessFilePath(dir = configDir()) {
return join(dir, "access.json");
}
function requireArray(value, where) {
if (!Array.isArray(value)) refuse(`${where} must be an array`);
return value;
}
function requireText(value, where) {
if (typeof value !== "string" || value.length === 0 || value.length > SUB_MAX || value.includes("\0")) {
refuse(`${where} must be a non-empty string of at most ${SUB_MAX} characters`);
}
return value;
}
function subjectKey(provider, sub) {
return JSON.stringify([provider, sub]);
}
function checkContext(context) {
const where = "access context";
requireObject(context, where);
rejectUnknownKeys(context, ["providers", "peers", "businesses", "ssoSubjects"], where);
const providers = new Set(requireArray(context.providers, `${where}.providers`).map((p, i) => requireText(p, `${where}.providers[${i}]`)));
const peers = new Set(requireArray(context.peers, `${where}.peers`).map((p, i) => requireId(p, `${where}.peers[${i}]`)));
if (peers.has(LOCAL_HOME)) refuse(`${where}.peers must not name ${JSON.stringify(LOCAL_HOME)}`);
requireObject(context.businesses, `${where}.businesses`);
const businesses = new Map();
for (const [id, business] of Object.entries(context.businesses)) {
const at = `${where}.businesses.${id}`;
requireId(id, `${where}.businesses key`);
requireObject(business, at);
requireId(business.human, `${at}.human`);
const instances = requireArray(business.instances, `${at}.instances`).map((x, i) => requireId(x, `${at}.instances[${i}]`));
businesses.set(id, { human: business.human, instances: new Set(instances) });
}
const owners = new Set();
requireArray(context.ssoSubjects, `${where}.ssoSubjects`).forEach((s, i) => {
const at = `${where}.ssoSubjects[${i}]`;
requireObject(s, at);
owners.add(subjectKey(requireText(s.provider, `${at}.provider`), requireText(s.sub, `${at}.sub`)));
});
return { providers, peers, businesses, owners };
}
function checkUser(user, at, ctx, humans, seenSubjects) {
requireObject(user, at);
rejectUnknownKeys(user, ["id", "home", "subjects"], at);
requireId(user.id, `${at}.id`);
if (RESERVED_USERS.includes(user.id)) refuse(`${at}.id must not be ${JSON.stringify(user.id)}, a bus principal`);
if (humans.has(user.id)) refuse(`${at}.id must not be a business's human (${JSON.stringify(user.id)})`);
if (user.home !== LOCAL_HOME && !ctx.peers.has(user.home)) {
refuse(`${at}.home must be a configured peer or ${JSON.stringify(LOCAL_HOME)} (got ${JSON.stringify(user.home)})`);
}
const subjects = requireArray(user.subjects, `${at}.subjects`);
if (subjects.length === 0) refuse(`${at}.subjects must not be empty`);
subjects.forEach((s, i) => {
const sat = `${at}.subjects[${i}]`;
requireObject(s, sat);
rejectUnknownKeys(s, ["provider", "sub"], sat);
if (typeof s.provider !== "string" || !ctx.providers.has(s.provider)) {
refuse(`${sat}.provider must be a configured provider (got ${JSON.stringify(s.provider)})`);
}
requireText(s.sub, `${sat}.sub`);
const key = subjectKey(s.provider, s.sub);
if (ctx.owners.has(key)) refuse(`${sat} is an owner sign-in subject (console.sso.subjects)`);
if (seenSubjects.has(key)) refuse(`${sat} appears more than once in the file`);
seenSubjects.add(key);
});
}
// Either ["*"] alone or a non-empty list of distinct entries, each checked.
function checkScope(value, where, check) {
const list = requireArray(value, where);
if (list.length === 0) refuse(`${where} must not be empty`);
if (list.includes(ALL)) {
if (list.length !== 1) refuse(`${where} must be ["*"] alone or list entries without "*"`);
return;
}
const seen = new Set();
list.forEach((entry, i) => {
check(entry, `${where}[${i}]`);
if (seen.has(entry)) refuse(`duplicate entry in ${where}: ${JSON.stringify(entry)}`);
seen.add(entry);
});
}
function checkExpires(value, where) {
if (typeof value !== "string" || !ISO_TIME.test(value) || Number.isNaN(Date.parse(value))) {
refuse(`${where} must be an ISO time such as 2026-12-31T00:00:00Z (got ${JSON.stringify(value)})`);
}
const iso = new Date(value).toISOString();
if (iso.slice(0, 19) !== value.slice(0, 19)) refuse(`${where} is not a real time: ${value}`);
}
function checkGrant(grant, at, ctx, users, seenGrants) {
requireObject(grant, at);
rejectUnknownKeys(grant, ["id", "user", "business", "projects", "instances", "level", "expires"], at);
requireId(grant.id, `${at}.id`);
if (seenGrants.has(grant.id)) refuse(`${at}.id is a duplicate grant id: ${grant.id}`);
seenGrants.add(grant.id);
if (typeof grant.user !== "string" || !users.has(grant.user)) refuse(`${at}.user must name a user in this file (got ${JSON.stringify(grant.user)})`);
if (typeof grant.business !== "string" || !ctx.businesses.has(grant.business)) {
refuse(`${at}.business must name a declared business (got ${JSON.stringify(grant.business)})`);
}
const business = ctx.businesses.get(grant.business);
checkScope(grant.projects, `${at}.projects`, (p, where) => {
if (typeof p !== "string" || !UUID.test(p)) refuse(`${where} must be "*" or a bus project id, a lowercase UUID (got ${JSON.stringify(p)})`);
});
checkScope(grant.instances, `${at}.instances`, (x, where) => {
if (typeof x !== "string" || !business.instances.has(x)) refuse(`${where} must be "*" or a declared instance of ${grant.business} (got ${JSON.stringify(x)})`);
});
if (!LEVELS.includes(grant.level)) refuse(`${at}.level must be one of ${LEVELS.join(", ")} (got ${JSON.stringify(grant.level)})`);
if (grant.expires !== undefined) checkExpires(grant.expires, `${at}.expires`);
}
// Check a parsed access document against the instance's context. Returns the
// document frozen. Every refusal is a BusinessError naming the key path. An
// expired grant is not refused: covers() treats it as absent.
export function checkAccess(document, context) {
const ctx = checkContext(context);
const humans = new Set([...ctx.businesses.values()].map((b) => b.human));
if (!isPlainObject(document)) refuse("access file must be a JSON object");
rejectUnknownKeys(document, ["accessVersion", "revision", "users", "grants"], "access file");
if (document.accessVersion !== 1) refuse(`accessVersion must be 1 (got ${JSON.stringify(document.accessVersion)})`);
requirePositiveInt(document.revision, "revision");
const users = new Set();
const seenSubjects = new Set();
requireArray(document.users, "users").forEach((user, i) => {
checkUser(user, `users[${i}]`, ctx, humans, seenSubjects);
if (users.has(user.id)) refuse(`users[${i}].id is a duplicate user id: ${user.id}`);
users.add(user.id);
});
const seenGrants = new Set();
requireArray(document.grants, "grants").forEach((grant, i) => checkGrant(grant, `grants[${i}]`, ctx, users, seenGrants));
return deepFreeze(structuredClone(document));
}
// Read and check the access file. One descriptor, opened without following a
// link, serves the stat checks and the read (readJsonFile). The file holds
// authority, so it must belong to this uid and not be writable by group or
// other, as the business file is.
export function loadAccess(file, context, { uid = process.getuid() } = {}) {
const document = readJsonFile(file, "access file", (stat) => {
if (stat.uid !== uid) refuse(`access file must belong to uid ${uid}: ${file}`);
if ((stat.mode & 0o022) !== 0) refuse(`access file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)}): ${file}`);
});
return checkAccess(document, context);
}
// Does a checked grant cover a thread? `target` is the thread's business, bus
// project id and instance. `level` is required (row 193a, Duckworth's N1 on R1):
// it is the least level the action needs (LEVELS runs from most to least), so `viewer` asks for
// read and any grant covers it, while `collaborator` asks to drive the instance
// and a viewer grant never covers it. An expired grant covers nothing.
export function covers(grant, { business, project, instance }, now, { level } = {}) {
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError("covers needs a valid Date for now");
if (!LEVELS.includes(level)) throw new TypeError(`covers needs a level, one of ${LEVELS.join(", ")} (got ${JSON.stringify(level)})`);
if (grant.expires !== undefined && now.getTime() >= Date.parse(grant.expires)) return false;
if (grant.business !== business) return false;
if (!(grant.projects[0] === ALL || grant.projects.includes(project))) return false;
if (!(grant.instances[0] === ALL || grant.instances.includes(instance))) return false;
// A grant whose own level is unknown covers nothing, even if it skipped checkAccess.
if (!LEVELS.includes(grant.level) || LEVELS.indexOf(grant.level) > LEVELS.indexOf(level)) return false;
return true;
}
+1
View File
@@ -10,5 +10,6 @@ export { validateRoleDocument, loadRoleFile, loadRole } from "./role.mjs";
export { LAYERS, REGISTRY, THINKING, FALLBACK_VARS, checkVars, mergeVars } from "./vars.mjs";
export { parseCredentialRef, checkCredentialRef } from "./credentials.mjs";
export { configDir, businessFilePath, validateBusinessDocument, loadBusiness } from "./business.mjs";
export { LEVELS, accessFilePath, checkAccess, loadAccess, covers } from "./access.mjs";
export { projectFilePath, validateProjectDocument, loadProject } from "./project.mjs";
export { systemVars, resolveInstance, classify } from "./resolve.mjs";
@@ -0,0 +1,81 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { BusinessError } from "../src/errors.mjs";
import * as access from "../src/access.mjs";
// Row 193a (#1691): the viewer level. R1 (federation design r4.5, section 7) owns access.mjs and its
// tests; this file pins only what row 193 adds: a grant's level is collaborator or viewer, and covers()
// tells the two apart with a required least level. Every call into R1 goes through check() below, so a change to R1's loader
// interface edits one place.
const CONTEXT = {
providers: ["authentik"],
peers: ["factory"],
businesses: { "creative-hub": { human: "melanie", instances: ["researcher", "writer"] } },
ssoSubjects: [],
};
const check = (doc) => access.checkAccess(doc, CONTEXT);
const PROJECT = "0b6f4a52-6f1e-4c7a-9a0e-3f1d2c4b5a69";
const NOW = new Date("2026-10-20T19:30:00Z");
function file(level) {
const grant = { id: "g-1", user: "jason", business: "creative-hub", projects: ["*"], instances: ["researcher"] };
if (level !== undefined) grant.level = level;
return {
accessVersion: 1,
revision: 1,
users: [{ id: "jason", home: "local", subjects: [{ provider: "authentik", sub: "s-1" }] }],
grants: [grant],
};
}
function refuses(fn, pattern) {
assert.throws(fn, (error) => {
assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`);
assert.equal(error.exitCode, 2);
assert.match(error.message, pattern);
return true;
});
}
test("a grant's level is collaborator or viewer", () => {
for (const level of ["collaborator", "viewer"]) {
assert.doesNotThrow(() => check(file(level)), level);
}
});
test("any other level refuses with the key path named", () => {
for (const level of ["owner", "admin", "member", "Viewer", "viewer ", "", null, 1, ["viewer"], undefined]) {
refuses(() => check(file(level)), /grants\[0\]\.level/);
}
});
test("covers tells a viewer grant from a collaborator grant", () => {
const target = { business: "creative-hub", project: PROJECT, instance: "researcher" };
const viewer = file("viewer").grants[0];
const collaborator = file("collaborator").grants[0];
// The level is the least one the action needs: share reads ask viewer; create, open, send and share ask
// collaborator (design 9).
assert.equal(access.covers(viewer, target, NOW, { level: "viewer" }), true);
assert.equal(access.covers(viewer, target, NOW, { level: "collaborator" }), false);
assert.equal(access.covers(collaborator, target, NOW, { level: "viewer" }), true);
assert.equal(access.covers(collaborator, target, NOW, { level: "collaborator" }), true);
});
test("covers refuses a call with no level, so a viewer grant can't slip past a collaborator check", () => {
const target = { business: "creative-hub", project: PROJECT, instance: "researcher" };
const viewer = file("viewer").grants[0];
assert.throws(() => access.covers(viewer, target, NOW), TypeError);
assert.throws(() => access.covers(viewer, target, NOW, {}), TypeError);
});
test("a viewer grant still needs its business, project, instance and expiry to cover", () => {
const viewer = { ...file("viewer").grants[0], projects: [PROJECT], expires: "2026-10-21T00:00:00Z" };
const target = { business: "creative-hub", project: PROJECT, instance: "researcher" };
const read = { level: "viewer" };
assert.equal(access.covers(viewer, target, NOW, read), true);
assert.equal(access.covers(viewer, { ...target, business: "other" }, NOW, read), false);
assert.equal(access.covers(viewer, { ...target, project: "7c1e2d3f-0a9b-4c8d-8e7f-6a5b4c3d2e1f" }, NOW, read), false);
assert.equal(access.covers(viewer, { ...target, instance: "writer" }, NOW, read), false);
assert.equal(access.covers(viewer, target, new Date("2026-10-21T00:00:01Z"), read), false);
});
+300
View File
@@ -0,0 +1,300 @@
// FED R1 (#414, row 201): the access file loader and validator, DESIGN.md
// section 3.5, and covers(). Every test works in a scratch directory.
import { test } from "node:test";
import assert from "node:assert/strict";
import { chmodSync, mkdirSync, symlinkSync } from "node:fs";
import { join } from "node:path";
import { BusinessError } from "../src/errors.mjs";
import { LEVELS, accessFilePath, checkAccess, loadAccess, covers } from "../src/access.mjs";
import { tmp, writeJson } from "./helpers.mjs";
const P1 = "0f8b6c1e-2a3d-4e5f-8a9b-0c1d2e3f4a5b";
const P2 = "11111111-2222-4333-8444-555555555555";
function context(over = {}) {
return {
providers: ["authentik"],
peers: ["factory"],
businesses: {
"creative-hub": { human: "melanie", instances: ["researcher", "writer"] },
acme: { human: "jason", instances: ["coder"] },
},
ssoSubjects: [{ provider: "authentik", sub: "owner-sub" }],
...over,
};
}
function doc() {
return {
accessVersion: 1,
revision: 3,
users: [
{ id: "bean", home: "factory", subjects: [{ provider: "authentik", sub: "s-1" }] },
{ id: "chet", home: "local", subjects: [{ provider: "authentik", sub: "s-2" }] },
],
grants: [
{ id: "g-1", user: "bean", business: "creative-hub", projects: ["*"], instances: ["researcher"], level: "collaborator", expires: "2026-12-31T00:00:00Z" },
{ id: "g-2", user: "chet", business: "creative-hub", projects: [P1], instances: ["*"], level: "collaborator" },
],
};
}
// Refuses with a BusinessError, exit 2 unless given, and a message matching `pattern`.
function refuses(fn, pattern, exitCode = 2) {
assert.throws(fn, (error) => {
assert.ok(error instanceof BusinessError, `not a BusinessError: ${error}`);
assert.equal(error.exitCode, exitCode);
assert.match(error.message, pattern);
return true;
});
}
function mutated(change) {
const d = doc();
change(d);
return d;
}
test("a valid document passes and comes back frozen", () => {
const checked = checkAccess(doc(), context());
assert.deepEqual(checked, doc());
assert.ok(Object.isFrozen(checked));
assert.ok(Object.isFrozen(checked.grants[0].projects));
});
test("checkAccess does not freeze or alias the caller's document", () => {
const d = doc();
const checked = checkAccess(d, context());
assert.ok(!Object.isFrozen(d));
assert.notEqual(checked.grants, d.grants);
});
test("LEVELS is the single allowed level set, collaborator and viewer (row 193a)", () => {
assert.deepEqual([...LEVELS], ["collaborator", "viewer"]);
assert.ok(Object.isFrozen(LEVELS));
});
test("accessFilePath is access.json in the config directory", () => {
assert.equal(accessFilePath("/x/y"), join("/x/y", "access.json"));
const dir = tmp();
const saved = process.env.MOSAIC_CONFIG;
process.env.MOSAIC_CONFIG = join(dir, "config.json");
try {
assert.equal(accessFilePath(), join(dir, "access.json"));
} finally {
if (saved === undefined) delete process.env.MOSAIC_CONFIG;
else process.env.MOSAIC_CONFIG = saved;
}
});
test("top level: object, unknown keys, accessVersion, revision, arrays", () => {
refuses(() => checkAccess([], context()), /access file must be a JSON object/);
refuses(() => checkAccess(mutated((d) => { d.extra = 1; }), context()), /unsupported access file key: "extra"/);
refuses(() => checkAccess(mutated((d) => { d.accessVersion = 2; }), context()), /accessVersion must be 1/);
refuses(() => checkAccess(mutated((d) => { delete d.accessVersion; }), context()), /accessVersion must be 1/);
for (const bad of [0, -1, 1.5, "3", undefined]) {
refuses(() => checkAccess(mutated((d) => { d.revision = bad; }), context()), /revision must be a positive integer/);
}
refuses(() => checkAccess(mutated((d) => { d.users = {}; }), context()), /^users must be an array/);
refuses(() => checkAccess(mutated((d) => { delete d.grants; }), context()), /^grants must be an array/);
});
test("users: unknown keys and the id rule", () => {
refuses(() => checkAccess(mutated((d) => { d.users[1].role = "x"; }), context()), /unsupported users\[1\] key: "role"/);
refuses(() => checkAccess(mutated((d) => { d.users[0] = "bean"; }), context()), /users\[0\] must be a JSON object/);
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "Bean"; }), context()), /users\[0\]\.id must match/);
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "bean"; }), context()), /users\[1\]\.id is a duplicate user id: bean/);
});
test("users: human, tester and any business's human are refused", () => {
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "human"; d.grants = []; }), context()), /users\[0\]\.id must not be "human"/);
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "tester"; d.grants = []; }), context()), /users\[1\]\.id must not be "tester"/);
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "melanie"; d.grants = []; }), context()), /users\[0\]\.id must not be a business's human \("melanie"\)/);
// The human of a business the grant doesn't name is refused too.
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "jason"; d.grants = []; }), context()), /users\[1\]\.id must not be a business's human \("jason"\)/);
});
test("users: home is a given peer or local", () => {
refuses(() => checkAccess(mutated((d) => { d.users[0].home = "elsewhere"; }), context()), /users\[0\]\.home must be a configured peer or "local" \(got "elsewhere"\)/);
refuses(() => checkAccess(mutated((d) => { delete d.users[1].home; }), context()), /users\[1\]\.home must be a configured peer/);
refuses(() => checkAccess(doc(), context({ peers: [] })), /users\[0\]\.home must be a configured peer/);
});
test("users: subjects, provider, sub, uniqueness and owner subjects", () => {
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects = []; }), context()), /users\[0\]\.subjects must not be empty/);
refuses(() => checkAccess(mutated((d) => { delete d.users[0].subjects; }), context()), /users\[0\]\.subjects must be an array/);
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].extra = 1; }), context()), /unsupported users\[0\]\.subjects\[0\] key: "extra"/);
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].provider = "google"; }), context()), /users\[1\]\.subjects\[0\]\.provider must be a configured provider \(got "google"\)/);
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].sub = ""; }), context()), /users\[0\]\.subjects\[0\]\.sub must be a non-empty string/);
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].sub = 7; }), context()), /users\[0\]\.subjects\[0\]\.sub must be a non-empty string/);
// A pair twice in one user, and across two users.
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects.push({ provider: "authentik", sub: "s-1" }); }), context()), /users\[0\]\.subjects\[1\] appears more than once/);
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].sub = "s-1"; }), context()), /users\[1\]\.subjects\[0\] appears more than once/);
// The owner's own sign-in subject can't be an extra user.
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].sub = "owner-sub"; }), context()), /users\[1\]\.subjects\[0\] is an owner sign-in subject/);
// The same sub under another provider is a different pair.
const twoProviders = context({ providers: ["authentik", "google"] });
assert.ok(checkAccess(mutated((d) => { d.users[1].subjects[0] = { provider: "google", sub: "owner-sub" }; }), twoProviders));
});
test("grants: unknown keys, id, user and business", () => {
refuses(() => checkAccess(mutated((d) => { d.grants[0].role = "x"; }), context()), /unsupported grants\[0\] key: "role"/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].id = "g-1"; }), context()), /grants\[1\]\.id is a duplicate grant id: g-1/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].id = "G 1"; }), context()), /grants\[0\]\.id must match/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].user = "nobody"; }), context()), /grants\[0\]\.user must name a user in this file \(got "nobody"\)/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].business = "other"; }), context()), /grants\[1\]\.business must name a declared business \(got "other"\)/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].business = "constructor"; }), context()), /grants\[1\]\.business must name a declared business/);
});
test("grants: projects is [\"*\"] or distinct lowercase UUIDs", () => {
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["stack"]; }), context()), /grants\[1\]\.projects\[0\] must be "\*" or a bus project id, a lowercase UUID \(got "stack"\)/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1.toUpperCase()]; }), context()), /grants\[1\]\.projects\[0\] must be "\*" or a bus project id/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1, 7]; }), context()), /grants\[1\]\.projects\[1\] must be "\*" or a bus project id/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["*", P1]; }), context()), /grants\[1\]\.projects must be \["\*"\] alone/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1, "*"]; }), context()), /grants\[1\]\.projects must be \["\*"\] alone/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["*", "*"]; }), context()), /grants\[1\]\.projects must be \["\*"\] alone/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1, P1]; }), context()), /duplicate entry in grants\[1\]\.projects/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = []; }), context()), /grants\[1\]\.projects must not be empty/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = "*"; }), context()), /grants\[1\]\.projects must be an array/);
assert.ok(checkAccess(mutated((d) => { d.grants[1].projects = [P1, P2]; }), context()));
});
test("grants: instances is [\"*\"] or distinct declared instances of that business", () => {
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["coder"]; }), context()), /grants\[0\]\.instances\[0\] must be "\*" or a declared instance of creative-hub \(got "coder"\)/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["*", "writer"]; }), context()), /grants\[0\]\.instances must be \["\*"\] alone/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["writer", "writer"]; }), context()), /duplicate entry in grants\[0\]\.instances/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = []; }), context()), /grants\[0\]\.instances must not be empty/);
assert.ok(checkAccess(mutated((d) => { d.grants[0].instances = ["researcher", "writer"]; }), context()));
});
test("grants: any level other than collaborator or viewer refuses, naming grants[i].level", () => {
for (const bad of ["owner", "Collaborator", "COLLABORATOR", "Viewer", "", null, 1]) {
refuses(() => checkAccess(mutated((d) => { d.grants[1].level = bad; }), context()), /grants\[1\]\.level must be one of collaborator, viewer/);
}
refuses(() => checkAccess(mutated((d) => { delete d.grants[0].level; }), context()), /grants\[0\]\.level must be one of collaborator, viewer \(got undefined\)/);
});
test("grants: expires is optional ISO time, and a past expiry is not refused", () => {
for (const bad of ["2026-12-31", "2026-12-31 00:00:00Z", "2026-12-31T00:00:00+01:00", "tomorrow", 1798675200000, null]) {
refuses(() => checkAccess(mutated((d) => { d.grants[0].expires = bad; }), context()), /grants\[0\]\.expires must be an ISO time/);
}
refuses(() => checkAccess(mutated((d) => { d.grants[0].expires = "2026-02-30T00:00:00Z"; }), context()), /grants\[0\]\.expires is not a real time/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].expires = "2026-13-01T00:00:00Z"; }), context()), /grants\[0\]\.expires must be an ISO time/);
assert.ok(checkAccess(mutated((d) => { d.grants[0].expires = "2026-12-31T00:00:00.500Z"; }), context()));
// DESIGN 3.5: a grant past expires is treated as absent, not refused (sage-1399).
assert.ok(checkAccess(mutated((d) => { d.grants[0].expires = "2001-01-01T00:00:00Z"; }), context()));
});
test("the context is required and checked", () => {
refuses(() => checkAccess(doc()), /access context must be a JSON object/);
for (const key of ["providers", "peers", "ssoSubjects"]) {
const c = context();
delete c[key];
refuses(() => checkAccess(doc(), c), new RegExp(`access context\\.${key} must be an array`));
}
refuses(() => checkAccess(doc(), context({ businesses: [] })), /access context\.businesses must be a JSON object/);
refuses(() => checkAccess(doc(), context({ extra: 1 })), /unsupported access context key: "extra"/);
refuses(() => checkAccess(doc(), context({ peers: ["local"] })), /access context\.peers must not name "local"/);
refuses(() => checkAccess(doc(), context({ ssoSubjects: [{ provider: "authentik" }] })), /access context\.ssoSubjects\[0\]\.sub must be a non-empty string/);
refuses(() => checkAccess(doc(), context({ businesses: { acme: { human: "jason" } } })), /access context\.businesses\.acme\.instances must be an array/);
});
// loadAccess: the file checks, then checkAccess.
test("loadAccess reads a valid file and checks it", () => {
const file = writeJson(join(tmp(), "access.json"), doc());
assert.deepEqual(loadAccess(file, context()), doc());
});
test("loadAccess refuses a missing file, a symlink and a directory with exit 4", () => {
const dir = tmp();
refuses(() => loadAccess(join(dir, "access.json"), context()), /access file not found/, 4);
const real = writeJson(join(dir, "real.json"), doc());
const link = join(dir, "access.json");
symlinkSync(real, link);
refuses(() => loadAccess(link, context()), /access file must be a regular, non-symbolic-link file/, 4);
const sub = join(dir, "d");
mkdirSync(sub);
refuses(() => loadAccess(sub, context()), /access file must be a regular, non-symbolic-link file/, 4);
});
test("loadAccess refuses another owner and group or other write", () => {
const file = writeJson(join(tmp(), "access.json"), doc());
refuses(() => loadAccess(file, context(), { uid: process.getuid() + 1 }), /access file must belong to uid/);
for (const mode of [0o620, 0o602, 0o666]) {
chmodSync(file, mode);
refuses(() => loadAccess(file, context()), /access file must not be writable by group or other/);
}
chmodSync(file, 0o644);
assert.ok(loadAccess(file, context()));
});
test("loadAccess refuses bad JSON and a bad document with exit 2", () => {
const dir = tmp();
refuses(() => loadAccess(writeJson(join(dir, "a.json"), "{"), context()), /access file is not valid JSON/);
refuses(() => loadAccess(writeJson(join(dir, "b.json"), mutated((d) => { d.grants[0].level = "owner"; })), context()), /grants\[0\]\.level/);
});
// covers.
const NOW = new Date("2026-10-20T12:00:00Z");
const target = (over = {}) => ({ business: "creative-hub", project: P1, instance: "researcher", ...over });
// Any level reads: the least level is viewer (row 193a).
const READ = { level: "viewer" };
test("covers: * and listed ids match", () => {
const [g1, g2] = checkAccess(doc(), context()).grants;
assert.equal(covers(g1, target({ project: P2 }), NOW, READ), true);
assert.equal(covers(g2, target({ instance: "writer" }), NOW, READ), true);
assert.equal(covers(g2, target(), NOW, READ), true);
});
test("covers: an unlisted project, instance or business doesn't match", () => {
const [g1, g2] = checkAccess(doc(), context()).grants;
assert.equal(covers(g2, target({ project: P2 }), NOW, READ), false);
assert.equal(covers(g1, target({ instance: "writer" }), NOW, READ), false);
assert.equal(covers(g2, target({ business: "acme" }), NOW, READ), false);
assert.equal(covers(g1, target({ business: "acme" }), NOW, READ), false);
});
test("covers: an expired grant doesn't match, at and after the instant", () => {
const [g1, g2] = checkAccess(doc(), context()).grants;
assert.equal(covers(g1, target(), new Date("2026-12-30T23:59:59.999Z"), READ), true);
assert.equal(covers(g1, target(), new Date("2026-12-31T00:00:00Z"), READ), false);
assert.equal(covers(g1, target(), new Date("2027-01-01T00:00:00Z"), READ), false);
// No expires never expires.
assert.equal(covers(g2, target(), new Date("2099-01-01T00:00:00Z"), READ), true);
});
test("covers: level is the least level asked for (row 193a)", () => {
const g = checkAccess(doc(), context()).grants[0];
const v = { ...g, level: "viewer" };
assert.equal(covers(g, target(), NOW, { level: "collaborator" }), true);
assert.equal(covers(g, target(), NOW, READ), true);
assert.equal(covers(v, target(), NOW, READ), true);
assert.equal(covers(v, target(), NOW, { level: "collaborator" }), false);
});
test("covers: a missing or unknown level throws (row 193a, N1)", () => {
const v = { ...checkAccess(doc(), context()).grants[0], level: "viewer" };
assert.throws(() => covers(v, target(), NOW), TypeError);
assert.throws(() => covers(v, target(), NOW, {}), TypeError);
assert.throws(() => covers(v, target(), NOW, { level: undefined }), TypeError);
for (const bad of ["owner", "Viewer", "", null, 1]) {
assert.throws(() => covers(v, target(), NOW, { level: bad }), /covers needs a level, one of collaborator, viewer/);
}
});
test("covers: a grant with an unknown level covers nothing (row 193a)", () => {
const g = checkAccess(doc(), context()).grants[0];
for (const bad of ["owner", "Collaborator", undefined]) {
assert.equal(covers({ ...g, level: bad }, target(), NOW, READ), false);
}
});
test("covers: now must be a valid Date", () => {
const g = checkAccess(doc(), context()).grants[0];
assert.throws(() => covers(g, target(), Date.now(), READ), TypeError);
assert.throws(() => covers(g, target(), new Date("nope"), READ), TypeError);
});
+112 -5
View File
@@ -381,7 +381,7 @@ async function seed(e,{copy=B,pm=false,store='3g',pointer:how='adopt'}={}){
const rolledBack=(code,to=B)=>`stack-release: ${code}; rolled back to ${to}, which passed the gate\n`;
const planBackup=(e)=>`backup: the run backs up the stopped store to ${e.data}/backups/stack/<activation>/ before any migration`;
const planNoBackup='backup: none; the run rolls back before the backup';
const planTail=(e,to,pm=false)=>[`bus: scripts/bus-service.sh install --copy ${to} --copies ${COPIES}${pm?' --pm':''} --claude ${e.claude}`,'proof: off',`Console: scripts/webui-service.sh install --copy ${to} --copies ${COPIES} --port ${e.port} --business acme`,`CLI: scripts/stack-copy.sh wrapper ${to} --copies ${COPIES} --bin ${e.bin}`];
const planTail=(e,to,pm=false)=>[`bus: scripts/bus-service.sh install --copy ${to} --copies ${COPIES}${pm?' --pm':''} --claude ${e.claude}`,'proof: off','send: off',`Console: scripts/webui-service.sh install --copy ${to} --copies ${COPIES} --port ${e.port} --business acme`,`CLI: scripts/stack-copy.sh wrapper ${to} --copies ${COPIES} --bin ${e.bin}`];
// --- The T table ---
@@ -1030,17 +1030,17 @@ test('T36 --plan shows the pending state and lists the row 169 refusals it can s
return r.out.split('\n');
};
let out=await quiet(0,'activate',C1,'acme','--pm-after-harness','--plan');
assert.deepEqual(out.slice(-6),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,C1),'']);
assert.deepEqual(out.slice(-7),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,C1),'']);
biz(e,HARNESS);
out=await quiet(3,'activate',A,'acme','--pm-after-harness','--plan');
assert.deepEqual(out.slice(-7),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,A),`refuses unknown-kind-for-target: the business file names plug-in kind(s) harness, which ${A} does not know`,'']);
assert.deepEqual(out.slice(-8),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,A),`refuses unknown-kind-for-target: the business file names plug-in kind(s) harness, which ${A} does not know`,'']);
out=await quiet(3,'activate',C1,'acme','--pm-after-harness','--plan');
assert.equal(out.at(-2),'refuses pm-after-harness-set: the business file already sets harness ["claude-code"]; use --pm');
biz(e);assert.equal((await run(e,'activate',C1,'acme','--pm-after-harness')).code,0);
out=await quiet(3,'activate',C,'acme','--plan');
assert.deepEqual(out.slice(-7),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,C,true),`refuses pm-pending: ${PENDING_MSG}`,'']);
assert.deepEqual(out.slice(-8),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,C,true),`refuses pm-pending: ${PENDING_MSG}`,'']);
out=await quiet(0,'rollback','acme','--plan');
assert.deepEqual(out.slice(-6),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,B,true),'']);
assert.deepEqual(out.slice(-7),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,B,true),'']);
});
// Row 176 test 11: supported and older Console copies deliberately differ from the checkout.
@@ -1706,6 +1706,113 @@ test('T54 row 188: a carried pair gets the proof file checks, and a proof file i
});
});
// Row 189 (#1687, mos-0972/0973): activate --no-send drops the pointer's --send; nothing turns it back on.
const sendLines=(out)=>out.filter((l)=>l.startsWith('send:'));
const noSend=(c)=>c.filter((x)=>x!=='--send');
const LIVE_NOTE=`note: ${B} is the pointer's copy; the run re-pins it and only the Console flags change`;
test('T55 row 189: activate --no-send drops --send, and rollback and pm-on carry it off',async(t)=>{
await t.test('test 1: a live-copy --no-send drops --send from the pointer and the unit, keeps the rest in order, logs the step and backs up the stopped store',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']),want=noSend(base);
assert.deepEqual(want.slice(-2),['--tester-port','7333']);
const sum=createHash('sha256').update(readFileSync(e.store)).digest('hex'),n=calls(e).length;
let r=await prun(e,'activate',B,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
assert.equal(pointer(e).copy,B);assert.deepEqual(pointer(e).console,want);
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));assert.doesNotMatch(conExec(e),/ --send( |$)/);
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
assert.match(stepText(e),/Z drop --send; --no-send was given\n/);
const act=pointer(e).activation,l=logLines(e).at(-1);
assert.deepEqual([l.op,l.from,l.to,l.result,l.backup],['activate',B,B,'ok',act]);assert.equal(backupSum(e,act),sum);
assert.deepEqual(startsStops(e,n),[`stop ${CON}`,`stop ${BUS}`,`start ${BUS}`,`start ${CON}`]);
await noLeak(e);
});
await t.test('test 2: a plain live-copy activate still refuses already-active, and so does --no-send once --send is gone',async(t)=>{
const e=await mk(t);await seedPreview(e,undefined,['--send']);
await refused(e,['activate',B,'acme'],'already-active',`${B} is the pointer's copy`);
await refused(e,['activate',B,'acme','--tester-port','7333'],'already-active',`${B} is the pointer's copy`);
await refused(e,['activate',B,'acme','--no-proof'],'already-active',`${B} is the pointer's copy`);
const r=await prun(e,'activate',B,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
await refused(e,['activate',B,'acme','--no-send'],'already-active',`${B} is the pointer's copy`);
const g=await mk(t);await seedPreview(g);
await refused(g,['activate',B,'acme','--no-send'],'already-active',`${B} is the pointer's copy`);
for(const x of [e,g])await noLeak(x);
});
await t.test('test 3: --plan prints one send: line in each case, with the live-copy note on a same-copy run',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send']);
let out=await planOnly(e,0,'activate',B,'acme','--no-send');
assert.deepEqual(sendLines(out),['send: dropped; --no-send was given']);
assert.deepEqual(out.filter((l)=>l.startsWith('note: ')),[LIVE_NOTE]);
assert.equal(out[out.indexOf('send: dropped; --no-send was given')+1],`Console: scripts/webui-service.sh install --copy ${B} --copies ${COPIES} ${noSend(base).join(' ')}`);
out=await planOnly(e,0,'activate',C1,'acme');
assert.deepEqual(sendLines(out),['send: carried']);assert.deepEqual(out.filter((l)=>l.startsWith('note: ')),[]);
assert.ok(out.find((l)=>l.startsWith('Console: ')).endsWith(' --send'));
assert.deepEqual(sendLines(await planOnly(e,0,'activate',C1,'acme','--no-send')),['send: dropped; --no-send was given']);
let r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,noSend(base));assert.match(stepText(e),/Z drop --send; --no-send was given\n/);
assert.deepEqual(sendLines(await planOnly(e,0,'activate',B,'acme','--no-send')),['send: off; --no-send was given and the pointer holds no --send']);
assert.deepEqual(sendLines(await planOnly(e,0,'activate',B,'acme')),['send: off']);
assert.deepEqual(sendLines(await planOnly(e,0,'rollback','acme')),['send: off']);
r=await prun(e,'activate',B,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
assert.match(stepText(e),/Z --no-send was given and the pointer holds no --send, so there is nothing to drop\n/);
assert.doesNotMatch(stepText(e),/drop --send/);
await noLeak(e);
});
await t.test('test 4: after --no-send, a rollback to the earlier copy leaves --send out of the pointer and the unit',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send']);
let r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
assert.equal(pointer(e).copy,B);assert.deepEqual(pointer(e).console,noSend(base));assert.doesNotMatch(conExec(e),/ --send( |$)/);
assert.doesNotMatch(stepText(e),/--send/);
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
await noLeak(e);
});
await t.test('test 4: pm-on after --no-send keeps --send off',async(t)=>{
const e=await mk(t),base=await seedPreview(e,{pm:true},['--send']);
let r=await prun(e,'activate',C1,'acme','--pm-after-harness','--no-send');assert.equal(r.code,0,r.out+r.err);
biz(e,HARNESS);
r=await prun(e,'pm-on','acme');assert.equal(r.code,0,r.out+r.err);
assert.equal(pointer(e).pm,true);assert.deepEqual(pointer(e).console,noSend(base));assert.doesNotMatch(conExec(e),/ --send( |$)/);
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
await noLeak(e);
});
await t.test('test 5: --no-send on rollback, pm-on, status, adopt or rehearse is a usage error',async(t)=>{
const e=await mk(t);await seedPreview(e,undefined,['--send']);
const usageText=(await prun(e,'--help')).err,n=calls(e).length,logs=logLines(e).length,before=fp(e);
for(const args of [['rollback','acme','--no-send'],['rollback','acme','--plan','--no-send'],['pm-on','acme','--no-send'],['status','acme','--no-send'],['adopt','acme','--no-send'],
['rehearse','acme','--backup','/tmp/x','--port','7400','--no-send']]){
const r=await prun(e,...args);assert.equal(r.code,4,args.join(' '));assert.equal(r.out,'');assert.equal(r.err,usageText);
}
assert.match(usageText,/\| --no-proof\] \[--no-send\]/);
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
await noLeak(e);
});
await t.test('test 6: with both a pair and --send in the pointer, --no-send and --no-proof each drop what they name and keep the rest',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send']),pre=noSend(base),f=await proofFile(e),g=await proofFile(e,'proof2.189',0o400);
let r=await prun(e,'activate',B,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,[...pre,...pair(f),'--send']);
let out=await planOnly(e,0,'activate',C1,'acme','--no-proof');
assert.deepEqual([...proofLines(out),...sendLines(out)],['proof: dropped; --no-proof drops header x-console-proof','send: carried']);
out=await planOnly(e,0,'activate',C1,'acme','--no-send');
assert.deepEqual([...proofLines(out),...sendLines(out)],['proof: carried, header x-console-proof','send: dropped; --no-send was given']);
out=await planOnly(e,0,'activate',B,'acme','--no-send','--no-proof');
assert.deepEqual([...proofLines(out),...sendLines(out)],['proof: dropped; --no-proof drops header x-console-proof','send: dropped; --no-send was given']);
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,[...pre,...pair(f)]);
r=await prun(e,'activate',C1,'acme','--no-send',...pair(g,'X-Proof-Two'));assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,[...pre,...pair(g,'X-Proof-Two')]);
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
const h=await mk(t),hbase=await seedPreview(h,undefined,['--send']),hpre=noSend(hbase),k=await proofFile(h);
r=await prun(h,'activate',B,'acme',...pair(k));assert.equal(r.code,0,r.out+r.err);
r=await prun(h,'activate',C1,'acme','--no-proof');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(h).console,[...hpre,'--send']);
r=await prun(h,'activate',C1,'acme','--no-send','--no-proof');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(h).console,hpre);assert.doesNotMatch(conExec(h),/--proof-| --send( |$)/);
const steps=stepText(h);
assert.match(steps,/Z --no-proof was given and the pointer holds no proof pair, so there is nothing to drop\n/);assert.match(steps,/Z drop --send; --no-send was given\n/);
r=await prun(h,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
for(const x of [e,h])await noLeak(x);
});
});
// --- rehearse (D2, row 163; the brief's "Stack rehearsal on a scratch root") ---
// Copies of their own in RCOPIES: the real scripts/mosaic, config loader, business loader, roles and
// mosaic-config.mjs, with stand-ins for the bus host (cli.mjs), the Console, the Store and
+38 -6
View File
@@ -3,7 +3,7 @@
# Moves the bus, the Console and the human CLI from the pointer's copy to another copy together,
# behind a health gate, and puts the old stack back inside the same run when the gate fails.
# Console tester listener flags follow target support (row 176, #1674); so does the row 178 proof
# pair, which activate sets or drops (row 187, #1685).
# pair, which activate sets or drops (row 187, #1685). activate --no-send drops --send (row 189, #1687).
# Design and tests: agents/darkwing/work/queue-162/BUILD.md.
#
# Who runs it. Decision 96 (Mos, mos-0716) lets this script stop and start the live units under
@@ -30,13 +30,13 @@ BIN="$HOME/.npm-global/bin"
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
CONSOLE_UNIT=mosaic-webui.service
TIMEOUT=60 WANT_PM=0 AFTER_HARNESS=0 MIGRATE=0 PLAN=0 RESTORE="" EVENTS="" TESTER_PORT=""
PROOF_HEADER="" PROOF_FILE="" NO_PROOF=0 R_BACKUP="" R_PORT=""
PROOF_HEADER="" PROOF_FILE="" NO_PROOF=0 NO_SEND=0 R_BACKUP="" R_PORT=""
usage() {
cat >&2 <<'USAGE'
usage: stack-release.sh adopt BUSINESS
stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-port N]
[--proof-header NAME --proof-file PATH | --no-proof] [--timeout S] [--plan]
[--proof-header NAME --proof-file PATH | --no-proof] [--no-send] [--timeout S] [--plan]
stack-release.sh pm-on BUSINESS [--timeout S]
stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
stack-release.sh status BUSINESS
@@ -85,6 +85,7 @@ parse_opts() {
--proof-header) [ "$OP" = activate ] && [ $# -ge 2 ] && [ -n "$2" ] || usage; PROOF_HEADER="$2"; shift 2 ;;
--proof-file) [ "$OP" = activate ] && [ $# -ge 2 ] && [ -n "$2" ] || usage; PROOF_FILE="$2"; shift 2 ;;
--no-proof) [ "$OP" = activate ] || usage; NO_PROOF=1; shift ;;
--no-send) [ "$OP" = activate ] || usage; NO_SEND=1; shift ;;
--migrate) [ "$OP" = activate ] || usage; MIGRATE=1; shift ;;
--restore) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]] || usage; RESTORE="$2"; shift 2 ;;
--backup) [ "$OP" = rehearse ] && [ $# -ge 2 ] && { [[ "$2" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]] || [[ "$2" =~ ^/[A-Za-z0-9/._+-]*$ ]]; } || usage; R_BACKUP="$2"; shift 2 ;;
@@ -244,14 +245,16 @@ target_schema() {
in_chain() { local v; for v in "${T_CHAIN[@]}"; do [ "$v" != "$1" ] || return 0; done; return 1; }
# The item 3 checks on the target copy, in order: copy, schema, already-active. An activate that
# sets a proof pair, or drops the pointer's pair with --no-proof, may name the pointer's copy: it
# re-pins the live copy with new Console flags (row 187, Part B's B3 and B-R1).
# sets a proof pair, drops the pointer's pair with --no-proof, or drops the pointer's --send with
# --no-send, may name the pointer's copy: it re-pins the live copy with new Console flags (row 187,
# Part B's B3 and B-R1; row 189, mos-0972).
check_target() {
copy_exists "$TO" 2>/dev/null || refuse copy-missing "no copy $TO in $COPIES"
copy_ok "$TO" 2>/dev/null || refuse copy-unreachable "$TO is not reachable from refactor and origin/refactor"
target_schema "$TO" || refuse copy-schema-unknown "cannot read SCHEMA_VERSION and its chain from $COPIES/$TO/packages/bus/src/store.mjs"
[ "$TO" != "$P_COPY" ] || [ -n "$PROOF_HEADER" ] ||
{ [ "$NO_PROOF" = 1 ] && [[ " ${P_CONSOLE[*]} " == *" --proof-header "* ]]; } ||
{ [ "$NO_SEND" = 1 ] && [[ " ${P_CONSOLE[*]} " == *" --send "* ]]; } ||
refuse already-active "$TO is the pointer's copy"
}
@@ -690,6 +693,33 @@ console_for_target() {
DROP_TESTER="--tester-port $inherited"
fi
proof_for_target
send_for_target
}
# Row 189 (mos-0972): --no-send drops --send from RUN_CONSOLE, so the unit and the pointer both lose
# it; without the flag the pointer's --send goes on unchanged. Rollback and pm-on never take it, so
# they carry the pointer as it is. Sets SEND_PLAN (the plan's send: line) and SEND_STEP.
send_for_target() {
local had=0 i
local -a without_send=()
for ((i = 0; i < ${#RUN_CONSOLE[@]}; i++)); do
if [ "${RUN_CONSOLE[i]}" = --send ]; then had=1; else without_send+=("${RUN_CONSOLE[i]}"); fi
done
SEND_STEP=""
if [ "$NO_SEND" = 1 ]; then
RUN_CONSOLE=("${without_send[@]}")
if [ "$had" = 1 ]; then
SEND_PLAN="send: dropped; --no-send was given"
SEND_STEP="drop --send; --no-send was given"
else
SEND_PLAN="send: off; --no-send was given and the pointer holds no --send"
SEND_STEP="--no-send was given and the pointer holds no --send, so there is nothing to drop"
fi
elif [ "$had" = 1 ]; then
SEND_PLAN="send: carried"
else
SEND_PLAN="send: off"
fi
}
# Row 187: the row 178 proof pair in RUN_CONSOLE. A new pair replaces the pointer's and goes last;
@@ -943,6 +973,7 @@ plan() {
echo "bus: scripts/bus-service.sh install --copy $TO --copies $COPIES$([ "$RUN_PM" = 0 ] || echo ' --pm') --claude $P_CLAUDE"
[ -z "$DROP_TESTER" ] || echo "drop $DROP_TESTER; the target Console does not support it"
echo "$PROOF_PLAN"
echo "$SEND_PLAN"
echo "Console: scripts/webui-service.sh install --copy $TO --copies $COPIES${RUN_CONSOLE[*]:+ ${RUN_CONSOLE[*]}}"
echo "CLI: scripts/stack-copy.sh wrapper $TO --copies $COPIES --bin $BIN"
local r
@@ -956,6 +987,7 @@ run_steps() {
capture || refuse capture-failed "could not write the rollback set $RS; nothing was stopped"
[ -z "$DROP_TESTER" ] || say "drop $DROP_TESTER; the target Console does not support it"
[ -z "$PROOF_STEP" ] || say "$PROOF_STEP"
[ -z "$SEND_STEP" ] || say "$SEND_STEP"
trap 'after_fail signal' INT TERM HUP
stop_both || rollback_in_run stop-timeout
if [ -e "$STORE" ]; then
@@ -1478,7 +1510,7 @@ STORE="$DATA/bus/bus.sqlite"
BUS_UNIT="mosaic-bus@$BUSINESS.service"
FROM="" TO="" ACT="" RS="" POST="" BACKUP="" TAKEN="" RESTORED="" STEPS="" COUNT="" UNCLEAN="" FAIL=""
STORE_SCHEMA="" START_EPOCH="" START_MS=0 GATE="" GATE_WHY="" S_CHANGED=""
P_COPY="" P_PM=0 P_PENDING=0 P_SCHEMA="" P_CLAUDE="" P_CONSOLE=() RUN_PM=0 RUN_PENDING=0 RUN_CONSOLE=() DROP_TESTER="" PROOF_PLAN="" PROOF_STEP="" PLAN_REFUSE=()
P_COPY="" P_PM=0 P_PENDING=0 P_SCHEMA="" P_CLAUDE="" P_CONSOLE=() RUN_PM=0 RUN_PENDING=0 RUN_CONSOLE=() DROP_TESTER="" PROOF_PLAN="" PROOF_STEP="" SEND_PLAN="" SEND_STEP="" PLAN_REFUSE=()
R="" R_RC=0 R_EARLY=() R_BK="" R_HOST_PID="" R_CONSOLE_PID="" R_STARTED="" LIVE_CFG="" LIVE_BIZ=""
case "$OP" in
adopt) do_adopt ;;