Compare commits

..
20 changed files with 489 additions and 710 deletions
-3
View File
@@ -19,9 +19,6 @@ not fleet seats. Keep changes in `/mnt/storage/src/mosaic-stack`; do not modify
| Launchpad | Builder, MVP T1 row 192 (Q175 SSO phase 1) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
| Morgana | Builder, MVP T1 row 193 (human users and RBAC) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
| Honker | Builder, MVP T1 row 194 (agent lifecycle) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
| 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 |
| 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` |
-8
View File
@@ -1,8 +0,0 @@
# Bushroot
Builder seat, Claude Haiku 5.5, started by Sage under mos-1008 item 2 (a low-level model under Q177) for the row
that retires the control board writer on 7331 (#1696).
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 `bushroot/*`
branches through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
-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.
+1 -1
View File
@@ -3,5 +3,5 @@
Reviewer seat, Claude Opus 5.5, started by Mos (mos-0977, thread ffceafec). Reviews federation designs and MVP T1 row 193.
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 `duckworth/*` branches
credentials of its own; until row 195 settles per-agent identity, it pushes only its own `duckworth/*` branches
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
-8
View File
@@ -1,8 +0,0 @@
# Gander
Builder seat, Codex gpt-6.1-sol, started by Sage under mos-0977 item 5 (more Codex Sol rows) for the human
boundary row (P19 333, #1694, mos-0989).
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 `gander/*`
branches through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
-8
View File
@@ -1,8 +0,0 @@
# Herb
Builder seat, Codex gpt-6.1-sol, started by Sage under mos-0977 item 5 (more Codex Sol rows) for a second gate lane
design row (#1695, mos-0977 item 6).
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 `herb/*`
branches through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
+1 -1
View File
@@ -3,5 +3,5 @@
Builder seat, Claude Opus 5.5, started by Sage for MVP T1 row 194 (agent lifecycle, #1692) under mos-0974 and mos-0977.
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 `honker/*` branches
credentials of its own; until row 195 settles per-agent identity, it pushes only its own `honker/*` branches
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
+1 -1
View File
@@ -3,5 +3,5 @@
Builder seat, Claude Opus 5.5, started by Sage for MVP T1 row 192 (Q175 SSO phase 1, #1690) under mos-0974 and mos-0977.
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 `launchpad/*` branches
credentials of its own; until row 195 settles per-agent identity, it pushes only its own `launchpad/*` branches
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
+1 -1
View File
@@ -3,5 +3,5 @@
Builder seat, Claude Opus 5.5, started by Sage for MVP T1 row 193 (human users and RBAC, #1691) under mos-0974 and mos-0977.
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 `morgana/*` branches
credentials of its own; until row 195 settles per-agent identity, it pushes only its own `morgana/*` branches
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
+482
View File
@@ -0,0 +1,482 @@
# Row 193 design: many humans per business, human roles, private and shared threads
Morgana (Opus 5.5), 2026-10-20. Revision 5: sage-1373 item 1 (mos-1023 item 2), the user sentence names every record the owner sees. Revision 4: duckworth-0011 B3 and N5. Revision 3: duckworth-0008 B1, B2 and N1-N4, sage-1368. Revision 2: sage-1354 (5.7 private from the owner, 193b split) and gizmoduck-0018 (coverage and the R4 predicate in FED r4.1). Gitea #1691, queue row 193, brief `docs/plans/2026-10-20_human-users.md`
(blob f7472d1e47b1). Reviewer Duckworth, gate owner Sage. Base: `refactor` at 53bb59462.
Read for this design: COMMON.md (53bb59462), the brief, Gizmoduck's federation design r4.4 (b67bc6de9 on
gizmoduck/federation-design, cited as FED; r3.1 a2840a6be was read first), Duckworth's federation reviews (b402197ff, 03a663cd7,
45cbe5896 on duckworth/reviews), Sage's Q175 SSO design (`agents/sage/work/q175-sso/DESIGN.md`), the row 192
and row 196 briefs, Darkwing's row 190 packet (uncommitted, sha256 5493c9df3ec0...), and the bus, business,
cli and webui code at the lines cited. Messages: morgana-0001 to 0003, sage-1338, gizmoduck-0009,
gander-0002 and gander-0005.
## 1. Summary
- Three human roles: **owner**, **collaborator**, **viewer**. The owner is the business file's one `human`,
as today. Collaborators and viewers are access.json users, the same store federation uses (FED 3.5).
Their role is the `level` of a grant, so a role is per project and instance, not per person.
- Every human principal is minted by one bus verifier, owned by row 196 (Gander). Row 193 states what it
needs from that verifier (section 6) and adds no second mechanism.
- Threads stay private to their owner by default. A thread owner may share a thread, read-only, with a
named user, with the owner, or with everyone granted on it. Shares live in a new append-only bus table,
`thread_shares`. This is a deviation from brief point 4, agreed by Sage (sage-1338), for Duckworth to rule
on (section 5.1).
- A share never widens a grant. It counts only while the reader's live grant covers the thread's project
and instance, and while the thread's owner still has coverage. Revocation, expiry and unsharing take
effect on the next request.
- Existing businesses keep working unchanged: every existing thread is already owned by the business
human, and an instance without access.json has exactly one human, the owner.
- The build splits so the part without a schema change can land first. `thread_shares` needs schema 3j and
a `--migrate` window with Jason.
## 2. What exists today (measured at 53bb59462)
- One `human` per business (`packages/business/src/business.mjs:160`). `bindHuman` refuses any other name
(`packages/bus/src/broker.mjs:532-538`).
- The owner's CLI proof is path-bound (#225): `verifyHuman` checks uid, start time, nonce
(`MOSAIC_BUS_CLI_NONCE`), `argv[1]` equal to the tree's `human-cli.mjs`, then walks parents and refuses
agent markers, launch records and agent binaries (`packages/bus/src/human.mjs:64-96`).
`authenticateHuman` binds the proof to `businesses[business].human` (`packages/bus/src/runtime.mjs:175-182`).
- Threads: `threads(seq,id,at,business,project,instance,owner,name)`, append-only
(`packages/bus/schema.sql:247`, triggers at :310-312). `#owner(s)` returns `tester` or `s.human`
(broker.mjs:364-366). `#thread` refuses a non-owner with `thread-not-owned` (:357-362).
- Thread verbs are gated `if (!s.tester) this.#human(s)`: `project.list` (:2318), `thread.create` (:2325),
`thread.list` (:2351, owner filter), `thread.open` (:2365), `thread.send` (:2374, writes `human.input`
for the human only), `thread.messages` (:2401).
- Board reads are business-wide and human or reader only: `inbox`, `agents`, `tasks`, `trail` (:2143-2167),
`decisions` (:2174), `credentials` (:2195), `messages` (:2217, `to_role='human'`), `replies` (:2422).
`trail` and `replies` return any message in the business by id (:2679-2700, :2422-2432).
- The Console reads threads by polling the same verbs (`packages/webui/src/chat.mjs:22-26`). It has no
stream, so a refusal on the next poll ends access.
- The launcher restarts a thread with pending messages as the business human
(`packages/cli/src/launcher.mjs:533-542`).
## 3. The human model (brief point 1)
### 3.1 Who the humans are
| Human | Where it is declared | How many | Identity |
|---|---|---|---|
| owner | business file `human` (unchanged) | exactly one per business | the business human's name |
| user (collaborator or viewer) | access.json `users[]` with `home: "local"` (or a peer id under federation) | any number | `user:<id>` |
| tester | not a human; row 177 test principal | one | `tester` |
The owner stays outside access.json (FED 9 Q2). A business has no second owner and no admin level. Adding
one is out of scope; section 9 lists it as a follow-up for Jason.
### 3.2 How a sign-in or a call becomes exactly one human
| Entry | Becomes | Checked by |
|---|---|---|
| Console SSO session whose `(provider, sub)` is in `console.sso.providers[].subjects` (row 192) | the owner | the Console after OIDC checks, then the bus verifier (row 196) |
| Console SSO session whose `(provider, sub)` is a `users[].subjects` entry in access.json with a live grant | that one user | same |
| Console SSO session matching neither | nobody: refused | the Console, and the bus refuses any later claim |
| CLI call through `human-cli.mjs` that passes the human proof | the owner | the bus (`verifyHuman`) |
| Any other process, including an agent | nobody | the bus |
Why each is exactly one:
- The R1 loader refuses a `(provider, sub)` pair that appears twice in access.json or also in
`console.sso.subjects` (FED 3.5). So one subject maps to at most one human.
- A user id can't be `human`, `tester` or any business's human (FED 3.5). So a user can't be named as the
owner.
- A terminal maps only to the owner. The only identity the bus can verify for a local process is the
instance's OS user, and that is the owner's. A user has no CLI path in row 193. This follows Mos's B1 rule
(mos-0985): the principal comes from what the bus verifies, never the process's own claim.
- The bus never reads a user name from a request body as identity. The verifier returns the principal
(section 6).
## 4. Human roles (brief point 2)
### 4.1 The three roles
- **owner**: everything the business human does today. Only the owner restructures the organization
(business file writer, project and role changes) and only the owner manages users and grants.
- **collaborator**: FED 3.5's level, unchanged. Creates threads in granted projects on granted instances,
sends into and opens its own threads, and in row 193 also shares its own threads.
- **viewer**: new. Reads threads shared with it, within its grant. It creates, opens and sends nothing, so
it never drives an instance. FED 3.5 notes that a collaborator's message drives the instance with the
owner's role authority (F10). A viewer has none of that.
The word "collaborator" stays, so FED's tests and help text need no rename. The brief's example "member" is
this level.
A role is the `level` on a grant, and a grant is per business, project set and instance set. A user may
hold a collaborator grant on one project and a viewer grant on another. Every check asks "does a live
grant of a level that allows this verb cover this project and instance". A user has no single role.
### 4.2 R1 widening
FED R1 refuses any level but `collaborator`, because the 10-27 slice ships only that (FED 9 Q2).
Row 193 widens R1's level check to `collaborator` or `viewer`, with its own test that each is accepted and
anything else refuses. Nothing else in the access file changes. If R1 has not landed when row 193 builds,
this one-line widening goes into the row 193a candidate chained on R1's candidate (Q177).
### 4.3 Bus verbs per role
Every verb not in a user's column fails `access-refused` before its own checks, as FED 3.5 requires for
collaborators. That covers every owner verb, every board read, `message.*`, `decision.*`, project files,
`spec.read`, `assistant.stop`, `launch.*`, `name.set`, `usage.*` and `access.reload`.
| Verb | owner | collaborator | viewer |
|---|---|---|---|
| `project.list` | all | projects covered by any live grant | projects covered by any live grant |
| `thread.create` | yes | collaborator grant covers project and instance | refused |
| `thread.list` | own threads | own threads still covered | own threads still covered (from an earlier collaborator grant) |
| `thread.list {shared: true}` (new) | threads shared with the owner | threads shared with me and readable (5.4) | same |
| `thread.open` | own | own, collaborator grant still covers | refused |
| `thread.send` | own | own, collaborator grant still covers, no `human.input` | refused |
| `thread.messages` | own, or shared with the owner | own (any level still covering), or shared and readable | same as collaborator |
| `thread.share`, `thread.unshare` (new) | own threads | own threads, collaborator grant still covers | refused |
| `thread.shares` (new) | own threads | own threads | refused |
| `names` | all | instances covered by any live grant | instances covered by any live grant |
| every other verb | as today | refused | refused |
Notes:
- `thread.list` keeps its response shape exactly. Shared threads come only from `thread.list {shared: true}`,
whose rows add `owner`. The Console and existing tests read the plain list unchanged.
- A viewer can still read its own threads when a collaborator grant is lowered to viewer, but can't send
or open them. This is the downgrade path.
- The tester keeps its row 177 verbs. It can't share or be shared with: `thread.share`, `thread.unshare`
and `thread.shares` fail `tester-refused`, and a share target is never `tester`. Agent testers (Q173) test
sharing through the bus suites, not the live 7333 listener.
- `reader` sessions (Console read caps, broker.mjs:1303) are the owner's board view and stay owner-only.
A user never gets a reader session.
### 4.4 Console per role
- owner: as today, plus a share control on each own thread and a "Shared with me" list.
- collaborator: the chat view only, for granted projects (FED R6's granted-user sign-in), plus share
controls on its own threads and "Shared with me". No board.
- viewer: "Shared with me" and its own old threads, read-only. No composer, no open, no share control.
- The Console hides controls a role can't use, but the bus check is authoritative. A Console bug can't
widen a grant (FED 3.6). Each role gets a webui test with the UI check removed (mutant) that the bus still
refuses.
### 4.5 Who manages humans and roles
Only the owner, through `mosaic access` (FED R5), which checks the human proof and refuses tester and agent
callers. Row 193 needs from R5:
- `mosaic access user add <id> --home local --subject <provider>:<sub>` and `user remove <id>`;
- `--level viewer` on `grant`;
- the receipt line in `<dataRoot>/state/access-log.jsonl` and the `access.reload` call, as FED R5 has.
If R5 has no `user` verbs when row 193 builds, row 193c adds them on R5's candidate. The Console gets no
user or grant writes in row 193: a write from the Console would need a new owner verb that writes
access.json from the bus, and access.json is a config file only the owner's CLI writes. A read-only users
page for the owner is a follow-up (section 9).
The owner keeps every restructuring power (TARGET Case 1). Users can't touch the business file, projects,
roles or access.json.
## 5. Private and shared threads (brief point 3)
### 5.1 Deviation from brief point 4, agreed by Sage (sage-1338)
Brief point 4 says access.json is the one store. Users, roles and grants live there. Per-thread shares do
not, for three reasons (morgana-0002):
- F1. Thread ids are bus-generated UUIDs. A hand-typed share with a typo would fail the loader, and the
loader refuses the whole file on any failure, which revokes every user.
- F2. Only the owner writes access.json. A collaborator could never share its own thread.
- F3. Threads are append-only bus rows, and a share is a runtime act by a principal that wants a trail.
So shares go in an append-only bus table, gated by the grant. Sage agreed (sage-1338). Gizmoduck had no
objection (gizmoduck-0009) and recorded it as FED 9 Q2. Duckworth rules on it in this review.
### 5.2 Schema 3j
```sql
CREATE TABLE thread_shares (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
op TEXT NOT NULL CHECK (op IN ('share','unshare')),
target TEXT NOT NULL,
by TEXT NOT NULL
) STRICT;
CREATE INDEX thread_shares_thread ON thread_shares (thread, target, seq);
-- no_update, no_delete and no_replace triggers, as thread_opens has (schema.sql:319-321).
```
- `target` is an owner string: `user:<id>`, the business human's name, or `*`. A share whose target is a
name that is no longer the business's `human` (after a rename) is void: rule 1 in 5.4 compares against
the current owner string, so it matches nobody (Duckworth N4).
- `by` is the sharer's owner string, which is always the thread's owner.
- A share's state is its latest row per `(thread, target)` (Duckworth N12, REVIEW-r3.md:74-76). Shares
are not assumed to only grow.
- No existing row changes. Existing threads are already owned by the business human (`#owner` at
broker.mjs:364-366), so they are the owner's and private. The brief's "all become the current human's"
is already true.
The migration `3i-3j.sql` creates the table, index and triggers. `thread_shares` joins every table list that
holds timed append-only tables: `timedTables` in store.mjs (:60-74), the broker's clock list
(broker.mjs:223-235), the restore guard's counted tables (row 181) and `COUNTED` in
`scripts/stack-release.sh:315`. The schema digest for 3j is pinned as the other versions are.
### 5.3 Verbs
- `thread.share {thread, to}`: the caller must own the thread (`thread-not-owned`). For a user, a
collaborator grant must still cover the thread. `to` is `user:<id>`, the business human's name or `*`.
A `user:<id>` target must have a live grant, of any level, covering the thread's project and instance,
else `share-not-covered`. Sharing with yourself fails `invalid-request`. A share that is already in force
fails `share-exists`. Writes one `share` row.
- `thread.unshare {thread, to}`: the caller must own the thread. The share must be in force, else
`share-not-found`. Writes one `unshare` row. Unsharing the owner's or `*` shares works the same way.
- `thread.shares {thread}`: the caller must own the thread. Returns the shares in force, latest row each.
- Both writes go through `refuse()` on failure, so each refusal records `action.refused` with `by` set
(FED 3.6 item 6).
### 5.4 The read check, one function
`#readable(s, t)` decides whether a session may read thread `t` it doesn't own. True only when all hold:
1. a share for `t` is in force whose target is the caller's owner string, or `*`;
2. the caller is the owner, or the caller has a live grant of any level covering `t.project` and
`t.instance`;
3. if `t.owner` is a user, that user still has a live grant covering `t` (a revoked owner hides the
thread, sage-1338);
4. `t.owner` is not `tester`.
`#thread(s, id)` keeps its owner check for `thread.open`, `thread.send`, `thread.share`, `thread.unshare`
and `thread.shares`. A new `#threadRead(s, id)` admits the owner or `#readable`, and serves only
`thread.messages`. A sharee fails `thread-not-owned` on every write verb.
### 5.5 Every read path and its filter
| Read path | Today | After row 193 |
|---|---|---|
| `thread.list` | `owner=?` | unchanged for the owner; for users also each row's thread still covered |
| `thread.list {shared: true}` | none | rows where `#readable` holds |
| `thread.messages` | `#thread` | `#threadRead` |
| `thread.open`, `thread.send` | `#thread` | `#thread`, plus a collaborator grant for users |
| `project.list`, `names` | business-wide | per FED 3.5 for users, unchanged for the owner |
| `trail`, `replies` | human, reader or tester; any message by id | users refused (`access-refused`); for the others, messages in other users' unshared threads hidden (5.7) |
| `messages`, `inbox`, `agents`, `tasks`, `decisions`, `credentials` | human or reader | users refused (`access-refused`); owner unchanged. A user-thread run can't send to the human (`user-thread-human`, FED 3.6), so `messages` holds none of a user's thread |
| `message.receive` by a run | its own thread (:1933) | unchanged: runs read their own thread, never a share |
| `threadsState` (launcher) | all threads | adds `revoked` per 5.6 |
### 5.6 Revocation, expiry and unsharing
Gizmoduck asked that a grant loss end access to threads shared with that user (gizmoduck-0009 point 2).
- A sharee holds no claim and no run. Its access is only reads, and every read runs `#readable` against the
access state the broker already refreshes on each request (mtime, size, inode), on its 30 s timer, and on
`access.reload` (FED 3.7). So a removed, expired or invalid grant ends share reads on the next request,
with nothing to tear down. The Console polls, so a revoked sharee's next poll fails.
- An unshare row ends access on the next request in the same way.
- A user who loses coverage of their own thread also loses its shares: rule 3 in 5.4.
The downgrade case. With viewer, "no live grant covers" would be wrong for runs: a viewer grant covers the
thread but must not let a run continue. FED puts this in R4: `revoked`, claim end and the launch check call
`covers` with level `collaborator`, so a downgrade to viewer ends the run within one launcher tick. FED r4.4
(b67bc6de9, mos-1020) chains R4 after row 193's viewer level (193a) and keeps the level-loss test and its
mutant in R4. Row 193 carries neither (Duckworth N3), so the mutant has one owner.
### 5.7 What "private" means, stated plainly
Sage ruled (sage-1354): a user's thread is private from the owner on the bus too. Jason's words are "private
agent sessions per user", and in Case 2 the owner of Melanie's hub is not the user whose thread it is.
Duckworth's B1 asked where the line sits, since a user-thread run acts with the owner's role authority (F10).
I took Duckworth's option (a): **the user's messages are private, and the run's governance is not.** The
run spends the owner's authority, so its decisions, task writes and claims are the owner's to see and
answer. Filtering them too (option b) would need a thread link on `decisions`, more schema, and would hide
from the owner what is done in the owner's name.
Every read an owner or reader session has, and its side of the line:
| Read | Shows | Side |
|---|---|---|
| `thread.list`, `thread.messages`, `thread.open` | own threads only, plus shares to the owner from 193b2 | private: other users' threads never appear |
| `trail` | events, messages, decisions and deliveries by subject | messages filtered (below); decision rows and their events stay visible |
| `replies` | messages replying to an id | filtered (below) |
| `messages`, `message.receive`, `message.read` | messages `to_role='human'` (broker.mjs:1931-1936, :1953) | private by construction: a user-thread run can't send to the human (`user-thread-human`, FED 3.6 item 7) |
| `inbox` | open decisions routed to the human | governance, visible |
| `decisions {list: roles \| recent}` | decision rows with question, options and recommendation | governance, visible |
| `agents` | role claims and each held role's state | governance, visible |
| `tasks`, `state` | tracker tasks and launch state | governance, visible |
| `credentials`, `usage` | credential state, usage records | governance, visible; neither holds message text |
| `project.files`, `project.file` | project files, including records a run writes with `record.write` (broker.mjs:1395-1407, :1441-1450) | governance, visible |
| `project.list`, `names` | projects and instance names | business-wide for the owner, visible |
So a decision a user-thread run raises shows in the owner's `inbox` or `decisions`, with the free text the
run wrote. Its role claims show in `agents`, its task writes in `tasks`, and a record it writes in
`project.file`. Any of these can quote the user, and 5.7 doesn't call them private. The users' help text
(R5) and the Console's share and thread pages say so in one sentence, which FED R6 reuses (mos-1023 item 2,
duckworth-0012 N21):
> The business owner sees your thread's agent's decisions, inbox entries, claims, tasks and project files,
> but not your messages.
The gated-raise route. A user-thread run's uncited gated raise or hard wait routes to the human (broker.mjs:1677-1680).
FED r4.5 (acd5f1f42, B9) settled this with no guard: the tester guard at :1683 (`tester-thread-human`) is not
copied, and the decision reaches the owner's inbox and no other (sage-1368, mos-1021, mos-1022). FED R3 tests it.
Row 193 adds nothing here.
The message filter. A message is hidden from a session when it is in a thread (`message_threads`) whose owner
is a `user:<id>` other than the caller, and `#readable(s, t)` is false. Threads owned by the business human or
by `tester` are not affected, so row 177's behaviour is unchanged.
- `trail`: drops hidden messages, their `deliveries` rows, and `events` rows whose `subject` is a hidden
message id. A `subject` that is itself a hidden message returns `message-not-found`, as an unknown id does,
so the reply doesn't confirm the id exists.
- `replies {id}`: a hidden `id` fails `message-not-found`, and hidden messages are dropped from the result.
- It applies to every session that can call these verbs: the owner, reader sessions and the tester.
- It lands in 193b1, with no schema (Duckworth B2). Before 193b2 there are no shares, so `#readable` is false
for anyone but the thread's owner and b1 hides every message in another user's thread. 193b2 adds only the
share exception. So no build order can put a user thread live without the filter.
What it does not do. The owner holds the store as the instance's OS user and can read it with sqlite. That is
the same-uid limit in section 6, not a bus path. An owner audit verb, explicit and logged, is a follow-up row
only if Jason asks for one (sage-1354).
## 6. The bus human boundary (brief point 5)
Row 196 (Gander, #1694, brief `docs/plans/2026-10-20_human-boundary.md` at 96f243faf) owns P19 333 and the
verifier. Row 193 cites it and designs no boundary of its own (sage-1338: one mechanism).
Agreed with Gander (gander-0002, morgana-0003, gander-0005):
- One bus verifier, `verifyPrincipal(request, connection) -> {kind, business, id}`, kind `owner`, `user` or
`tester`. It mints one transient principal per request. It returns no role. The broker reads the level
from the live grant on every request, so a grant change acts without a new principal.
- Gander recommends FED 3.6's shape for users (path-bound `user-cli.mjs`, direct parent the bus tree's
`packages/webui/src/cli.mjs serve`) plus kernel peer PID binding, and weighs bus-side ID-token
verification as the larger alternative.
- Correction from Gander, accepted: the tester proof doesn't run the full agent walk, because agents bind
tester by design (Q173). Managed runs still fail it. The tester listener moves from TCP 7333 to a private
Unix socket, with `bus.tester` unchanged (agreed by Sage with Gander).
What row 193 needs from row 196:
- H1. Owner on the CLI keeps today's proof. Owner in the Console is a `console.sso.subjects` entry.
- H2. A user principal comes only from an OIDC sign-in, through a path the bus verifies. No CLI path for
users.
- H3. The user binding fails closed on no live grant, an unknown subject, a subject also in
`console.sso.subjects`, or a missing or invalid access.json. A broken access.json never stops the owner.
- H4. Agent processes never get an owner or user principal.
- H5. Revocation ends a bound user within 30 s and at once on `access.reload`, from the same access state
row 193's share check reads.
The same-uid limit. Within one instance, a non-agent process on the instance's uid can claim the owner, and
can start its own Console copy and claim any granted user (FED 3.6). An unsandboxed same-uid agent that
detaches from its ancestry can do the same. The walk doesn't stop that, and Gander's design won't claim it
does (gander-0005). Several humans don't make this worse: a user principal is no stronger than the owner
principal that the same process could already take. Users themselves are not on the uid. They reach the
bus only through the Console, so the limit is about local processes, not other humans.
Does row 190 narrow it? Row 190 runs several instances under one uid, so no. Row 190b puts an instance (the
hub) under its own Linux user (mos-0986). That narrows reach across instances: another instance's uid fails
the uid check (human.mjs:67) and can't read the hub's store or access.json. It does not narrow reach within
one instance. Private threads are private from other humans using the bus, not from processes running as
the instance's uid.
## 7. Migration and rollback (brief point 6)
- No access.json: the business has exactly its owner, every verb behaves as today, and the share verbs
work for the owner only (target `*` or none). A broken access.json revokes users and leaves the owner.
- Existing threads: already owned by the business human. Nothing is rewritten.
- Schema: 3i to 3j adds `thread_shares` only. A re-pin past a schema bump needs a `--migrate` window with
Jason, and row 185's backup before activate covers it.
- Rollback before any share is written: activate the previous copy with the store restored from the
pre-activate backup. A 3i copy refuses a 3j store, as it does for every bump.
- Rollback after shares are written: the same restore loses shares made since the window. Grants are in
access.json and survive. Users re-share. Row 181's restore guard counts `thread_shares`, so the loss is
shown, not silent.
- To keep that window small, the build splits (section 9): 193a, 193b1, 193c and 193d have no schema
change and can go live in any window. 193b2 carries 3j.
## 8. Tests and mutants
Bus, each in `packages/bus/tests/` (193b1: viewer, mixed grants, board reads, the 5.7 message filter and governance pin; 193b2: shares, the share exception to the filter, migration):
- Viewer: each allowed verb works, every other verb refuses `access-refused`, including `thread.create`,
`thread.open`, `thread.send` and `thread.share`.
- Mixed grants: collaborator on project A and viewer on B. Create in A works, create in B refuses.
- Downgrade: not here. FED R4 owns the level-loss test and its mutant (r4.4, mos-1020, N3).
- Shares: owner shares with `user:<id>`, the user reads `thread.messages` and gets `thread-not-owned` on
`thread.send` and `thread.open`. Share with `*` reaches every covered user and the owner. Share with a user
whose grant doesn't cover the project or instance refuses `share-not-covered`.
- A share never widens: the target's grant drops the project, the next read refuses.
- Revoke, expiry and an invalid file each end share reads on the next request. The 30 s timer and
`access.reload` paths each have a case.
- A revoked thread owner hides the thread from sharees.
- Unshare ends access. Share, unshare, share again leaves it shared (latest row wins).
- Non-owner `thread.share`, `thread.unshare`, `thread.shares` refuse. Tester refuses on all three.
- `thread.list` plain shape unchanged. `thread.list {shared: true}` returns only readable threads.
- Board reads refuse users and viewers.
- Governance pin (B1, sage-1368): a user-thread run raises a decision; it shows in the owner's
`decisions recent` with its question text, and `inbox` lists it when routed to the human; the run's
role claim shows in the owner's `agents`, its task write in `tasks`, and its `record.write` in
`project.file` (B3, sage-1373); the user's message bodies stay hidden from the owner's `trail` and
`replies`. The test names each item of the 5.7 user sentence, so the sentence and the pin match.
- `trail`, for the owner, a reader session and the tester (193b1): a user's thread message as `subject`
fails `message-not-found`; a decision trail that reaches such a message omits it, its deliveries and its
events. Once the thread is shared with the owner, the owner's trail shows it.
- `replies`, same three sessions (193b1): a hidden `id` fails `message-not-found`; hidden replies are dropped. The
owner's own and the tester's threads are unchanged.
- Migration: a 3i store with threads migrates to 3j, row counts unchanged, `thread_shares` empty and
append-only (update, delete, replace each abort). Restore-guard and `COUNTED` include it.
Business (193a): R1 accepts `viewer`, refuses any other new level.
CLI (193c): `mosaic access user add/remove` and `grant --level viewer` refuse tester and agent callers,
write a receipt line and call `access.reload`.
Webui (193d): a viewer has no composer or share control. With each UI check removed, the bus still refuses.
Mutants, each must be killed:
1. `#readable` rule 1 dropped (any covered user reads any thread).
2. Rule 2 dropped (a share widens the grant).
3. Rule 3 dropped (a revoked owner's thread stays visible).
4. Latest-row lookup replaced by "any share row" (unshare ignored).
5. (Moved to FED R4, N3: the level-loss mutant is R4's.)
6. Viewer allowed `thread.send`.
7. `#threadRead` used for `thread.send` (sharee writes).
8. `share-not-covered` check removed.
9. `thread.share` owner check removed (sharee re-shares).
10. A user allowed `trail`.
11. The `trail` filter removed (the owner reads a user's unshared message by id).
12. The `replies` filter removed (the owner reads replies in a user's unshared thread).
## 9. Build rows and order
| Row | Work | Chains on | Schema |
|---|---|---|---|
| 193a | R1 level check widened to viewer | FED R1 candidate | none |
| 193b1 | Bus: viewer verb list, per-grant level checks, users refused on board reads, the 5.7 `trail` and `replies` filter with `#readable` as owner-only | FED R3, R4 candidates, row 196 verifier | none |
| 193b2 | Bus: `thread_shares`, share verbs, the share rules of `#readable`, `#threadRead`, the 5.5 share filters | 193b1 candidate | 3j |
| 193c | `mosaic access` user verbs and `--level viewer`, if R5 lacks them | FED R5 candidate | none |
| 193d | Console: shared list, share controls, viewer read-only | FED R6, row 192 candidates, 193b2 for share controls | none |
Sage ruled the split (sage-1354): 193b1 has no schema change and can go live first. 193b2 carries 3j, and
Sage takes its migrate window to Mos when 193b2 is near. The 5.7 message filter is in 193b1 (Duckworth B2,
sage-1368), so it is live before any user thread can be, whatever order FED R8 and 193b2 land in. 193b2 adds
only the share exception. FED R4 chains on 193a (viewer level), and 193b1 chains on R4.
Coverage, settled in FED r4.1 (73677b87d, gizmoduck-0018, still under Duckworth's review). A grant's
`projects` is `["*"]` or bus project UUIDs (`projects.id`, broker.mjs:2306; `threads.project` references it).
R1 exports one pure `covers(grant, {business, project, instance}, now, {level})`. Row 193 uses only that
function and adds no second one (Sage confirmed, sage-1354):
- share reads (5.4 rules 2 and 3) and `share-not-covered`: `covers(g, thread, now, {})`, any level;
- `thread.create`, `thread.open`, `thread.send`, `thread.share`: `covers(g, thread, now, {level: 'collaborator'})`;
- `project.list` and `names` for users: any level, as FED 3.5.
`covers` needs no further argument for row 193. If r4.1 changes in review, 193b1 follows it.
Follow-ups, not in this row:
- A read-only users and grants page for the owner in the Console.
- A second owner or admin level, if Jason wants one.
- An explicit, logged owner audit verb for user threads, only if Jason asks for one (sage-1354).
## 10. Gate
Per the brief: Duckworth approves this design, then the manifest. The bus, business, cli and webui suites and
every `scripts/test-*.sh` pass on Sage's gate, and the section 8 mutants are killed. No test touches the live
directories. No product code before Duckworth approves.
-8
View File
@@ -1,8 +0,0 @@
#!/bin/bash
n=0
for p in /proc/[0-9]*; do
c=$(tr '\0' ' ' < $p/cmdline 2>/dev/null) || continue
case "$c" in *shell-snapshots*|*chrome-devtools-mcp*|*sage-busy-now*) continue;; esac
if echo "$c" | grep -qE 'node --test|gate\.sh|mutant|scripts/test-[a-z-]+\.sh|docker compose'; then echo "BUSY ${p#/proc/}: ${c:0:160}"; n=$((n+1)); fi
done
[ $n -eq 0 ] && echo CLEAR
@@ -1,24 +0,0 @@
#!/bin/bash
set -o pipefail
# Landing gate template (2026-10-14): sed the G= and OUT= lines and this comment.
# Sequential suites, each teed to its own log; stops at the first failure.
G=/mnt/storage/scratch/SET-ME-wt
OUT=/mnt/storage/scratch/SET-ME-out
cd "$G" || exit 9
mkdir -p "$OUT" || exit 9 # a missing OUT failed the row 142 gate at step 1 (2026-10-15)
# test-task.sh live cases run `docker compose` from $G, so without a name the project is "wt"
# and leaves wt_default behind (row 142 gate, 2026-10-15). Name it per gate dir and remove it on exit.
export COMPOSE_PROJECT_NAME="$(basename "$(dirname "$G")")"
trap 'docker network rm "${COMPOSE_PROJECT_NAME}_default" >/dev/null 2>&1 && echo "removed network ${COMPOSE_PROJECT_NAME}_default"' EXIT
# A gate tree without the node_modules link fails cli tests with ENOENT (2026-10-13).
[ -d "$G/node_modules" ] || { echo "STOP no node_modules in $G"; exit 8; }
echo "base $(git rev-parse --short HEAD) dirty $(git status --short | grep -vc node_modules) DOCKER_HOST=${DOCKER_HOST:-unset}"
run() { local name="$1" dir="$2" cmd="$3"
(cd "$G/$dir" && bash -c "$cmd") > "$OUT/$name.log" 2>&1; local rc=$?
echo "$name rc=$rc $(grep -E '^ℹ (pass|fail) [0-9]+' "$OUT/$name.log" | tr '\n' ' ') | $(tail -n1 "$OUT/$name.log")"
[ $rc -eq 0 ] || { echo "STOP $name"; exit $rc; }; }
# No pkg-queue step: scripts/test-queue.sh runs packages/queue/tests/ itself (same 156 tests; CI debate item 2a, Vikunja #274).
for d in packages/*/; do p=$(basename "$d"); [ -d "$d/tests" ] || continue; [ "$p" = queue ] && continue
run "pkg-$p" "packages/$p" 'node --test tests/*.test.mjs'; done
for s in scripts/test-*.sh; do n=$(basename "$s" .sh); run "script-${n#test-}" . "bash $s"; done
echo GATE_PASSED
+1 -1
View File
@@ -3,5 +3,5 @@
Reviewer seat, Claude Opus 5.5, started by Mos (mos-0981, thread 03086006). Reviews Case 2 designs and MVP T1 rows 192 and 194.
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 `webby/*` branches
credentials of its own; until row 195 settles per-agent identity, it pushes only its own `webby/*` branches
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
-2
View File
@@ -1361,5 +1361,3 @@ are never rewritten or removed; corrections are new entries.
2026-10-20T18:34:53Z | Filbert (Opus 5.5, 048f3c63) | Q175 SSO design review r2 (DESIGN.md blob 3eb718b5e131 at 1a268249e), static | changes: C1 config.json console key fails old loaders (mosaic-config.mjs:113 rejects unknown keys; key add inside phase 2 window, rollback removes it, loader test), C2 app.js signs out only on opaqueredirect so 401 never shows sign-in; notes on tests 15/7/9a, Q-B 200-HTML classification, scratch Console flags; Q-E keep iss/sub escaped and capped; verdict sent to Sage, no Gitea/queue record
2026-10-20T18:40:20Z | Filbert (Opus 5.5, 048f3c63) | Q175 SSO design review r3 (DESIGN.md blob a152fd58e18e at 817e8f51b), static | approve on condition D1 (slot file checks stay out of shared loader, shape-only there) as a line edit; D2 notes: slot root from configDir, scratch dataRoot, name discovery GETs for Mos; C1/C2 and notes verified; Mos C1 sequence confirm still open; no Gitea/queue record
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)
-77
View File
@@ -1,77 +0,0 @@
# Brief: Melanie's hub instance on its own host (row 195, was 190b)
Sage, 2026-10-20. Rewritten for Mos's rulings mos-0998 and mos-1005 (Vikunja #418, #419). The first version
put the hub under its own Linux user on dragon-lin. That form can't pass Duckworth's B7 there, because the
factory uid's `sudo -n` succeeds on dragon-lin (Mos's recon, brain-redux
`projects/mosaic-mvp/MELANIE-HOST-RECON.md`). Melanie's own server is ruled out too: it has 1.2 GiB RAM free
with her two Hermes bots running, and co-tenancy would share her user and her passwordless sudo. The hub runs
on a separate host. Which host is Jason's decision (#419): hardware outside the cluster, a cluster VM with
factory PVE root removed, or a cluster VM with that residual accepted. This row is written so the host choice
doesn't change it. Gitea #1693. On the 10-27 MVP path. Federation R8 (Gizmoduck's design r4, 04a6112be) needs
this row or nothing.
Owner Darkwing (Opus 5.5), reviewer Duckworth (author of B5 and B7), gate owner Sage. Chained on row 190
(Q177): the hub runs under row 190's config canon. Design first. The first deliverable is
`agents/darkwing/work/queue-190b/DESIGN.md`.
## Goal
Melanie's hub instance (Case 2, C2-13) runs on its own host. No factory agent can read or write the hub's
config, data root or secrets, reach its bus socket, or log in to the hub host. Her writing reaches the hub only
through the existing git.mosaicstack.dev remotes, books repo only.
## Provision (blank until Jason answers #419)
Who provisions the host and how is left open. Sizing from Mos (mos-0998): 4 vCPU, 8 GB RAM, 64 GB disk,
internal network only. Each agent process measures 300 to 390 MB RSS on dragon-lin. The design takes the host
as given: a fresh Linux host with a named admin path, reachable by the deploying seat while the row runs.
## The design must state
1. The deploy. Exactly what Darkwing installs on the hub host and how: the stack copy, node, the units, the
config dir and data root under row 190's canon (O1: `~/.config/mosaic-NAME`, and "`~` is the home of the OS
user that runs the instance"). Which commands run as which user on the hub host, and which need its root.
2. Re-pin, rollback and backup on the hub host, and who runs them after the deploy key is gone.
3. How the hub gets model access without a copy of jwoltje's `claude-session.env` or any factory credential.
If that needs the credentials row (T1 item 6), say so and name the interim. If it needs a new subscription
or a new provider account, flag it for Jason and don't pick one (mos-1008).
4. The books repo path. Which git.mosaicstack.dev remote, which credential the hub holds for it, and that it
can't read or push any other repository.
5. The admin path and the end state. The host ends with no key the factory uid can read. The design names the
admin path that remains (for example the hypervisor console, or a key kept off the factory uid) and who
holds it.
6. The acceptance script, as code Duckworth can read. It takes Gizmoduck's list of infrastructure gate items
as its input (mos-1011), not a fixed set of checks. Each item names a path, its closed form and its
accepted form, and the script reports which form holds for each, or fails. The list covers at least:
- federation R8's separate-host form: the hub's bus socket, data root and config dir don't exist on the
factory host, the factory host mounts nothing of the hub's, and no factory agent holds a login or key
for the hub host
- B7: from the factory uid, `sudo -n`, `doas -n`, `su`, polkit, and `ssh -o BatchMode=yes` to the hub
host all fail
- the hypervisor and backup paths ops-01 measured (mos-1011: one PVE node the factory key has root on,
PBS on that node with unencrypted backups, a flat /16 LAN), each closed or accepted by Jason
7. The identity provider and TLS front end (B8, mos-1008, mos-1011). Either the factory holds no credential
that can create users, change groups, issue tokens or impersonate in the issuer the hub trusts, or the hub
uses an issuer the factory can't administer. A brand, tenant or app inside the same Authentik does not
count as separate. The same holds for the TLS front end that serves the hub. The proof is a failed admin
call from every token the factory's root can read. No credential value is printed in that proof.
## Boundaries
- Provisioning, hypervisor access and any infra or k3s change are Jason's (Q173 exclusions). Mos takes the
hypervisor residual (factory root on the PVE nodes, Q172) to Jason. It doesn't block this row.
- Melanie's server (`[email protected]`) is reference only, read only, under Jason's authorization in
mos-0995. Nothing on it changes, and her Hermes bots keep running.
- No tunnel, Traefik or other exposure outside the internal network. That waits for Jason, after Q175 SSO.
- No credential value is printed, logged or committed. Keys made for the deploy are removed at the end, and
the acceptance script proves it.
- The admin-call proof in point 7 and any call to the issuer or the TLS front end run only in a window Mos
approves, never as a test or an anonymous probe (Q175).
- Tests in the repository use scratch roots and stand-ins. The live deploy and the acceptance run on the hub
host are a window with a backup first (where there is state) and a STATE report after.
## Gate
Duckworth approves DESIGN.md, then the manifest. Any repository change is green on Sage's gate (cli suite and
every `scripts/test-*.sh`), with the design's mutants killed. The row is done when the acceptance script passes
on the hub host from the factory uid, Duckworth reads its output, and Mos confirms.
-62
View File
@@ -1,62 +0,0 @@
# Brief: the human boundary across uids and humans (P19 333)
Sage, 2026-10-20, for Mos's order mos-0989 (P19 333, MOSAICSTA-173, is now on the 10-27 path: federation R3 and
row 193 need it). Gitea #1694. Owner Gander (Codex gpt-6.1-sol, a new builder seat under mos-0977 item 5),
reviewer Duckworth, gate owner Sage. Latest-by: DESIGN.md approved by 2026-10-22, landed by 2026-10-24, and in
any case before federation R3 lands. R3 and row 193 may chain on this row's candidate (Q177). Base: refactor at
the brief commit. Design first. The first deliverable is `agents/gander/work/queue-ROW/DESIGN.md`.
## The problem, as filed and as it now matters
P19 333 as filed (row 87, mos-0657): the Console listens on a loopback TCP port, and any host process that
reaches it, under any uid, acts as Jason. Jason accepted that as a documented limit for a one-user host
(mos-0787, row 161). Two things change that:
- Row 190b (#1693, mos-0986) puts Melanie's hub under its own Linux user. Factory processes then sit on another
uid, on the same host, next to the hub's ports and socket. The other-uid reach is now the Case 2 threat.
- Federation r2 (gizmoduck-0004, 60b9b112a) binds a bus user only from a user-cli whose proof passes the human
walk, whose direct parent is the bus's own Console, and which holds a live grant. That rule rests on the
path-bound human proof (#225): today a same-uid process that runs the tree's `human-cli.mjs` acts as the
human, without touching the Console. The Q175 design (section 8) names that limit and leaves it here.
## What already settles part of it
- Q175 phase 2 (#412) settles the Console-ingress part on the main port. Under `--sso`, 7332 answers any
non-public Host with 403 except `GET /healthz`, and every other request needs a verified OIDC session. A
process on another uid that reaches the port gets only `/healthz`. Phase 1 (row 192) builds it; it is live
only after the phase 2 window.
- Row 189 (`--no-send`) is interim only. Until phase 2 the Console takes no human writes, so the reach is
read-only. It settles nothing.
- Row 190b separates files and units by uid. Whether the hub's `broker.sock` refuses the factory uid depends on
its directory and socket modes, which this row checks and tests rather than assumes.
## What the design must settle
1. Inventory every listener and entry point per instance: the Console port, the tester listener (7333 today),
`broker.sock`, the human CLI wrapper, the Discord connector and anything row 190 adds. For each: who can
reach it today from the same uid and from another uid, with and without `--sso`.
2. The tester listener. It serves reads and the row 175 tester writes to anything that reaches it, on any uid.
Close the other-uid reach (for example a unix socket with 0600 modes, or a peer-uid check on loopback), or
bring Mos a concrete reason it must stay open. bus.tester stays Jason's switch.
3. The bus human boundary. How the bus tells the human's own verified call from a same-uid process that runs
`human-cli.mjs`. State what federation R3's direct-parent rule needs from the bus (peer credentials on
`broker.sock`, the parent check, how it can't be forged by a child of an agent), and what stays possible for
a same-uid process after this row. Say it plainly where a residual limit remains.
4. Fit with row 193 (Morgana, human users and roles; Mos's B1 rule: a principal comes only from something the
bus verifies) and with federation R3. One mechanism, not three.
5. Migration: the live single-user Console and bus keep working until each instance re-pins.
6. Which parts of P19 333 this row closes, which parts Q175 phase 2 closes, and what P19 333 says when it is
closed.
## Boundaries
- Who can reach the Console from outside stays Jason's. This row changes what a host process can do once it
reaches a local port or socket.
- No test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or a slot file. No test creates a real
second uid; use stand-ins as rows 190 and 190b do, and name any part only a Q173 window can prove.
- No request to the live 127.0.0.1:7332 or 7333, mosaic.woltje.com or auth.diversecanvas.com.
## Gate
Duckworth approves DESIGN.md, then the manifest. The bus, webui and cli suites and every `scripts/test-*.sh`
are green on Sage's gate, and the design's mutants are killed.
+2 -4
View File
@@ -12,10 +12,8 @@ before any code, as Q175 did. Then the build follows the design, with tests and
Two or more instances run side by side on dragon-lin. Each one has its own config file, data root (store,
broker.sock, host.lock, state, runs, sessions, workspaces), businesses, bus unit, Console unit and port,
tester port, Discord unit, Claude session env file, stack-active pointer and activation log. No instance's
commands, units or tools address another instance's state by default or by mistake. On one uid this is
addressing only, not a security boundary: a same-uid process can still read another instance's files.
Isolation against that is row 190b (#1693, mos-0986), one Linux user per instance. The current instance keeps working unchanged: its
tester port, Discord unit, Claude session env file, stack-active pointer and activation log. Nothing one
instance does can read or change another's state. The current instance keeps working unchanged: its
config path, data root, unit names and ports stay as they are, and no re-pin is needed for it.
## Known single-instance points (GAP-MAP.md, measured at 9ae8c73ac)
@@ -1,42 +0,0 @@
# Brief: retire the control board writer on 7331
Sage, 2026-10-20, for Mos's ruling mos-1008 item 2. Gitea #1696. MVP 10-27 work. Owner Bushroot (Haiku 5.5, a
low-level model under Q177), reviewer Webby, gate owner Sage. Base: refactor at the brief commit.
## Why
`packages/control-board/src/serve.mjs` is a loopback HTTP service on 7331 with no authentication. It reads
transcripts and writes through `/api/reply`. Any host process that reaches the port can do both. That fails
Q175 ("zero trust with verification"). Gander found it in the row 196 inventory (gander-0004). Nothing listens
on 7331 now (`ss -ltnp`, 2026-10-20T19:05:09Z), so retiring it costs the least.
## Steps, in order
1. Inventory first. List every reference to the control board and to 7331: scripts, units, `scripts/mosaic`
and other CLI dispatch, docs (AGENTS.md, READMEs, TOOLS.md), package.json scripts, tests, and the Console
(`packages/webui`). For each, say whether it starts the writer, reads its code, or only mentions it.
Commit the list as `agents/bushroot/work/queue-ROW/INVENTORY.md` and send it to Sage and Webby.
2. Stop if something depends on it. If a reference shows a live dependency (the Console or another package
imports code that only the control board has, or a unit or script still starts it), stop. Sage takes it to
Mos before anything is removed.
3. Otherwise retire the writer. Either remove the `serve` entry point, or make it refuse to start with a
message that points to the Console. Keep any read-only code another package imports, and say which.
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.
## 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.
## Boundaries
- No change to the live units, `~/.mosaic-dev`, `~/.config/mosaic-dev`, slot files or `~/.mosaic`.
- No request to any live port.
- Single test files run freely. Suites need Sage's slot.
## Gate
Webby approves INVENTORY.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.
-42
View File
@@ -1,42 +0,0 @@
# Brief: a second gate lane, two full gates at once on dragon-lin
Sage, 2026-10-20, for Mos's order mos-0977 item 6 ("put the design for a second gate lane on the list now")
and the MVP push (mos-0974). Gitea #1695. Owner Herb (Codex gpt-6.1-sol, a new builder seat), reviewer
Webby, gate owner Sage. Base: refactor at the brief commit. Design first. The first deliverable is
`agents/herb/work/queue-ROW/DESIGN.md`.
## Why
Every full run shares one slot today. A full gate (cli suite plus every `scripts/test-*.sh`) takes about 13
minutes or more, reviewers' runs and mutants take the same slot, and with eight or more seats building for
10-27 the slot is the bottleneck. The slot exists because concurrent runs broke each other. The busy check is
`agents/sage/work/gate-lane/busy-now.sh` (a copy of Sage's), and the gate is
`agents/sage/work/gate-lane/gate-template.sh` (a copy of Sage's template). The host has 16 cores and 62 GB.
## What the design must settle
1. Every shared resource two concurrent runs can collide on, found from the code and tests, not guessed:
Docker compose project names and networks (`scripts/test-task.sh` and others), fixed TCP ports in tests,
unix socket paths and their 107-byte limit under TMPDIR, the sandbox's private `/tmp`, shared caches,
scratch and worktree paths, the queue lock, and any test that reads host-wide state (process lists,
`pgrep`, `/proc`). For each: how a lane gets its own copy, or why it is safe to share.
2. Timing. Which tests have time limits that a doubled CPU load could break, measured, and what changes (a
bound, or a lane rule that keeps those suites apart).
3. The lane mechanism: lane ids, a lock per lane that a run takes and releases even on a crash, the busy
check that reports per lane, and how a seat asks for a lane. Sage still grants it.
4. The gate template takes a lane id and isolates everything from point 1 by it.
5. Proof: two full gates on different candidates at once, three times, with zero cross-failures, plus the
wall time of each against a single-lane run. Any flake found is named with its cause.
## Boundaries
- No change to the live units, `~/.mosaic-dev`, `~/.config/mosaic-dev` or slot files. The proof runs use
scratch roots only, and it needs Sage's grant of the whole slot while it runs.
- Test code changes that only isolate a resource are in scope. A change to what a test checks is not, and
goes to its owner.
- Docker networks and containers a run makes are removed by the run (trap), and nothing else is touched.
## Gate
Webby approves DESIGN.md, then the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's
gate, both singly and in the two-lane proof.