Compare commits

..
Author SHA1 Message Date
jason.woltje 2f488b4ea7 webby: row 226 design d2 notes, timestamp from the clock 2026-10-21 16:56:03 -05:00
jason.woltje 660d25e656 webby: row 226 design d2 review, approve, and session line 2026-10-21 16:55:55 -05:00
jason.woltje 15c09fd7b5 webby: row 226 design d1 review, changes, and session line 2026-10-21 16:49:44 -05:00
jason.woltje ebb3d83bf6 webby: row 244 round 1 review, approve, and session line 2026-10-21 16:41:43 -05:00
jason.woltje a11a9926cd webby: row 243 round 1 review, approve, and session line 2026-10-21 14:10:53 -05:00
jason.woltje 5d8f0f4b54 webby: row 252 design r1 review, approve, and session line 2026-10-21 14:00:02 -05:00
jason.woltje ef2098c967 webby: row 197 proof driver a5b636750 review, approve, and session line 2026-10-21 10:20:26 -05:00
jason.woltje bb7c028852 webby: row 248 round 1 review, approve, and session line 2026-10-21 08:15:18 -05:00
jason.woltje de135fc029 webby: row 197 queue round 6 review, approve, and session line 2026-10-21 07:17:09 -05:00
jason.woltje ef63393ee4 webby: row 218 round 3 review, approve, and session line 2026-10-21 07:04:18 -05:00
jason.woltje bbca2935ba webby: row 218 round 2 review, approve, and session line 2026-10-21 07:00:20 -05:00
jason.woltje 191048fbec webby: row 247 round 1 review, approve, and session line 2026-10-21 06:55:52 -05:00
jason.woltje 4a3dc25763 webby: row 218 round 1 review, approve, and session line 2026-10-21 06:53:05 -05:00
jason.woltje bef9a62f4c webby: row 197 queue round 5 review, approve, and session line 2026-10-21 06:29:48 -05:00
jason.woltje a0a00630ea webby: row 221 round 1 review, approve, and session line 2026-10-21 05:37:27 -05:00
jason.woltje efd6c1f028 webby: row 231 round 2 review, approve, and session line 2026-10-21 04:52:58 -05:00
jason.woltje 02bb0afb3a webby: row 213 round 4 review, approve, and session line 2026-10-21 04:37:31 -05:00
jason.woltje 387f04dfda webby: row 231 round 1 notes, correct header time 2026-10-21 04:33:41 -05:00
jason.woltje 7a542c0ea2 webby: row 231 round 1 review, changes, and session line 2026-10-21 04:32:59 -05:00
jason.woltje 858f773625 webby: row 197 queue round 4 review, approve, and session line 2026-10-21 04:29:50 -05:00
jason.woltje e10a6f1166 webby: row 197 proof driver r2 review, approve, and session line 2026-10-21 03:31:33 -05:00
jason.woltje bba870ee9c webby: row 197 proof driver review, changes, and session line 2026-10-21 03:22:36 -05:00
jason.woltje 2fb6815963 webby: row 197 queue round 3 merge review, approve, and session line 2026-10-21 03:13:32 -05:00
jason.woltje 57da6fda51 webby: row 212 review r3, approve, and session line 2026-10-21 02:43:37 -05:00
jason.woltje 361bd753fa webby: row 197 queue round 2 merge review, approve, and session line 2026-10-21 02:19:08 -05:00
jason.woltje 69d5b39600 webby: row 238 review r3, approve, and session line 2026-10-21 02:01:14 -05:00
jason.woltje 4e931c4f1f webby: row 238 review r2 and row 228 review r3, approve, and session line 2026-10-21 01:50:45 -05:00
jason.woltje d2344ff8bf webby: row 228 review r2, approve, and session line 2026-10-21 00:06:50 -05:00
jason.woltje 3ab3c010b1 webby: row 213 review r3, approve, and session line 2026-10-21 00:02:48 -05:00
jason.woltje deda9f48e8 webby: row 212 review r2, approve, and session line 2026-10-20 23:56:00 -05:00
jason.woltje 433a95292e webby: row 238 review r1, approve, and session line 2026-10-20 20:31:20 -05:00
jason.woltje 7a9a90a7a2 webby: row 230 review r2, approve, and session line 2026-10-20 18:58:51 -05:00
jason.woltje 2f00563249 webby: row 230 review r1, changes B1, and session line 2026-10-20 18:50:47 -05:00
jason.woltje 2a216fd169 webby: row 200 review r3, approve, and session line 2026-10-20 18:29:41 -05:00
jason.woltje 928405649c webby: row 213 review r2, approve, and session line 2026-10-20 16:53:09 -05:00
jason.woltje 2dfc1b9ad6 webby: row 212 review r1, approve, and session line 2026-10-20 16:48:26 -05:00
jason.woltje 5bd27e804b webby: row 213 review r1, changes B1-B2, and session line 2026-10-20 16:47:06 -05:00
jason.woltje 449c3458ff webby: row 228 review r1, approve, and session line 2026-10-20 16:19:21 -05:00
jason.woltje 82f50d1273 webby: Case 2 design r11 review (#416), approve, and session line 2026-10-20 16:15:35 -05:00
jason.woltje 2beb49daeb webby: Case 2 design r10 review (#416), changes B1-B3, and session line 2026-10-20 16:01:42 -05:00
jason.woltje d37be6ff29 webby: row A brief review, approve, and session line 2026-10-20 15:55:45 -05:00
jason.woltje a5643f9bd7 webby: row 197 implementation review r3, approve, and session line 2026-10-20 15:50:23 -05:00
jason.woltje 9705a6aaca webby: Case 2 design r9 review (#416), approve, and session line 2026-10-20 15:48:49 -05:00
jason.woltje 1f404c973b webby: row 197 implementation review r2, approve, and session line 2026-10-20 15:45:28 -05:00
jason.woltje b2793f2aa1 webby: row 200 r2 review (#1698), approve, and session line 2026-10-20 15:31:25 -05:00
jason.woltje f942bd7d43 webby: row 197 implementation review, changes B1 B2, and session line 2026-10-20 15:24:21 -05:00
jason.woltje 425900b036 webby: row 200 r1 review (#1698), approve, and session line 2026-10-20 15:14:58 -05:00
jason.woltje 85a72842c3 webby: Case 2 design r8 review (#416), approve, and session line 2026-10-20 15:04:59 -05:00
jason.woltje 19bb3ae364 webby: Case 2 design r7 review (#416), changes B1, and session line 2026-10-20 15:03:11 -05:00
jason.woltje 216302d65f webby: Case 2 design r6 step 1 review (#416), changes B1, and session line 2026-10-20 15:00:07 -05:00
jason.woltje 0d0df1ecee webby: row 192 r2 review (#1690), approve, and session line 2026-10-20 14:58:32 -05:00
jason.woltje dba33aaf60 webby: Case 2 design r5 review (#416), approve, and session line 2026-10-20 14:54:11 -05:00
jason.woltje 0597cc1988 webby: Case 2 design r4 review (#416), changes B1, and session line 2026-10-20 14:52:33 -05:00
jason.woltje c7a672d622 webby: row 192 r1 review (#1690), changes B1, and session line 2026-10-20 14:49:56 -05:00
jason.woltje 04e78354a6 webby: row 198 design rev 5 approve (#1696), and session line 2026-10-20 14:44:59 -05:00
jason.woltje fcac66f7bf webby: Case 2 design r3 review (#416), changes B1, and session line 2026-10-20 14:43:52 -05:00
jason.woltje ca6b32d668 webby: row 197 manifest r2 approve (#1695), and session line 2026-10-20 14:40:29 -05:00
jason.woltje 5a02ee3954 webby: row 198 design rev 4 review (#1696), changes B1, and session line 2026-10-20 14:39:47 -05:00
jason.woltje e1e64ff011 webby: row 194 design r4 approve (#1692), and session line 2026-10-20 14:38:46 -05:00
jason.woltje 6d2e502728 webby: row 197 manifest review (#1695), changes B1, and session line 2026-10-20 14:37:29 -05:00
jason.woltje c9d65664af webby: row 198 design rev 3 review (#1696), changes B1, and session line 2026-10-20 14:36:19 -05:00
jason.woltje 18f5daaaef webby: row 194 design r3 review (#1692), changes B1-B2, and session line 2026-10-20 14:33:02 -05:00
jason.woltje 822bf1aa04 webby: row 194 design r2 review (#1692), changes B1, and session line 2026-10-20 14:31:52 -05:00
jason.woltje 162ce46dbc webby: Case 2 design r2 review (#416), changes B1-B4, and session line 2026-10-20 14:29:11 -05:00
jason.woltje dada066753 webby: row 197 design r2 approve (#1695), and session line 2026-10-20 14:24:03 -05:00
jason.woltje 9afb947c73 webby: row 198 inventory approve, design changes B1-B3 (#1696), and session line 2026-10-20 14:23:14 -05:00
jason.woltje 8d53b93c78 webby: row 197 design review (#1695), changes B1, and session line 2026-10-20 14:20:20 -05:00
jason.woltje 365a763122 webby: row 194 design review (#1692), changes B1-B8, and session line 2026-10-20 14:15:02 -05:00
jason.woltje f9d218ab1e webby: Case 2 design r1 review (#416), changes B1-B12, and session line 2026-10-20 13:57:45 -05:00
138 changed files with 3947 additions and 4413 deletions
-9
View File
@@ -16,15 +16,6 @@ not fleet seats. Keep changes in `/mnt/storage/src/mosaic-stack`; do not modify
| Rocko | General development, investigation, testing, and review | T3 session (Codex, gpt-6.1-sol, thread d99326cc, lead decision 122; Claude thread b84bb264 retired 2026-10-12); launcher Claude Code, Sonnet model | `agents/rocko/launch.sh` |
| Filbert | General development, investigation, testing, and review | T3 session (Claude, Opus 5.5, thread 048f3c63, non-sandbox reviews only, lead decisions 130 and 134; Codex threads f712d8af and 50342f6a retired 2026-10-13, Claude thread 9cb9731e retired 2026-10-12); launcher Pi, `openai-codex/gpt-6-astra:low`, not to be used under R26 until it changes (lead decision 69) | `agents/filbert/launch.sh` |
| Researcher | Source-grounded technical research and evidence | T3 session (Claude, Sonnet 5.5, thread 9bac0794, rows 97 and 98, lead decision 124); Haiku rows (71, 80, 84, 88, later 96) run as subagents Sage dispatches with model haiku, effort high and a worktree (lead decision 129); Haiku thread c98aaf4a retired 2026-10-13 unused; launcher Pi, configured Mosaic model | `agents/researcher/launch.sh` |
| 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 |
| Megavolt | Builder, MVP T1 item 6 (agent credentials and git identity, #1697) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
| 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` |
Each script supports `--check` and `--fresh`. Normal launches resume the agent's
-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.
-7
View File
@@ -1,7 +0,0 @@
# Duckworth
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
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.
-7
View File
@@ -1,7 +0,0 @@
# Honker
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
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
-7
View File
@@ -1,7 +0,0 @@
# Launchpad
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
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
-8
View File
@@ -1,8 +0,0 @@
# Megavolt
Builder seat, Claude Opus 5.5, started by Sage under mos-0974 and mos-0977 item 5 for MVP T1 item 6, the
credential store per agent with its own git identity (#1697).
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `megavolt/*`
branches through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
-10
View File
@@ -1,10 +0,0 @@
# #436b build carry items
The design is approved at r5: 1be535aca, DESIGN.md sha256 68df02cdf890564c6ed0a5426204114389ba7eb0eea1cf04f1bf5938b6de45f4. Dewey gave the approval in dewey-0763 (verdict sha256 de52be1b…6b68). Section 7 is approved at fa5658941 by duckworth-0067 and is unchanged since. DESIGN.md stays as approved. The row's build takes the items below, and its BUILD.md cites this file.
- N9 (dewey-0763). Revoke writes `grant.revoked`, not a `grant.step`. A restart after a revoke on an open rotation therefore still sees a `commit` with no settle after it, and settles again: no file, `grant-lost`, a wrong message to Jason, and repeated deletes that come back 404 and go to `serviceLeft`. Two fixes:
- The section 5 restart row "`commit`, rotation, settle-pending" takes the held row's clause "with no `grant.revoked` after it".
- A 404 on a delete by id counts as done, not as a `serviceLeft` entry. That applies to revoke, sweep and the settle deletes.
- Add a test: revoke on an open rotation, then a restart. No settle runs, no Gitea call is made, and the restart sends no message about it.
- N10 (dewey-0763). `grant.revoked {serviceLeft}` can now hold two ids, so it is a list of ids everywhere. `sweep` reads it as one.
- duckworth-0067 N1 is in r4 (section 5, X-G15). Nothing more is carried for it.
-640
View File
@@ -1,640 +0,0 @@
# Gitea grant path, design draft r5 (Vikunja #436b, Gitea #1697 follow-up)
Author: Megavolt. Reviewer: Dewey. GitHub half: Bushroot. Base: Gizmoduck's #436a r6 (f9039682e, final,
`agents/gizmoduck/work/machine-grant/DESIGN.md` on origin/gizmoduck/machine-grant-design) and row 199's
DESIGN.md section 7a (e10e03f6b). Mos rulings used here: mos-1130 and mos-1136.
This draft builds nothing. It says how an Approve in Jason's inbox makes an agent's Gitea user, its token and
its forks with no manual step, and it answers mos-1130 item 3 (a to e) on the executor's admin credential.
## r5 changes (dewey-0761 on r4 3e66f5acf)
Section 7 is unchanged from fa5658941.
- Item 1 (blocks). A revoke on an open rotation deletes both tokens. When the last `grant.step` is a
rotation's `commit` with no settle after it (settle-pending or `grant-token-unknown`), section 6 step 3
deletes `previousTokenId` and the minted `tokenId`, each failure to `serviceLeft`. Section 5's
`grant-token-unknown` text now says both (X-G5).
- N8. Step 0 refuses `grant-token-unknown` while the agent is held, so a revoke is the only way out.
## r4 changes (dewey-0759 on r3 fa5658941, sage-1578, duckworth-0067)
Section 7 is unchanged from fa5658941, the text Duckworth approved and Gizmoduck cites.
- Item 1 (blocks), option (b), Sage's ruling in sage-1578. A step 0 refusal leaves the decision unspent only
until the next start. #436a r6 X3's start reconcile spends every resolved-yes grant decision with no
`action.allowed` as `interrupted`, and this draft no longer claims otherwise. `grants run` works while the
bus that refused is still up; after a restart the chief raises again and Jason approves again. `status`
says so on each open decision (D5, section 4 step 0, section 5, X-G11). (a) would change #436a r6, which is
final. The cost is in D5.
- Item 2 (blocks). A settle that gets 200 with an `id` that is neither token no longer leaves the file in use.
Memory drops the credential (199 `revoke`'s memory step), the file and both recorded tokens stay, nothing
is deleted at Gitea, and the step ends `grant.failed {code: 'grant-token-unknown'}` for Jason. A restart
keeps the credential out of memory (`Credentials` `held`, built in #436b's row like `reload`). Revoke clears
it (section 5, X-G6).
- N7. 3d says the private session-less writer runs `#secretCheck` on every guarded body, as `#event` does
(`broker.mjs:559`). The sentence is in 3d, so section 7 doesn't change.
- duckworth-0067 N1. Every reader of grant evidence filters on `kind = 'action.allowed'` and the grant actor
pair, so a `grant.*` name under another kind is not evidence (section 5, X-G15).
## r3 changes (dewey-0756 on r2 6db7cc45d, gizmoduck-0081 and -0084, duckworth-0065, mos-1144)
- B1 (blocks). Only `#consumeGrant`'s own record carries `decision`. Every other `grant.*` body names the
decision `grantDecision`, so `#spent` sees exactly one spend. A step 0 or step 1 refusal leaves the
decision unspent (section 7, X-G11, a mutant).
- B2 (blocks). Settle with the seat token is proven on scratch (F13): `GET /api/v1/token` answers 200 with the
minted id and 401 after the token is deleted. Only a 401 ends `grant-lost`. 403, 404, 5xx, a timeout, an
unreachable Gitea or a failed C1 to C4 leave the step open as `settle-pending`, delete nothing, and retry
on the next start and in `grants sweep` (section 5, X-G6). `Credentials.reload` is built by #436b's own
row in `packages/bus/src/credentials.mjs`; row 199's candidate doesn't change.
- B3 (blocks). Step 5 compares against the user's forks of every upstream in the agent's `publish` list, not
only `target.projects`. The rendered block names those other upstreams as reach the token may have. A fork
whose upstream has left `publish` refuses `gitea-reach-wider` with its name (section 2, step 5, X-G12).
- Section 7, the guard (gizmoduck-0081, -0084, duckworth-0065): one pattern, `grant.*`, on `body.action` and
on `body.operation` (Dewey N2: the consume record's `operation: 'grant.<method>'`). #436a renames its
`host.enroll.step`, `host.enrolled`, `host.checked` and `host.removed` into `grant.host.*`, so row 210's
`host.sudo` and `host.run` stay outside. The grant actor is `actor_role NULL, actor_run 'grant'`, which no
session or human event can produce (answers Dewey N1, same safety as `_bus`).
- D12 / Q-B4 closed by mos-1146 (section 8).
- N3. Step 5 reads every page of `/user/repos` and checks the total. N4. One nonce format, and the adopt
check compares the lowercased login. N5. `grants run` takes the `#inflight` claim. N6. One open-decision
query, `openGrantDecisions`, for `status`, rotate's void count and the start reconcile (section 7).
## r2 changes (from r1 714c07304, Dewey's dewey-0754, verdict sha256 20d63dfb…)
- D1 (blocks). `gitea_users` records the Gitea user `id`, and every later grant, rotation and fire compares it
(step 2). A nonce goes in `grant.step user` before the create and in the user's `full_name`, so a restart
adopts a user it made and refuses anything else (section 5, X-G6). Both are proven on scratch (F8, F9).
- D2 (blocks). The audit records are `action.allowed` bodies with reserved `grant.*` actions, plus a trigger
in the store migration that only `system/grant` writes them (section 7). A call and its result are two
records paired by `callId`, because `events` is append-only (3d). To settle once with Gizmoduck for #436a.
- D3 (blocks). Seat-token rotation gets its own rows. After `issue` has renamed the file, the executor settles
by asking Gitea which token the file holds (`GET /api/v1/token`, F7), and goes forward or back on that
answer, never by guessing (section 5).
- D4 (blocks). 3c and section 8 now state the real gap. Reading the admin file gives instance admin, which is
more than a forged Approve gives. `write:admin` resets any user's password, email and admin flag, proven on
scratch (F10). The separate uid is deferred on cost, and the draft says so.
- D5. The admin checks run before `#consumeGrant`, so a predictable refusal leaves Jason's Approve unspent. A
human `mosaic grants run <decision>` starts it again (step 0). The raise refuses when the admin slot is past
its rotate-by date. r4 (sage-1578): unspent only until the next start, when #436a r6 X3's reconcile spends
it as `interrupted`. The cost: if a step 0 refusal is followed by a restart before Jason rotates the admin
token or upgrades Gitea, the chief raises again and Jason approves again.
- D6. One list of start checks (3a, 3e). The "no organization" claim is now part of the bootstrap promise,
and the check is that `GET /user/repos` is empty, which catches org and collaborator repositories (F11, and
F12 for a site-admin token).
- D7. Admin rotation: a timestamped name (F9: names are unique per user), `--rotate-by` required, and `setAt`
changes, so open approvals are voided and `status` says so.
- D8. The restart's remote undo runs the same admin checks and writes the same call records.
- D9 (Q-B1). On fire: `prohibit_login` (F10) and delete every token the user has, not only the recorded one.
- D10 (Q-B2). Step 5 checks the seat token's real reach with `GET /user/repos` (F11). Section 8 says reach can
grow later.
- D11. One Gitea per business, stated, and checked at `set` and at raise.
- D12. Section 8 asks for the jason.woltje comparison. I can't check it myself.
## 0. What this adds to #436a
#436a section 11 left `method: "gitea"` for a later draft, because the executor needs an admin credential and
roles may never hold the admin scope (`packages/business/src/vocabulary.mjs:108-112`). That rule stays. The
admin credential here is not a role service: no `useFor` reaches it, no definition can list it, and only the
executor reads it.
Everything in #436a's sections 4 and 5 holds unless this draft says otherwise: the `credential.mint` raise
with a `grant` body, chief only (R3), the rendered block (R4), Jason's Approve via cli (R5, X1),
`#consumeGrant` with `operation: 'grant.gitea'`, the `#inflight` claim and abort (X5), `grant.step` records and
restart reconcile (X3), and revoke local first (section 6).
What row 199 already gives: the agent's `identity = {name, email, gitea: {url, user}}`, its
`publish: [project]` grant and each project's `remote` (199 D8, D9), the store and `issue(instance, service,
token, date, commit)` (`packages/bus/src/credentials.mjs:156`), and section 7a's rule that `issue` accepts a
resolved `credential.mint` decision as the human path. `issue` writes the file (tmp, fsync, rename), then calls
`commit`, then fills memory (`credentials.mjs:156-193`). Section 5 depends on that order.
## 1. Facts, proven on a scratch Gitea
Run on gitea/gitea:1.27.1 (sha256:fe4e4b1c8b93) on 127.0.0.1, throwaway data, container removed after each
run. Scripts and logs: `/mnt/storage/scratch/megavolt-r199/g436b/`. No log holds a token or password value.
F1 to F6 were reported in megavolt-0034, and Mos accepted them in mos-1136.
- F1 (`run4.log`). `POST /api/v1/users/{u}/tokens` with `Authorization: Basic <admin>:<admin token>` creates a
token for another user: 201. The same token in a `token` or `Bearer` header gets 401 on that route, while the
`token` header works on `GET /api/v1/user` (200). So the executor sends Basic on the token routes and
`token` everywhere else.
- F2 (`run4.log`). The child-scope cap: a child scope broader than the parent's gets 403 ("cannot create an
access token with a broader scope than the authenticating token"), for `write:organization` and for `all`.
A narrower one gets 201.
- F3 (`run4.log`). TOTP 2FA on the admin account blocks password Basic (401) and doesn't block token Basic
(201).
- F4. The route's check is `reqSelfOrAdmin`: whether the account is a site admin, not the token's scope. Any
`write:user` token on a site-admin account can mint a token for any user.
- F5. `write:X` implies `read:X` under Gitea's `HasScope`.
- F6. The cap is Gitea #38406, backported in #38426 (de4b8277e9 on release/v1.27, 2026-07-12). It first ships
in v1.27.0. v1.27.0-rc0 lacks it, and so does every 1.24.x, 1.25.x and 1.26.x through 1.26.4.
- F7 (`introspect.log`). `GET /api/v1/token` with the `token` header gives 200 with `id`, `name`, `scopes`,
`user`, `created_at` and `last_used_at`, and no token value. With Basic it gives 404. Scopes come back
normalized (3b).
- F8 (`r2probe.log` 1a-1d). `POST /admin/users` with a `full_name` gives 201 with the user's `id` and that
`full_name`. `GET /users/SEAT-ONE` finds `seat-one` with the same `id`: names match case-insensitively. A
create of `Seat-One` next to `seat-one` gets 422.
- F9 (`r2probe.log` 2a-2c). A second token with a name already used by that user gets 400. `GET
/users/{u}/tokens` with Basic lists the user's tokens.
- F10 (`r2probe.log` 4a-4f, 5a). With `write:admin`, `PATCH /admin/users/{u}` on another site admin sets a new
password (200; the new password then signs in, the old one gets 401) and a new email. On a plain user it sets
`admin: true`, then `admin: false` with `prohibit_login: true`. Without `write:admin` the same PATCH gets 403.
- F11 (`reach.log`). With the seat token (`write:repository`, `read:user`), `GET /user/repos` lists the user's
own fork, then also a repository it was made collaborator on, then also an org repository after it joined an
org team. `GET /user/orgs` and `GET /users/{u}/orgs` need `read:organization`, which neither token holds.
- F12 (`c4.log`). With a site-admin account's three-scope token, `GET /user/repos` is empty on an instance
where other users and an org it isn't in hold public and private repositories: being site admin doesn't add
them. It lists a repository once the account is a collaborator, and an org repository once it joins that
org's team. Joining an org with no repositories leaves it empty, and `GET /user/orgs` gets 403 (no
`read:organization`).
- F13 (`b2.log` b1-b7). Two seat tokens minted (201, ids 2 and 3). `GET /api/v1/token` with each token in the
`token` header gives 200, and the `id` is the minted one for both. After `DELETE` of seat-a's token (204), the
same call with it gives 401. After `prohibit_login` on seat-b (200), the call with seat-b's token gives 403
("This account is prohibited from signing in"), not 401. A value never issued gives 401. So 401 is the only
answer that means the token is gone; 403 means the account is shut, and section 5 leaves it `settle-pending`.
## 2. The binding
The shared part (section 10) is proposed to Bushroot in megavolt-0036. This section is the Gitea half.
```
grant: { "method": "gitea", "agent": "<instance>", "rotateBy": "YYYY-MM-DD",
"target": { "projects": ["<project>", ...] } }
```
Code fixes the scopes of the agent's token: `write:repository` and `read:user`, row 199 A2's set. The raise
can't name scopes, so the chief can't widen them.
Checks at raise, inside the raise transaction. Each refusal stores nothing:
- `agent` is a role instance of this business, its definition declares `{"service": "gitea"}`, and it has
`identity` and a `publish` grant (199 D8). Otherwise `gitea-not-declared`.
- Each project in `target.projects` is in the agent's `publish` list (`remote-not-granted`), and its
`remote`'s origin equals `identity.gitea.url`'s (`remote-host-mismatch`), the same checks as 199 D9 step 2.
- The business has an admin slot for `gitea` whose recorded `url` has the same origin
(`grant-admin-missing`), and the slot isn't past its rotate-by date (`grant-admin-rotation-due`).
- `rotateBy` is inside 199's rotation window.
The rendered block, appended by the broker as #436a R4 does: agent, Gitea user, email, Gitea URL, each fork
URL it will make (`<url>/<user>/<repo>`), the two fixed scopes, the rotate-by date, one of "new Gitea user",
"existing stack-made user, new token" or "token rotation" (step 2), and the line "The token can write every
repository this user can write. Step 5 checks that this is only its forks." Then, when the agent's `publish`
list has projects outside `target.projects`, the line "It may also reach this user's existing forks of:" and
each such upstream. The raise makes no network call, so the block names every such upstream, not only those
with a fork (B3). The 4096-character cap applies as in R4.
The raise event body gets `grant = {method, agent, target, rotateBy, definitionSha256, identitySha256,
adminSha256}`:
- `definitionSha256` as in #436a.
- `identitySha256` over the agent's `identity`, its `publish` list and the `remote` of every project in
`publish` (step 5 checks against all of them).
- `adminSha256` over the admin slot's metadata (`url`, `account`, `setAt`), never over the token.
At step 1 any of the three that differs refuses `decision-mismatch`. So a changed identity, publish list,
upstream or admin credential between raise and Approve voids the Approve (REQ-GRANT-2).
## 3. The executor's admin credential (mos-1130 item 3)
### 3a. A dedicated account
One Gitea account per Gitea instance, named `mosaic-grant` by default. It is a site admin and has TOTP 2FA on
(F3: the token path still works). Its password and TOTP secret stay with Jason and never reach the stack. It
is never jason.woltje or any other human's account. It owns no repositories, belongs to no organization and is
no repository's collaborator.
The admin checks, one list, run at `set` (3e), before every executor start (step 0) and before every remote
undo (section 5):
- C1. `GET /api/v1/version` is at least 1.27.0 (F6), compared as numbers, or `gitea-too-old`.
- C2. `GET /api/v1/user` gives `login == account` as recorded at `set`, and `is_admin` true, or
`grant-admin-account`.
- C3. `GET /api/v1/token` gives exactly the three scopes (3b), or `grant-admin-scope`.
- C4. `GET /api/v1/user/repos` is empty, or `grant-admin-not-dedicated`. By F11 and F12 this list covers
owned, collaborator and org-team repositories, and site admin alone doesn't fill it.
What code can't check: that nobody else uses the account, and org membership with no repositories (F11: the
orgs routes need `read:organization`, which the admin token doesn't hold and shouldn't). Both are the bootstrap
step's promise and go in the approval notes (section 8).
### 3b. The scopes, each shown needed
The admin token holds exactly `write:admin`, `write:user` and `write:repository` (mos-1136 item 1). From
`matrix.log`, with each scope dropped in turn:
| Parent token | A1 create user | A2 mint seat token | A3 fork (seat token) | A5 delete seat token |
|---|---|---|---|---|
| all four | 201 | 201 | 202 | 204 |
| without `write:admin` | **403** | 201 | 202 | 204 |
| without `write:user` | 201 | **403** | 202 | **403** |
| without `write:repository` | 201 | **403** (cap, F2) | 202 | 204 |
| without `read:user` | 201 | 201 | 202 | 204 |
- `write:admin` is needed for A1, and for `prohibit_login` on fire (F10).
- `write:user` is needed for A2 and A5.
- `write:repository` is needed only because of the cap. The seat's token holds `write:repository`, and a
parent must hold at least what it mints. The executor itself never writes a repository with it.
- `read:user` is covered by `write:user` (F5), so it is dropped.
C3 reads the scopes with the `token` header (F7). Gitea reports normalized scopes, so a token made with the
three plus `read:user` reads as the three. That is harmless, because `read:user` adds nothing to `write:user`.
A token made with `all`, or with another category, refuses.
### 3c. Where it lives, who can read it
- The token: `<configDir>/secrets/grants/<business>/gitea/admin`, 0600, in 0700 dirs, owned by the stack's
OS user. Written and checked by row 199's store code (D1: tmp, fsync, rename, the mode and owner checks).
- The metadata: `admin.json` next to it, 0600, holding `url`, `account`, `rotateBy` and `setAt`. No secret.
- A business grants on one Gitea. `set` refuses a second `url` while a slot exists (`grant-admin-exists`;
`remove` first), and the raise checks the agent's Gitea against the slot's `url` (section 2). Two businesses
on one Gitea each hold their own token for the same `mosaic-grant` account.
Who can read the file: the stack's OS user, and root.
Whether an agent process runs as that uid:
- On dragon-lin, yes. The stack runs as jwoltje, and so does every agent process on the machine: 13 claude, 4
codex and 2 pi processes, the bwrap sandboxes and node, counted 2026-10-20. That includes the T3 dev seats,
which are not sandboxed and can read any jwoltje file.
- Stack workers run in the seat sandbox, which has a private HOME and no `~/.config`, `~/.mosaic` or `~/.ssh`
(`packages/seat/src/sandbox.mjs`). A sandboxed worker can't reach the file by path. The sandbox doesn't
confine the network or the host uid (`sandbox.mjs:29-31`). So the file is safe from a sandboxed worker and
not from an unsandboxed process running as the same uid.
- On the .53 and .54 installs (#432) the stack runs as its own OS user. Workers run sandboxed as that user.
The T3 seats don't run there.
What a separate uid would buy. A same-uid process has two paths. It can forge a human proof (`verifyHuman` is
a cooperative same-UID check, `packages/bus/src/human.mjs:50-52`), and an approved grant then gives it a
two-scope token for a stack-made, non-admin user, because step 2 refuses foreign and admin users. Or it can
read the admin file, which gives instance admin (section 8). A separate uid for the file and the executor would
close the second path and leave the first. That is a real step up, from "a seat token for a stack user" to "no
admin".
I defer it on cost, not because it wouldn't help. It means the executor runs as its own process under its own
uid, with an IPC path from the bus that carries the decision id and nothing the bus could forge. That is a row
of its own, and it is cleaner after the human proof stops being same-uid (#435 behind Q175 sign-in). Jason
approves the gap as section 8 states it.
### 3d. Every use logged, and rotation
Every Gitea call made with the admin token, and every call the executor makes with a seat token, writes two
records (section 7 has where they live):
```
grant.call { callId, reason, method: "gitea", op } written before the call
grant.result { callId, status } written after it
```
- `op` is one of `version`, `self`, `token-scopes`, `repos-check`, `user-get`, `user-create`, `user-patch`,
`token-list`, `token-create`, `token-delete`, `admin-rotate`, `fork-get`, `fork`, `seat-self`,
`seat-repos`, `token-whoami`.
- `reason` is the decision id for a grant or its undo, the `grant.revoked` record id for a revoke, the
`grant.sweep` record id for a sweep, or `admin:<verb>` with the human actor for `set`, `rotate` and `remove`.
- No body, no header, no token, no URL query. `Credentials.assertClean` (`packages/bus/src/credentials.mjs:233`,
the runtime's `secretCheck`) runs on both. The private session-less writer of section 7 runs `#secretCheck`
on every guarded body it takes, `grant.step`, `grant.applied`, `grant.failed`, `grant.revoked`,
`grant.sweep` and the consume record included, as `#event` does (`packages/bus/src/broker.mjs:559`) (N7).
- `events` is append-only (`events_no_update`, `packages/bus/src/migrations/3b.sql:131`), so the result is its
own record, written in the transaction of the step's next `grant.step` where there is one. A `grant.call`
with no `grant.result` means the process died during the call. Reconcile lists each one in its message.
Rotation of the admin token: a human-only verb, `mosaic grants admin rotate gitea --rotate-by <date>`. It never
runs from an agent, the chief or the tester, and it doesn't need a decision because only `verifyHuman` can
start it:
1. C1 to C4 with the current token.
2. Mint a successor for the admin account itself: `POST /users/<account>/tokens` with Basic and the same three
scopes, named `mosaic-grant-<UTC yyyymmddThhmmssZ>`, so a second rotation on one day doesn't collide (F9).
F2's cap holds it to the current token's scopes.
3. C2 and C3 with the successor.
4. Write it through 199's tmp, fsync and rename. Update `admin.json` with the new `rotateBy` and a new `setAt`.
5. Delete the old token at Gitea by name, with the new token. If that fails, `status` shows
`adminTokenLeft: <name>` until `rotate --finish` succeeds.
A new `setAt` changes `adminSha256`, so every grant decision raised before the rotation and not yet run
refuses `decision-mismatch` (fail closed). `rotate` prints how many open grant decisions that voids, and
`status` lists them.
`status` shows the rotate-by date. Past it, the raise and the executor refuse `grant-admin-rotation-due` until
Jason rotates. A forgotten rotation stops grants rather than letting an old token run on.
If the admin token leaks, Jason deletes it in Gitea (Settings, Applications, as `mosaic-grant`) or disables
the account, then runs `mosaic grants admin remove gitea`, which unlinks the slot. Live agent tokens keep
working. Each one is revoked on its own (section 6).
### 3e. The one bootstrap step
Once per Gitea instance per stack, at #436b deploy. This is the only manual credential step left, and
Jason's approval names it:
1. In Gitea, Jason creates `mosaic-grant` as a site admin with no repositories and no organizations, turns on
TOTP, and as that user creates a token with `write:admin`, `write:user` and `write:repository`.
2. On the stack host, as the stack's OS user:
`mosaic grants admin set gitea --url <url> --account mosaic-grant --rotate-by <date>`, with the token on
stdin (never argv).
`set` is human-only (`verifyHuman`). It runs C1 to C4 (3a) before it writes anything, and any failure writes
nothing.
## 4. The executor's steps
0. **Start and pre-checks.** X1 starts the executor on Jason's yes. It claims `(agent, "gitea")` in
`#inflight` (#436a X5), then runs C1 to C4 and the admin rotate-by check. A refusal here writes
`grant.failed {code, grantDecision}` and leaves the decision unspent (section 7's spend rule). After the
fix (a rotation, an upgrade), Jason runs `mosaic grants run <decision>` (human-only, `verifyHuman`), which
starts step 0 again, claim included. That holds only while the bus that refused is up: at the next start,
#436a r6 X3's reconcile spends every resolved-yes grant decision with no `action.allowed` as
`interrupted`, a step 0 refusal included, and the chief raises again (D5, sage-1578). `status` lists each
such open decision with `spentAtNextStart: true`. While the agent is held (`grant-token-unknown`, section
5), step 0 refuses `grant-token-unknown`, so a revoke is the only way out (N8). While a start or another `grants run` holds the claim it refuses
`grant-busy` before any call, as #436a X2's `host-busy` does. It refuses `decision-consumed` for a spent
decision. No other path starts it. The tag is #436a X2 step 2's, over business, agent and `"gitea"`.
1. **Recheck and consume**, in one transaction: `#consumeGrant` with `operation: 'grant.gitea'`, the
definition still declares `gitea`, the three hashes match (section 2), no revoke of `(agent, gitea)` newer
than the raise. A failure rolls the transaction back, so the decision is not spent, and then writes
`grant.failed {code, grantDecision}`. Nothing remote has changed. `grants run` may retry it, but these
refusals (`decision-mismatch`, a newer revoke, the definition) recur until the chief raises again.
2. **User.** `GET /users/{user}` (F8: case-insensitive).
- 404: write `grant.step {step: 'user', nonce}`, where `nonce` is 16 random lowercase hex digits. Then
`POST /admin/users` with the agent's `gitea.user` and `email`, `full_name` = `<identity.name> [mosaic
<nonce>]`, a random password that is never stored, and `must_change_password: false`. Then, in one
transaction, record `gitea_users {user, id, nonce}` with the `id` from the response (F8) and
`grant.result`.
- 200, and `gitea_users` has this business and agent with the same `id`: go on. That is a second grant or a
rotation. A user deleted at Gitea and remade by a human under the same name has a new `id`, and refuses.
- 200 with any other `id`, or no `gitea_users` record: refuse `gitea-user-foreign`. The executor never mints
a token for a user this stack didn't create. Without this check an `identity` naming a human's account,
jason.woltje included, would get a token minted for that account on Approve.
- The user is a site admin, or has `prohibit_login`: refuse `gitea-user-admin` or `gitea-user-disabled`.
3. **Token.** Write `grant.step {step: 'mint', tokenName}`, with the name `mosaic-<tag>-<decision>`. Then
`POST /users/{user}/tokens` with Basic and scopes `write:repository` and `read:user`. The value is held in
memory only. Write `grant.step {step: 'minted', tokenId}`.
4. **Forks.** With the new token, for each project: `GET /repos/{user}/{repo}`. If it exists, its `parent`
must be the project's upstream, or `fork-foreign`. If it's 404, `POST /repos/{owner}/{repo}/forks`.
Push-to-create stays off on the Gitea side (199 N11).
5. **Verify.** With the new token: `GET /api/v1/user` gives `login == user` and the recorded `id`. Then
`GET /api/v1/user/repos?limit=50&page=N` from page 1 until a page holds fewer than 50, with the count
equal to `X-Total-Count`. More than 20 pages, or a count that disagrees, refuses `gitea-reach-unknown`.
Every listed repository must be owned by this user and have as `parent` the upstream of a project in the
agent's `publish` list (F11). Anything else refuses `gitea-reach-wider` and names it: a repository from a
collaborator or org-team add, or the user's old fork of an upstream that has left `publish`. So the token
never reaches past the rendered block (B3).
6. **Go live.** Write `grant.step {step: 'commit', previousTokenId}` (`null` for a first grant). Check the
abort signal, and with no `await` between, call 199 `issue(agent, "gitea", token, rotateBy, commit)`.
`commit` writes the `gitea_grants` record (tokenId, tokenName, projects, rotateBy, decision). The reference
is registered first (#436a X4).
7. **Old token.** For a rotation, `DELETE /users/{user}/tokens/{previousTokenId}` after the new one is live.
A failure adds `serviceLeft: <id>`, which `sweep` clears.
8. **Applied.** `grant.applied {grantDecision, agent, user, userId, tokenId, forks}` and a message to the human and
the chief. The token appears in no record, message or log.
The claim is released in a `finally`.
## 5. Failure and restart
Ordered so a failure leaves nothing an agent can use. "Delete the token" is `DELETE /users/{user}/tokens/{id
or name}` with the admin token, after C1 to C4, with `grant.call` and `grant.result` whose reason is the
decision (D8). Best effort; a failure adds `serviceLeft`.
**Settle.** Once `issue` has renamed the file, the file may hold the new token or the previous one, and the
record may name either. The executor and the restart never guess. They read the store file and ask Gitea which
token it is: `GET /api/v1/token` with that value in the `token` header (F7 for admin tokens, F13 for the
two-scope seat token; op `token-whoami`). The answer is an `id`, never a value.
- 200, `id` is the new token: forward. The `gitea_grants` record gets the new token (if `commit` hadn't
run), memory loads the file (`Credentials.reload(instance, service)`, a new method with load's checks,
built in #436b's row; row 199 doesn't change), and the previous token is deleted.
- 200, `id` is the previous token: back. The new token is deleted. The record keeps or gets the previous
token.
- 401: the file's token no longer exists at Gitea (F13 b4). The previous token can't be in the file, so the
agent holds nothing usable. Delete both by id, unregister, delete the record, unlink the file, and end
`grant.failed {code: 'grant-lost'}`. A new decision is needed. No file at all is the same case.
- 200, `id` is neither token: the file holds a live token this grant didn't mint and step 5 never checked.
Memory drops it (199 `revoke`'s memory step) and the agent gets `credential-unavailable`. The file, the
record and both recorded tokens stay, and nothing is deleted at Gitea. The step ends `grant.failed {code:
'grant-token-unknown', grantDecision, tokenId}`, where `tokenId` is the id Gitea gave, and Jason gets a
message. `status` shows `tokenUnknown`. At each start, before `Credentials` loads, the reconcile passes such
`(agent, "gitea")` pairs as `held` (a `Credentials` option built in #436b's row, like `reload`), so memory
never loads that file. A revoke (section 6) clears it: it unlinks the file and deletes both recorded tokens.
The unknown token is Jason's to look at.
- Anything else is `settle-pending`, and nothing is deleted: 403, 404, 5xx, a timeout, Gitea unreachable, or
C1 to C4 failing first. The last `grant.step` stays `commit`, the
`grant.call` without a `grant.result` stays listed, and `status` shows `settlePending`. The file stays as
it is, and the agent can use it: it holds one of the two tokens, and both passed step 5. The next start
and `mosaic grants sweep gitea` settle again. Only a 401 deletes.
| Fails at | State left | Cleanup |
|---|---|---|
| 0 | nothing, the decision unspent until the next start | none. `grants run` retries while this bus is up; after a restart the reconcile spends it (`interrupted`) |
| 1 | nothing | none |
| 2, before the create | nothing | none |
| 2, after the create, before the record | a user with the nonce | none here. Restart adopts it by the nonce (below) |
| 3, before `minted` | maybe a token nobody holds | delete the token by its recorded name |
| 4 or 5 | a token, maybe some forks | delete the token. Forks stay; they hold only upstream content |
| 6 before the rename, first grant | a token | unregister, delete the token |
| 6 after the rename, first grant | a store file with the new token, maybe the record | unlink the file, unregister, delete the record if written, delete the token |
| 6 before the rename, rotation | the previous grant intact | delete the new token |
| 6 after the rename, rotation | the file holds the new token, memory the previous | settle (forward: the new token passed step 5) |
| 7 | both tokens live, the new one in use | `serviceLeft: <previousTokenId>` |
**Restart**, before `Credentials` loads, from the last `grant.step`, as #436a X3. The local part runs first;
the remote part runs after the bus is up, with C1 to C4.
| Last step | Local | Remote |
|---|---|---|
| `user` | none | `GET /users/{user}`. 200 with `full_name` ending `[mosaic <nonce>]`, not admin: adopt, record `gitea_users` with its `id`. 404: none. Anything else: `gitea-user-orphan` with the name, for Jason to look at; nothing is deleted |
| `mint` or `minted` | none | delete the token by name |
| `commit`, first grant | unlink the file if present, delete the record if present | delete the new token |
| `commit`, rotation | none | settle |
| `commit`, rotation, settle-pending | none; memory loads the file as usual | none. The step stays open, and the next start or `grants sweep` settles again |
| `grant.failed {code: 'grant-token-unknown'}` with no `grant.revoked` after it | pass `(agent, "gitea")` as `held`; memory doesn't load the file | none. Jason's message repeats until a revoke |
Every reader of grant evidence (this reconcile, `status`, `openGrantDecisions`'s callers, `grants sweep`)
reads a `grant.*` record only where `kind = 'action.allowed'` and `actor_role IS NULL AND actor_run IS
'grant'`, so a `grant.*` name under another kind or actor is never evidence (duckworth-0067 N1).
Then `grant.failed {code: 'interrupted'}` (or `grant.applied` if a rotation settled forward) and a message to
Jason. A `grant.call` with no `grant.result` is listed in that message. After `interrupted`, the chief raises
again and Jason approves again.
## 6. Revoke
As #436a section 6, with the same revokers (the human, the assistant in its own organization, the tester on
dev only):
1. 199 `revoke`: memory, abort a use or the executor in flight, unlink the file.
2. One store transaction deletes the `gitea_grants` record and records `grant.revoked`.
3. After C1 to C4, `DELETE /users/{user}/tokens/{tokenId}` with the admin token. If Gitea can't be reached,
`grant.revoked {serviceLeft: <tokenId>}`, and `mosaic grants sweep gitea` clears it later. When the last
`grant.step` is a rotation's `commit` with no settle after it (settle-pending, or `grant-token-unknown`),
two tokens are live and the record may name either (section 5), so the revoke deletes both
`previousTokenId` and the minted `tokenId`, each failure listed in `serviceLeft` (dewey-0761). The id
`grant-token-unknown` named is not one this stack minted; it stays for Jason.
That closes 199 D12's gap: the token is gone at Gitea without Jason's A5 step.
The Gitea user and its forks stay after a revoke, so a new grant can reuse them.
On `credentials delete` (a fire), after the steps above and with the recorded `id` checked first:
- `GET /users/{user}/tokens` with Basic (F9), and delete every token it lists, not only the recorded one. A
token the record doesn't know about doesn't outlive a fire.
- `PATCH /admin/users/{user}` with `prohibit_login: true` (F10).
- Each step that fails is listed in `serviceLeft`, and `sweep` retries it.
## 7. Store records
Two tables, in #436a S1's migration if #436a row b and this row land together, otherwise a `3j-3k.sql`:
- `gitea_users`: business, agent, user, id, nonce, the decision that created it.
- `gitea_grants`: business, agent, user, tokenId, tokenName, projects, rotate-by date, decision.
The executor, revoke and `sweep` are the only writers, each in the transaction of its record. The admin slot
is a file, not a table, so a store backup holds no admin token.
The audit records (`grant.step`, `grant.call`, `grant.result`, `grant.applied`, `grant.failed`,
`grant.revoked`, `grant.sweep`) are `action.allowed` events with that name in `body.action`. They are not new
event kinds, so `events` and its CHECK (`3b.sql:9-30`) aren't rebuilt. This section is the rule's only home
(mos-1144). #436a r6 (f9039682e) uses it too: #436a row a's BUILD.md cites this section at the commit Duckworth
approves and maps r6's names to `body.action` values, with these renames (gizmoduck-0084):
`host.enroll.step` to `grant.host.enroll.step`, `host.enrolled` to `grant.host.enrolled`, `host.checked` to
`grant.host.checked`, `host.removed` to `grant.host.removed`. Cites are origin/refactor 00d4cc1f6.
**The guarded namespace** is one pattern: an `action.allowed` whose `body.action` or `body.operation` GLOBs
`'grant.*'`. `operation` is included because `#consumeGrant`'s consume record is
`{operation: 'grant.<method>', decision}` (#436a X1), and a forged one would spend a decision (Dewey N2). Row
210's `host.sudo` and `host.run`, written from agent sessions, stay outside it, and no grant-path name is left
under `host.*`.
**The grant actor** is `actor_role IS NULL AND actor_run IS 'grant'`. The only production insert into `events`
is `#event` (`packages/bus/src/broker.mjs:568`), which writes the session's role and run. Every session with a
NULL role also has a NULL run: the human (`:537`), the reader (`:549`) and a session-less record (`:1124`). The
tester's role is `tester`. So neither a session nor the human path can produce that pair, as no instance can
be named `_bus` (`:166-168`). A `system` role would not be safe, because `system` matches `ID_PATTERN`
(Dewey N1). The broker writes guarded events, the consume record included, through a private session-less
writer added in #436a row a; `#event` is unchanged. Revoke, sweep and check run for the human, the assistant,
the tester or the chief, and their events still go out under the grant actor, with `body.by` naming the caller
the broker checked.
**Two guards** stop a forged record:
- `recordEvent`, the trusted adapters' path (`broker.mjs:1111`), refuses `reserved-event` for any
`action.allowed` in the guarded namespace, as it does today for `git.push.working` and the merge action.
- The migration adds a trigger: an `action.allowed` in the guarded namespace aborts unless
`actor_role IS NULL AND actor_run IS 'grant'`. That covers any path that misses the first guard.
**Spend rule (B1).** `#spent` (`broker.mjs:811-817`) counts any `action.allowed` that names the decision in
`body.decision` and whose `operation` isn't `decision.raise`; a record with no `operation` counts. So the
consume record is the only guarded body with a `decision` key. Every other `grant.*` body names the decision
`grantDecision`. A step 0 or step 1 refusal therefore leaves the decision unspent, and the start reconcile
for "resolved yes, unspent" keeps reading `#spent` unchanged.
A wider gap sits under this one and isn't this row's: today any `recordEvent` `action.allowed` that carries
`body.decision` spends that decision. Follow-up for Sage to file.
**Open decisions (N6).** One query, `openGrantDecisions(business, method)`: `credential.mint` decisions
resolved yes by the human whose raise body has `grant.method = method`, and for which `#spent` is false.
`status` lists them, `grants admin rotate` counts the ones it voids (all of them, since `adminSha256`
changes), and the start reconcile reads the same query.
**No task refs.** Guarded bodies never carry `target` or `task_ref` holding a task ref, so they stay clear of
`task-subject-required` (`broker.mjs:561-565`) on any path. No other `action.allowed` reader sees these
events: the rest key on `decision.raise` (`:670`, `:803`), `git.push.working` (`:863`, `:2019`) or
routine (`:2100-2102`).
## 8. Approval notes for Jason
Stated once, here, for the #436b approval:
- **Site admin plus `write:user`, and `write:admin`.** The admin token can mint a token for any Gitea user,
with any scope up to its own three, including users the stack didn't create (F4). With `write:admin` it can
also set any user's password, email and admin flag, and disable any user (F10, proven on scratch against a
second site admin). Whoever holds the token can take over any account on that Gitea, jason.woltje's
included, make any user a site admin, and read or write any repository. The executor uses none of that
beyond its steps, but the token itself can.
- **Same uid on dragon-lin.** The token is a file readable by jwoltje, and every agent process on dragon-lin,
T3 dev seats included, runs as jwoltje (3c). The seat sandbox keeps sandboxed workers away from it. It does
not keep an unsandboxed same-uid process away. Such a process gets instance admin by reading the file, which
is more than it could get by forging an Approve. A separate uid would close that path. It is deferred on cost
(3c), not because it wouldn't help.
- **For comparison, today (D12).** Every dev seat on dragon-lin already pushes as jason.woltje through
`scripts/git-credential-mosaic.mjs` (mos-1106). jason.woltje is a site admin on git.mosaicstack.dev, with
restricted false (mos-1146: Mos read `/api/v1/users/jason.woltje` with the mos token at 23:05:29Z; Jason's
token wasn't used). So a same-uid process on dragon-lin can already act as a site admin there through that
credential, and the admin file adds little exposure on that Gitea beyond what the seats already have. It
still matters on any other Gitea a business grants on, and once seats stop pushing as jason.woltje (#431).
- **A seat token's reach can grow.** Step 5 checks, at the grant, that the token reaches only its forks. If
someone later adds the user to an org or as a collaborator, the token reaches that too, until the next grant
or rotation checks again.
- **The admin account is dedicated by promise.** Code checks that it is a site admin with these three scopes
and no reachable repositories (C1 to C4). It can't check that nobody else uses it or that it is in no org.
- **The bootstrap step** (3e) is the one manual credential step: Jason creates `mosaic-grant`, its TOTP and
its token, and enters the token once on stdin.
## 9. Tests and mutants (for the row, not built here)
Unit tests use a fake Gitea over TLS, as row 199's publish tests do. Section 1's facts get a scratch
gitea/gitea:1.27.1 test on 127.0.0.1 with throwaway data, removed after. No test contacts git.mosaicstack.dev
or any other live service. No token value is printed.
| Test | Claim |
|---|---|
| X-G1 | a gitea raise for an agent without `gitea`, `identity` or `publish`, or a project outside `publish`, a remote on another host, no admin slot, or a slot past rotate-by refuses and stores nothing |
| X-G2 | `set` refuses a version below 1.27.0 (fake reports 1.26.4, 1.27.0-rc0, and 1.9.0 against 1.27.0 as a string trap), a non-admin, a login mismatch, an account that reaches a repository, a token with another scope or `all`, and a second `url`. It writes nothing on any refusal |
| X-G3 | on scratch 1.27.1, end to end: user made with the nonce and its `id` recorded, token minted with the two scopes, forks made, step 5 passes, `issue` writes the 0600 file, `grant.applied`, then revoke deletes the token at Gitea (gone from its list) |
| X-G4 | an existing user with no record, a recorded name whose `id` differs (deleted and remade, and a case variant of the name), a site-admin user and a disabled user each refuse before any token call |
| X-G5 | each failure row of section 5 leaves the stated state, rotation rows included. A failed delete leaves `serviceLeft`. A revoke while a rotation's `commit` is settle-pending, and one after `grant-token-unknown`, deletes both `previousTokenId` and the minted `tokenId` at the fake, and a failure of either lands in `serviceLeft`. A new grant for a held agent refuses `grant-token-unknown` at step 0, before any Gitea call |
| X-G6 | a restart after `user` adopts a user whose `full_name` ends with ` [mosaic <nonce>]` (16 lowercase hex), whose lowercased login equals `gitea.user` lowercased and who isn't an admin; does nothing on 404; and reports `gitea-user-orphan` for a different `full_name`, a different login or an admin. A restart after `mint`, `minted` and `commit` of a first grant leaves no token at Gitea, no file and no record. A restart after `commit` of a rotation settles forward when the file holds the new token and back when it holds the previous one, ends `grant-lost` only on a 401, and stays `settle-pending` with nothing deleted on 403, 404, 500, a timeout and an unreachable fake. On 200 with an unknown `id` it drops the credential from memory, deletes nothing at Gitea, keeps the file, ends `grant-token-unknown`, and a second restart still doesn't load the file; a revoke then clears it |
| X-G7 | an identity, publish, upstream or admin slot change between raise and Approve refuses `decision-mismatch` before any Gitea call. An admin rotate voids open grant decisions and `status` lists them |
| X-G8 | every admin-token and seat-token call writes `grant.call` before it and `grant.result` after it, paired by `callId`. A crash between them is listed by reconcile. No record body holds a token |
| X-G9 | admin rotate: C1 to C4, the successor named by time, checked, written, the old token deleted. Two rotations in one second's name refuse cleanly, not half done. Past rotate-by, the raise and the executor refuse |
| X-G10 | seat-token rotation: the new token is live before the old one is deleted |
| X-G11 | a step 0 refusal (rotation due, too old) and a step 1 refusal leave `#spent` false, and `grants run` by the human gets past `#consumeGrant`. `grants run` from an agent, the chief or the tester refuses. A `grants run` while a start holds the claim refuses `grant-busy` before any Gitea call. No `grant.*` body other than the consume record has a `decision` key. A step 0 refusal, then a restart, then `grants run`: the reconcile has spent the decision as `interrupted` and `grants run` refuses `decision-consumed`; before the restart `status` shows `spentAtNextStart` |
| X-G12 | step 5 refuses `gitea-reach-wider` when the user was made collaborator on another repository, when it joined an org team, and when it owns a fork of an upstream that has left `publish`, and deletes the token. It passes with an old fork of a `publish` project outside `target.projects`, which the rendered block named. A fake returning 51 forks over two pages is read in full; a total that disagrees refuses `gitea-reach-unknown` |
| X-G13 | `recordEvent` refuses an `action.allowed` with `body.action` `grant.call` (and each `grant.*`) and one with `body.operation` `grant.gitea`. A direct store insert aborts on the trigger for `grant.step` and `grant.host.enrolled` from a session, and for `grant.step` under the human's NULL pair. `host.sudo` from an agent session still writes |
| X-G14 | fire: every token the user has is deleted, the user gets `prohibit_login`, and a failure of either lands in `serviceLeft` |
| X-G15 | a `grant.step {step: 'commit'}` stored under `action.refused`, and one under the human's NULL pair, are ignored by the restart reconcile: no settle runs and no Gitea call is made |
Mutants, a first list: drop the version floor; compare the version as a string; accept another admin scope;
skip `gitea-user-foreign`; compare the user by name instead of `id`; skip the admin-user refusal; adopt a user
at restart without the nonce; create the user before writing `grant.step user`; mint before writing
`grant.step mint`; delete the old token before `issue`; settle a rotation by the record instead of
`token-whoami`; run C1 to C4 after `#consumeGrant` instead of before; drop the `identitySha256` compare; drop
`adminSha256`; skip the `grant.call` write; write `grant.result` before the call; drop the `grant.` reservation
in `recordEvent`; drop the trigger; widen the trigger to `host.*` (breaks `host.sudo`); guard `body.action` only, not
`operation`; key the trigger on `actor_run` alone; put `decision` in step 0's `grant.failed`; delete both
tokens on a settle 403 or 5xx; compare step 5 against `target.projects` instead of `publish`; read one page
of `/user/repos`; let `grants run` skip the claim; send the admin token in a `token` header on the mint route; revoke the
remote token before the local steps; skip the fork `parent` check; skip step 5's reach check; delete only the
recorded token on fire; let `grants run` accept an agent; keep the file in use on a settle 200 with an
unknown `id`; load a `held` pair at start; revoke only `tokenId` on an open rotation; let step 0 pass a held agent; drop the `kind` filter from the restart reconcile.
## 10. Shared with Bushroot's GitHub half (proposed in megavolt-0036, answer pending)
- S1. The `grant` body: `{method, agent, rotateBy, target}`, scopes fixed by code per method.
- S2. The admin slot: `<configDir>/secrets/grants/<business>/<method>/admin`, set once by
`mosaic grants admin set <method>`, never an agent service. One service instance per method per business.
- S3. `grant.call` and `grant.result` (r2: two records, paired by `callId`) for every service call, as reserved
`action.allowed` bodies (section 7).
- S4. The executor shape: pre-checks before consume (step 0), claim, consume, mint, verify, `issue`, applied,
with `grant.step` before and after the mint, and settle by asking the service which credential the file
holds. GitHub may not fit "mint once and store" (App installation tokens expire within an hour). If so, S4
becomes per method and the rest stays shared.
- S5. Revoke local first, then the record, then the service, with `serviceLeft` and `grants sweep`.
- S6. A capability floor per method, checked at `set`, before every start and before every remote undo.
Dewey notes items 2, 3 and 5 of dewey-0754 apply to S3, S4 and S6. The r2 text above carries them.
## 11. Questions
- Q-B1. Answered (dewey-0754 item 9): on fire, `prohibit_login` and delete every token (section 6). The PATCH
route is proven (F10).
- Q-B2. Answered (dewey-0754 item 10): no organizations. The token's real reach is checked at step 5 and
stated in section 8.
- Q-B3. Bushroot's answer on S1 to S6.
- Q-B4. Answered (mos-1146): jason.woltje is a site admin on git.mosaicstack.dev, restricted false. Section 8
says so.
- Q-B5. Answered (gizmoduck-0081, -0084, mos-1144): one rule for #436a and #436b, section 7. #436a row a's
BUILD.md cites section 7 at the commit Duckworth approves, or carries its text in full if row a's candidate
comes first. Duckworth checks it with that candidate.
-167
View File
@@ -1,167 +0,0 @@
# Row 199 (#1697): per-agent credential store, each agent's own git identity, publish to its fork
Builder: Megavolt. Brief: `docs/plans/2026-10-20_agent-credentials.md`. Design: `DESIGN.md` r6 at
4d3007ec9, approved by Duckworth in duckworth-0027 (REVIEW-r6.md at 640011518). Authority model ruled
by Mos in mos-1027. Branch: megavolt/row199. Reviewer: Duckworth.
Status: in progress. The full gate has not run; it is Sage's. Only single test files have run on
this branch, apart from one slip noted under "Runs".
## Commits so far
- 286bf598c business: agent identity, the publish grant, the project upstream with the D9c parse, store
credential references; git.push.remote as a hard wait.
- a2c387dba bus: broker publish authority (checkPublish, recordPublish, publishRefused, pushCommit on
the raise); the D9c parse moves to `business/src/vocabulary.mjs`; pm.md and chief-of-staff.md list
git.push.remote among the hard waits.
- 609dc9364 bus: the per-agent credential store in `credentials.mjs`.
- 504336797 bus: runtime wiring, the store root and the four publish entries in the op table.
- a18fc6cf8 tasks: request calls use `useFor` keyed from the session; the start probe keeps `use`. The
D5 test in `credential-store.test.mjs` pins the `use(` call sites to `credentials.mjs` (useFor's own
call) and `tasks/src/adapter.mjs` (the host's @sync and start probe).
- aa740cc71 bus: the lifecycle verbs `credentials.issue`, `credentials.revoke` and `credentials.delete`
in `credential-verbs.mjs`, the broker branch, the runtime wiring; `organizationWriter` gets a
`detail` on `write` and a receipt-only `record`.
- 0cdace415 cli: `mosaic credentials status|issue|rotate|revoke|delete`, the scripts/mosaic route, docs.
## Deviations from DESIGN.md r6
1. **Identity is optional on an agent.** D8 reads as identity on every agent. Today's business files
carry none, so identity stays optional and `publish` requires it (N10's load refusal). An agent
without identity commits as E7 (T6).
2. **Revoke of a `{file}` or `{env}` reference.** Section 6 describes revoke for store references.
For a reference outside the store, revoke forgets the token in memory and never unlinks the file;
the lifecycle verb then turns the business file's reference into `store`, so a restart finds
nothing. The test: `credential-store.test.mjs`, "revoke of a {file} reference".
3. **Issue order.** Issue writes the token file (tmp, fsync, rename, dir fsync), then runs the
caller's commit (the business file's reference becomes `store`), then sets memory. A failed commit
leaves the file on disk and the token out of memory, so the agent stays `not-issued` until the
issue is run again. The test: "a failed commit leaves the token on disk but not in memory".
4. **The D9c parse lives in `business/src/vocabulary.mjs`, not `business.mjs`.** The broker reads
`forkProblem` and `forkUrl`, and `project-runtime.test.mjs` pins the broker's import closure to
files that import nothing. `business.mjs` wraps `parseGiteaUrl` with the loader's refusal
messages. The pin test names the two new imports.
5. **`withGitea` takes the publish ticket, not a cap (D9b).** The key is the session checkPublish
checked with `#agent` (a run holding its claim; never a tester, reader or human), read through
`broker.publishSession(ticket)`. The token is usable only between checkPublish and recordPublish
or publishRefused. Its refusals are filed once, by publishRefused on the task's trail, and not
also by `refusing()`. The test: `publish.test.mjs`, "runtime: withGitea keys from the open ticket
only".
6. **Role contracts.** `workflow-roles.test.mjs` requires the hard waits listed in `roles/pm.md` and
`roles/chief-of-staff.md` to equal GATED_ONLY. Adding git.push.remote to the vocabulary made it
fail, so both lists now name git.push.remote. Prose only; no hash pin reads either file. Duckworth
should review this hunk because `roles/` is reviewed authority.
7. **Delete takes a reason.** Section 6 lists `delete AGENT` with no reason. The broker requires
`reason` on delete as on revoke, so every receipt that ends a credential says why.
8. **Rotate is `credentials.issue`.** There are three bus verbs, not four. An issue over an entry the
bus holds is recorded as `rotate` in the receipt and the answer. The CLI's `rotate` reads the
status first and refuses before reading stdin when nothing is issued.
9. **Issue covers entries the bus loaded.** The business file must already name the kind for the
agent, and the bus must have loaded that reference at start; otherwise `credential-unknown`. An
agent added after the bus started needs the restart row 165 already asks for. Row 194's human
`create` is where a new agent's `store` references come from.
10. **No Console page yet.** Section 6 puts the verbs in the Console's agent page too. This branch has
the bus verbs and the CLI. The Console page is a separate piece (packages/webui, its `BUS_VERBS`
and `STATUS` pins); I raise the split with Sage before the manifest. Ruled in sage-1421: the
Console page is out of row 199 and becomes a follow-up row with Webby as reviewer. The manifest
holds bus, business, tasks, cli and seat.
11. **The push runs in the bus child, and the git socket polls for it (D9b).** The git socket runs in
the host process. The broker, the runtime and the token are in the bus child that
`packages/cli/src/host.mjs` forks, and host ops cross that IPC one at a time with a 10 s answer
limit. ls-remote and the push can run longer than that, so `withGitea(ticket, fn)` can't wrap a
host-side git. The split: the socket runs D9 steps 3 and 4 (tip and identity under the lock,
`working.publish`), then op `publishPush`, which starts `publishRemote` in the bus child under
`withGitea` and answers `{state: 'running'}`. The socket polls op `publishResult` every 200 ms
until it reads `pushed` or `refused`. Fork, user, source and destination come from the ticket
(`broker.publishTarget`), never from the host's message. `recordPublish` also refuses `ref-moved`
when the pushed ref isn't the ticket's destination. A second `publishPush` on a running ticket
refuses `publish-busy`. The token reaches git on fd 3 only (`runGit` option `pass`), read once
by a one-shot credential helper that answers `get` for https on the fork's exact host and port.
On close, a running publish's git isn't awaited; git's own time limit ends it, and the ticket
goes with the process. The 10 s IPC limit stays as it is (sage-1440). The poll has a bound
(sage-1440): `publishDeadlineMs`, by default four times git's limit and grace (publishRemote runs
at most four git commands) plus 30 s. Past it the socket refuses `publish-timeout`, filed once by
publishRefused, and logs that the bus process's push may still reach the fork. A failure that is not a refusal is filed as `git-failed` and logged on
the bus's stderr by error name and code only. Tests: `seat/tests/publish.test.mjs` (11),
`bus/tests/publish.test.mjs` (the process ops), `cli/tests/publish-socket.test.mjs` (7, end to
end through the socket, the in-process runtime, the store and a TLS fake Gitea; one is the
publish-timeout outcome).
12. **No harness tool for publish.** `packages/harness/src/git-tools.mjs` has push, fetch and land
clients. A runner tool that calls the socket's `publish` op is in packages/harness, outside the
manifest scope sage-1421 set. Today a publish needs a socket client; the tool is a follow-up.
13. **The bind rule's `git.push.remote` clause has no test of its own.** D11 adds it and the
launcher has it. A role can publish only a push its own role made (`work-not-yours`), and
`recordPush` needs `git.push.working`, which already binds the socket. No role reaches the
clause alone, so dropping it changes no outcome today. It matters once a role can publish work
it didn't push. Ruled in sage-1440: keep it; its mutant goes in the section 11 list as
equivalent, with this reasoning, and Duckworth judges it.
14. **A push between checkPublish and the git push refuses; it never lands a different commit.**
T3 says such a push "doesn't change the commit that lands". Under the hard wait it refuses:
`working.publish` checks the work ref's tip against the ticket's source under the lock
(`ref-moved`, `seat/tests/publish.test.mjs`), and `recordPublish` checks `pushCommit` again
(`decision-mismatch`, `bus/tests/publish.test.mjs` "r4 B4"). The fork gets only the ticket's
oid either way. N9's routing-commit test still applies. Ruled in sage-1440: T3's wording in
DESIGN.md now reads "refuses with `ref-moved` or `decision-mismatch`", and an end-to-end test
asserts each code mid-flight in `cli/tests/publish-socket.test.mjs`: a push after checkPublish
refuses `ref-moved` with no request to the fork; a push recorded while the bus process pushes
refuses `decision-mismatch`, filed once, and the fork holds only the ticket's oid.
15. **Fix: rotate on a due gitea key (gizmoduck-0050, sage-1494).** The CLI's `ISSUED` set
(`packages/cli/src/credentials.mjs`) listed valid, expiring and expired, but the bus's `status()`
gives a gitea key past its rotate-by date `rotation-due`. So `credentials rotate` refused the one
case it exists for. `ISSUED` now names every state status() gives a held token. The test:
`cli/tests/credentials.test.mjs`, "rotate goes ahead on each held state". Its mutant (the old set)
fails that test and goes in the section 11 list.
## N9, for the routing commit (sage-1388)
Today git.push.remote is a hard wait, so every publish carries a decision, and the decision's
`pushCommit` fixes the commit. If a later commit routes git.push.remote within role, a publish has
no decision and no `pushCommit`. `#publishFacts` then takes the commit from `#latestPush`, and the
git socket's tip check under the lock is the only thing that holds it. That commit needs a test
that a push between checkPublish and the git push doesn't change what lands.
## R1, the backup set
Checked at 504336797. Row 185's `backup_store` (`scripts/stack-release.sh:372-392`) copies only
`<dataRoot>/bus/bus.sqlite`, plus its sums, modes, listing and stamps, into
`<dataRoot>/backups/stack/<activation>/`. Nothing under `<configDir>` is copied, so
`<configDir>/secrets/agents` isn't in the backup set. `rehearse` copies the live business file
through `R_BUSINESS_JS` (`:1268-1288`). Its `swap` replaces every credential reference, a `store`
one included, with a placeholder file in the rehearsal's own dir. The guard then refuses
`credential-ref-live` unless every reference is such a placeholder (`:1329-1337`), so a rehearsal
never reads a store token. A backup tool outside this repository that copies `<configDir>` would
copy tokens. That is R1's open half, and it is Jason's to check on each VM.
## T1 limit
A wrong owner on a store dir or file can't be set up without root, so T1 covers modes, symlinks and
dirs. The owner check (`st.uid !== process.getuid()`) is in `privateDir` and `#read`, and is untested.
## Runs
Single files, each green on its commit: bus broker, decision-close, decision-route, decisions,
hard-waits, land, project-runtime, launch-notice, review, publish, credential-store, credentials,
credential-status, limits, tester, plugins-verb, routes-verb, limits-runtime, instances-verb, web,
usage-runtime, limit-checks, daily-quota-runtime, chief-of-staff, tasks, tester-writes; every
business test file. Since: credential-verbs (2/2), credential-store (11/11, with D5), each tasks file,
tester, credential-status, plugins-verb, routes-verb, instances-verb; business launch-settings,
plugin-settings, instance-settings, token-limit-settings; cli credentials (3/3), cli, app,
tester-reads, tester, other-tree, transport, instances, limits, verbs. Hash pins: none on a changed
file (`thread-migration.test.mjs:46` pins DDL only).
Publish, single files, each green before its commit: seat
checkout (77), working (26), publish (11), landing (8); bus process (8), land (17), publish (12),
credential-verbs (2); cli publish-socket (4), git-socket (24), launcher (29); harness tools (22).
Hash pins: none on a changed file.
sage-1440 changes (the poll bound, the mid-flight tests), single files, green before the commit: cli
publish-socket (7), git-socket (24), launcher (29). Hash pins: none on a changed file.
The rotation-due fix, single files: cli credentials (4), green; the old-set mutant fails it 3/1.
Hash pins: none on a changed file.
Slip, 2026-10-20 about 19:51Z: I ran `node --test packages/tasks/tests/*.test.mjs` in one command,
which is a package suite, without asking Sage for the slot. It passed, 97 pass and 0 fail, in under a
minute. Reported to Sage in megavolt-0017.
-430
View File
@@ -1,430 +0,0 @@
# Row 199 design: a credential store per agent, own git identity, pushes to a remote
Megavolt, 2026-10-20. Gitea #1697, MVP T1 item 6. Brief: `docs/plans/2026-10-20_agent-credentials.md`.
Reviewer Duckworth. Mos rules on the authority model (section 9) before any code. Base: refactor at 992650232.
No credential value was read to write this. Evidence is code and docs only.
r2 answers duckworth-0013 (review at 6ab9cd927 on duckworth/reviews): B1 in D3a, T1 and T5, B2 in D9 step 5
and T3, N1 in section 6 and Q-M3, N2 in section 6, N3 in D9 step 3, N4 in D9a and T3, N5 in E5 and D9b.
r3 records Mos's rulings (mos-1027) in section 9. The Q-M2 hard wait changes D9 step 1, D9b and T3. Q-M1
drops O2 (D10, A3, R2).
r4 answers duckworth-0017 (REVIEW-r3.md at 4065ed59f): B3 in T3 and D9 step 5, B4 in D9 steps 1, 3 and 5, N6
and N7 in D9a, N8 in D9 step 1.
r5 takes webby-0011 N1 (row 194 review, through honker-0009): the host checks at each push that the
remote's owner is the agent's own Gitea user. r4 named one `remote` per project, which can't be a fork per
agent (O1) once two agents publish the same project. So `projects.<p>.remote` now names the upstream
repository, and the host builds each agent's fork URL from it (D9 step 2, D10, T3). It also fixes a conflict
found while building: D8 made `identity` required for every agent whose definition declares `gitea`, while D9
step 2 says today's files load unchanged. Coder declares `gitea`, so r5 makes `identity` optional and
`publish` require it (D8).
r6 answers duckworth-0026 (REVIEW-r5.md at 3cb6888e1), with Mos's acceptance (mos-1043). B5: the derived URL
has one name, `fork`, and D9a's `ls-remote`, step 5's push, the helper's host match, `checkPublish`'s result
and step 6's event all use it (D9, D9a, D9b, T3). B6: the URL parse is written down (D9c) and tested (T3).
N10 in D8 and T3. N11 in D9a and A3. N9 is carried to BUILD.md for the routing commit.
## 0. Terms
- **Stack instance**: one Mosaic Stack deployment (row 190). It has one OS user, one config dir
`~/.config/mosaic-NAME` (here `<configDir>`), one data root and one bus. Case 1 and Case 2 are two stack
instances on two VMs (DECISIONS Q180).
- **Agent**: a role instance in a business file (`roles.<agent>`). Today's code calls it "instance". This
design says agent.
- **Kind**: a credential type: `gitea`, `vikunja`, `model`.
- **Host**: the bus process and its git socket, running as the stack instance's OS user, outside any sandbox.
## 1. What exists (measured at 992650232)
| Code | Fact | Where |
|---|---|---|
| E1 | The business file references credentials per agent: `roles.<agent>.credentials.<kind> = {file or env, rotateBy or expires}`. The kinds an agent may hold are the ones its role definition lists | `business/src/business.mjs:89-111`, `credentials.mjs:18-38`, `role.mjs:105-118` |
| E2 | The bus reads each token once at start (O_NOFOLLOW, own uid, 0600, at most 16 KiB, outside the repos and data root) into a private map keyed `<business>/<agent>:<kind>` | `bus/src/credentials.mjs:18-89`, `bus/src/runtime.mjs:78-89` |
| E3 | Only Vikunja uses a token: `credentials.use("<business>/<agent>", "vikunja", ...)`. Gitea tokens are loaded and never used. `assertClean` refuses any event that contains a token | `tasks/src/adapter.mjs:70`, `runtime.mjs:134`, `broker.mjs:560` |
| E4 | Agents run in bwrap as the bus's own uid. `~/.config`, `~/.mosaic` and `~/.ssh` are not bound. The git socket is bound only for `git.push.working`, `review.verdict` or a landing | `seat/src/sandbox.mjs:21-30, 104-124`, `cli/src/launcher.mjs:401` |
| E5 | A session proves who it is with a 64-hex cap the broker mints and maps to a frozen session record. The git socket resolves the cap to (business, role) before anything else | `broker.mjs:280, 379-383`, `packages/cli/src/git-socket.mjs:398-407` |
| E6 | The model credential is one env file per host (`EnvironmentFile=-%h/.config/mosaic-dev/secrets/claude-session.env`). `ENV_ALLOW` passes it into every sandbox, so every agent holds the same model token | `cli/systemd/mosaic-bus.service.in:24`, `seat/src/session.mjs:83-94` |
| E7 | Commits in a session's clone use `<agent>@<business>.mosaic.invalid`. Landed squashes use "Mosaic bus host" | `sandbox.mjs:151-157`, `working.mjs:280-281` |
| E8 | No code pushes to a remote. The git socket pushes bundles into a local bare working repo with no remote and no credential | `working.mjs:1-14, 198-238` |
| E9 | Adding or changing an agent refuses a definition that declares credentials (`organization-credentials-out-of-scope`). Binding them is a hand edit. Row 194's design keeps that refusal for create and hire "until the credentials row" | `business/src/instance-settings.mjs:20-29`, row 194 DESIGN.md D1 |
| E10 | The development repository's helper pushes as one identity from one JSON file | `scripts/git-credential-mosaic.mjs` |
So per-agent references and a host-only reader already exist for two kinds. What's missing: a fixed place for
the files, an agent identity, any remote push, lifecycle verbs, and a model credential that agents don't
share.
## 2. The store (brief item 1)
D1. **Layout.** Each stack instance keeps its agents' secrets under its own config dir:
```
<configDir>/secrets/agents/ 0700, owner = the stack instance's OS user
<configDir>/secrets/agents/<business>/<agent>/ 0700
<configDir>/secrets/agents/<business>/<agent>/<kind> 0600, the token only, one line
```
This is row 190's secrets root (`<configDir>/secrets/...`, as Q175's slot uses). The token file keeps E2's
format, so the bus reader doesn't change. Nothing in a token file is a name, an email or a URL. Those are not
secret and go in the business file (D6), where review sees them.
D2. **The business file reference.** A new reference form, `{"store": true, "rotateBy" | "expires": DATE}`,
means "the file at the layout path for this business, agent and kind". The path is derived, never written,
so an agent's entry can't name another agent's file. The old `{file}` and `{env}` forms stay valid for
migration (section 7). A `{file}` reference that points inside `<configDir>/secrets/agents/` but not at its
own agent's path refuses `credential-not-yours` when the business file loads.
D3. **Read path.** Unchanged in shape: the bus reads every entry once at start (E2) and on a store change
(section 6), and holds the values in memory. No sandbox binds `<configDir>` (E4 already holds). No token goes
into a sandbox for `gitea` or `vikunja`. The agent asks the bus to act, and the bus uses the token.
D3a. **A `store` reference with no file.** ENOENT on a `store` path loads as an absent entry. `status` shows
`not-issued`, and `useFor` refuses `credential-unavailable`. The bus still starts. This is the state after a
create without `issue` (D13) and after `revoke` (section 6). Every other defect still refuses start: wrong
owner or mode on the file or a dir, a symlink, a non-regular file, bad format, a missing or wrong-mode
`secrets/agents/` dir. `{file}` and `{env}` keep E2's behavior, where a missing file refuses start.
## 3. The "RLS equivalent" (brief item 1)
Three layers. Each one is enforced by code or the OS, not by prose.
D4. **OS, between stack instances.** Files are 0600 and dirs 0700, owned by the stack instance's own OS user.
Case 1's user can't read Case 2's store (Q180: each instance under its own user without sudo). The bus refuses
to start when any file or dir under `secrets/agents/` has the wrong owner or mode (`credential-file`, as E2
does for files today, extended to the dirs).
D5. **Bus, between agents of one stack instance.** The principal for every credential use comes only from the
verified cap (E5). The agent-facing API is `credentials.useFor(session, kind, op)`: `session` is the broker's
frozen record, and the key is built from `session.business` and `session.role`. It has no agent argument, so
there is nothing in a request to point at another agent. The existing `use(name, kind, op)` stays for the
host's own `@sync` entry and is not reachable from a request. The tasks adapter's per-role calls (`ctx.as`,
`verbs.mjs:126`) move to `useFor`. Any request field that names an agent other than the caller (`as`,
`agent`, `instance`, `role` on a credential path) is refused `credential-not-yours` and recorded through
`broker.refuse()` as `action.refused`, with the caller's cap and the task ref. A build test greps every
`credentials.use(` call site and fails on a new one outside the allowlist.
D6. **What stays open.** A process of the same OS user outside the sandbox can read every agent's file. That
is the same limit as the human proof (P19 333). Q180's per-user VMs move the boundary to "one stack instance
per OS user". Agents of one instance are kept apart by the sandbox and D5, not by the OS.
## 4. Kinds (brief item 2)
| Kind | 10-27 | Use |
|---|---|---|
| `gitea` | yes | Git push to a granted remote (section 5). Gitea API verbs (issues, pull requests) wait. The factory's review flow runs in the working repo today, and no 10-27 case needs an agent to open a pull request |
| `vikunja` | yes, exists | Moves to `store` references and `useFor`. No behavior change |
| `model` | per stack instance only | See D7 |
D7. **Model provider.** A model token has to sit inside the sandbox today, because the harness calls the
provider itself (E6). Per-agent model tokens need a proxy that holds the real token and gives each run its own
short-lived one, which is the CLIProxyAPI line in TARGET.md. That waits for its own row. For 10-27 the model
credential is per stack instance: row 190's `<configDir>/secrets/claude-session.env`, owned by that
instance's OS user. It is never a copy of jwoltje's file. Jason signs in on each VM user himself (A6). Whether
Case 1 and Case 2 run on Jason's existing subscription or need a new account is Jason's call (Q-J1). This row
doesn't pick. A pi model credential also waits for P19 #383.
## 5. Git identity and push (brief item 3)
D8. **Identity.** A new business-file key, `roles.<agent>.identity = {name, email, gitea: {url, user}}`. It is
optional, because today's business files with a `gitea` agent (coder) must load unchanged. `publish` (D9
step 2) requires it, and requires that the definition declare `gitea`. A business file with a `publish`
grant on an agent that lacks either refuses at load, and the error names `roles.<agent>.publish` and says
"needs identity and a gitea definition" (N10). Load is the only place this is checked: a business file that
fails it never reaches the bus. The sandbox sets `GIT_AUTHOR_*` and `GIT_COMMITTER_*` from
it in place of E7's invalid domain. The email is the one Jason gives the agent's Gitea user, so Gitea links the
commits to that user. Agents without `gitea` keep E7.
D9. **Push to a remote: a new git socket op, `publish`.** No new socket, no bundle, and no token in the sandbox.
```
publish {"cap", "op": "publish", "task_ref", "project", "branch", "decision"}
-> {"ok": true, "result": {"fork", "ref", "commit", "previous"}} or a refusal
```
The host checks, in order, and each refusal goes through `broker.refuse()`:
1. Identity from the cap (E5), then the broker's `#checkAuthority(s, "git.push.remote", {decision, target:
task_ref})`, as `checkLand` does for a merge (`broker.mjs:907-913`). `git.push.remote` is a new action in
ACTIONS and is not in ROUTED (REQ-WAIT-3, `packages/business/src/vocabulary.mjs:52-58`,
`broker.mjs:42-69`), so it is a hard wait and classifies `gated` for every role. Every publish therefore
needs a decision the human resolved for this task ref. A decision is spent once (`decision-consumed`).
**The decision binds one commit.** `#authorityFor` binds action, role, thread and target only
(`broker.mjs:769-798`), so the commit needs its own carrier. The raise records it the same way it records
`holderRun` for `role.revoke` (`broker.mjs:1733`). When the action is `git.push.remote`, the
`decision.raise` event body gets `pushCommit`. That is the commit of `#latestPush(business, target)` at
raise time, read by the broker and never taken from the agent. A raise with no recorded push for the task
refuses `no-push`. The decision's question shown to the human ends with that commit. `#authorityFor` then
refuses `decision-mismatch` when `context.pushCommit` differs from the commit of `#latestPush` now, which
is the same check `holderRun` gets at `broker.mjs:796`. So the sequence approve at A, push B, publish B
refuses.
No role definition grants the action yet. When Jason's PRD version routes it (Sage, Vikunja #420), a
reviewed commit adds it to both ROUTED lists and to coder's `withinRole`. From then on a coder publishes
without a decision, and `decision` is optional for a within-role class only.
2. The project is in the agent's grant, `roles.<agent>.publish: [project]`, and the business file names the
project's upstream repository, `projects.<project>.remote: "https://HOST/OWNER/REPO"`
(`remote-not-granted`). Both URLs pass D9c's parse at load. The upstream's host and port equal
`identity.gitea.url`'s (`remote-host-mismatch`). The host never contacts that URL. It builds the agent's
fork, `fork = https://HOST[:PORT]/<gitea.user>/REPO`, from the upstream's origin and repository name and
the agent's own `gitea.user`. `fork` is the only URL the rest of `publish` uses: D9a's `ls-remote`, step
5's push, the helper's host match, `checkPublish`'s result and step 6's event. So the owner of the
repository that receives a push is the agent's own Gitea user on every push, by construction, and the
agent can't name it (webby-0011 N1). D9c is what makes "by construction" hold. Gitea's refusal of a
push to a repository the user doesn't own or collaborate on is the second layer. An upstream whose owner
equals the agent's `gitea.user`, compared without case, refuses `remote-not-fork`, at load and at push. `remote` is a new key
beside `root` and `repo` (`business.mjs:186`), and `identity` and `publish` are new keys on a role entry
(`business.mjs:89`). All three are optional, so today's business files load unchanged.
3. The source is one commit, `C = context.pushCommit`. It is checked three ways, so all three are equal:
the decision's commit, the commit of `#latestPush`, and the work ref's tip in the working repo,
`work/vikunja-<project>-<task>` (`working.mjs:110-114`). The tip is read under the repository's lock, and
if they differ the push refuses `ref-moved`. The work ref is per task, not per agent. Two checks make it the
caller's: `#latestPush` was recorded by the caller's role (`work-not-yours`), and step 4. The destination is always `refs/heads/<gitea.user>/<branch>`, built by the host.
`validBranch(branch)` holds (`invalid-request`). The agent never names the destination ref.
4. Every commit in `main..<work ref>` has author and committer email equal to `identity.email`
(`identity-mismatch`).
5. The push runs through `credentials.useFor(session, "gitea", ...)`: `git push <fork> <C>:<dst>`, by oid,
with no `+` and no `--force`. It never pushes by ref name, so a push to the working repo after
`checkPublish` can't change what goes out (`non-fast-forward` from git stays a refusal). Git runs with E8's SAFE flags
(`working.mjs:92-98`), which set `protocol.allow=never`. `publish` adds exactly two flags,
`protocol.https.allow=always` and `http.followRedirects=false`, plus `GIT_TERMINAL_PROMPT=0` and no global
or system config. The token reaches git through a one-shot credential helper that reads it from an
inherited pipe, never through argv, env, the URL or a file. Seat `gitEnv` (`checkout.mjs:79-89`) takes only
PATH from the host and sets the rest itself, so no proxy or `GIT_SSL_*` variable reaches git. `publish` takes a `tls` parameter that
defaults to `[]`, the same way `push` takes `fsck = FSCK` (`working.mjs:100`). Tests pass
`["-c", "http.sslCAInfo=<scratch CA>"]`. The host never sets it, and nothing in config or env reaches it. The helper answers `get` only when `protocol`
is `https` and `host`, port included, equals `fork`'s. It writes nothing for any other `get` and for
`store` and `erase`. `assertClean` checks git's output, so a fork that echoes the token gets
`credential-leak`.
6. The bus records `action.allowed` for `git.push.remote` with fork, ref and commit.
D9a. **The agent's fork must share root's history.** A push sends every object the fork lacks. The working
repo's `main` is the project root's history (`working.mjs`, seed), so a fork that doesn't share it would
receive all of root, landing squashes included. The check runs on `fork`, never on the upstream: a related
upstream says nothing about what the agent's `<user>/REPO` holds.
`publish` runs `seed` first (`working.mjs:164-176`), under the repository's lock as `push` does, so `main`
catches up with root before the check below. That means a fork also receives any landed root commits that
upstream doesn't have yet, because they sit under the agent's work. That is expected: the agent's branch is
built on them.
Before the push, the host runs `git ls-remote --symref <fork> HEAD` (same flags and helper). It refuses
`remote-unrelated` when the fork is empty or when that tip isn't a commit the working repo already has and an
ancestor of `main`. When `ls-remote` exits non-zero, for a fork that doesn't exist, a refused token or a
network failure, `publish` refuses `fork-unavailable` and the push doesn't run (N11). Git's stderr isn't
parsed to tell those apart and isn't returned, since it can carry the URL. Only `ls-remote` crosses the
network for this, so nothing is fetched from the fork. If the fork has moved past root, the refusal stands
until root catches up. That is a human pull into root, not an agent's.
D9c. **The URL parse (B6).** `projects.<p>.remote` and `identity.gitea.url` are parsed with `new URL()` at load,
and the business file refuses to load unless all of these hold:
- The scheme is `https:`. There is no username, password, query or fragment.
- The string equals its own normalized form: for the upstream, `url.origin + url.pathname`; for
`gitea.url`, `url.origin + "/"` or `url.origin`. So a dot segment (`/<user>/../OWNER/REPO`), a
percent-encoded dot, an upper-case host, an explicit `:443`, a backslash, userinfo, a query and a fragment
each refuse, because `new URL()` would change them (checked with node: each differs from its normalized
form). A trailing slash survives normalization and is refused by the two-segment rule below. The checks below run on that path, so the owner that is checked is
the owner that is used.
- The upstream's path is exactly `/OWNER/REPO`: two non-empty segments, no more and no fewer. One trailing `.git` on
REPO is allowed and stripped. `fork` is built from the stripped REPO.
- `OWNER`, `REPO` and `gitea.user` each match Gitea's name rule, `^[A-Za-z0-9_.-]{1,100}$` and not `.` or
`..`. REPO after stripping must still match.
- `gitea.url`'s path is `/`. A Gitea served under a sub-path isn't supported by this row and refuses at load.
- Host and port are compared after `new URL()` normalized them, and an omitted port counts as 443 on both
sides.
- Gitea compares user and repository names without case (`lower_name`), so `remote-not-fork` compares the
upstream's OWNER with `gitea.user` without case. An upstream owned by `Coder` and a `gitea.user` of `coder`
would otherwise build a "fork" that is the upstream itself.
D9b. **How `publish` reaches the broker and the store.** The git socket runs in the bus process
(`cli.mjs:504-506`) and reaches the broker only through the host's op table (`bus/src/runtime.mjs:222-246`).
`publish` adds three entries there, each wrapped in `refusing()`:
- `checkPublish(cap, args)`: steps 1 to 3, including the decision check. It returns `{ticket, fork, user,
email, source}` and no token. The ticket holds the session and the decision until `recordPublish`, the
same way `checkLand` holds a landing.
- `withGitea(cap, fn)`: `credentials.useFor(broker.session(cap), "gitea", fn)`. `fn` gets the token and runs
step 5. The token never leaves that call.
- `recordPublish(ticket, result)`: step 6, and it spends the decision.
The op table gets no entry that returns a token.
D10. **Which refs.** Our side: only `refs/heads/<gitea.user>/*`, set by the host (D9 step 3). The default
branch and other agents' prefixes can't be named. Gitea side, two options (Q-M1):
- **O1. A fork per agent. Ruled by Mos (mos-1027).** The agent's Gitea user has read on the upstream repo and owns a fork
named like it. The host derives the fork's URL from the upstream and the agent's user (D9 step 2).
Its token can write only repos the user owns or collaborates on, so Gitea itself stops any push to upstream
or to another agent's fork. No branch rules are needed per agent, so none can drift. The cost: work reaches
upstream as a pull request from the fork, opened by a human or a later API verb.
- **O2. One shared repo. Not chosen.** The agent is a Write collaborator. Upstream's default and release branches get
branch protection with no agent on the push list, plus a rule `<user>/*` per agent. Gitea doesn't stop agent
A from creating an unprotected name outside `A/*` unless a catch-all rule outranks the per-agent ones. That
depends on Gitea's rule priority, which I haven't verified on our version (probe P1). Every hire adds a rule.
Token scopes for either: `write:repository` and `read:user`, nothing else, per vocabulary.mjs rules (no
`all`, no admin).
D11. **The host git socket.** `publish` is added to the socket. `push`, `fetch` and `land` don't change. The
socket's bind rule (E4) adds `git.push.remote`. The working repo stays credential-free and remote-free. The
`fork` URL is passed per call and never written into its config. A landing still squashes into the project
root as "Mosaic bus host". Landings don't push anywhere.
## 6. Lifecycle (brief item 4)
All verbs are on the bus, in the CLI (`mosaic credentials ...`) and in the Console's agent page. Every verb is
human-only. Under Q173 the tester may run `status` and `revoke` on the dev stack instance. `issue` and
`rotate` stay with Jason, because the tester is an agent and the token would pass through its session
(duckworth N1). Each one appends to the organization
writer's `changes.jsonl` (actor, agent, kind, verb, date; never a value). No schema migration, no new event
kind. A refusal uses `action.refused`.
| Verb | What it does |
|---|---|
| `issue AGENT KIND --rotate-by DATE` (gitea) or `--expires DATE` (vikunja) | Reads the token on stdin (never argv), checks E2's format, writes `<kind>.tmp` 0600, fsyncs, renames over `<kind>`, and sets the business file reference to `{"store": true, date}`. The bus reloads that one entry, with no restart |
| `rotate` | Same as `issue` over an existing entry. The old value is gone from disk and memory when the call returns. Jason then deletes the old token in Gitea (A5) |
| `revoke AGENT [KIND] --reason` | Drops the entry from the bus's memory first, then unlinks the file. A use in flight finishes. Every later use refuses `credential-unavailable` |
| `delete AGENT` | `revoke` for every kind, then removes the agent's dir. Refused while the agent has a running claim |
| `status [AGENT]` | Kind, date and state per entry (E3's `status()`), no values |
D12. **"Stops working at once."** On our side this holds without a timing window. A fired agent never holds a
`gitea` or `vikunja` token (D3), so revoking means the bus no longer has it. On the Gitea and Vikunja side the
token still exists until Jason deletes it (A5). The status line for a revoked entry says so until Jason
confirms. The model token is the exception: it sits in the running sandbox (D7), so a fired agent's runs must
end. Row 194's fire ends them.
D13. **What row 194 calls.**
- `credentials.issue` is never called by the chief. Row 194's hire stays uncredentialed (its D1).
- A human `create` of a credentialed definition: row 194 lifts E9's refusal for the human path only. The agent
is created with `store` references and no files. `launch` refuses `credential-unavailable` until Jason runs
`issue`.
- `fire` and `delete`: row 194 calls `credentials.revoke(business, agent, {reason, actor})` before it changes
the business file. A failed revoke stops the fire (`credential-revoke-failed`), so an agent can't be fired
with its credentials still live on our side.
- `regenerate`: no credential change.
## 7. Who does what (brief item 5)
Agents never mint accounts or tokens. Jason's steps, per agent that needs `gitea`:
- A1. Create the Gitea user: Site Administration, User Accounts, Create User Account. The name is the agent's
`gitea.user`. The email is the agent's `identity.email`.
- A2. Create its token: sign in as that user, Settings, Applications, Generate New Token, scopes
`write:repository` and `read:user`, nothing else.
- A3. Create the fork of each granted repo as that user (O1). Gitea's push-to-create
(`ENABLE_PUSH_CREATE_USER`, `ENABLE_PUSH_CREATE_ORG`) stays off, so a push to a missing `<user>/REPO`
fails rather than creating a plain repository (N11). `publish` refuses `fork-unavailable` before that.
- A4. On the stack instance's VM, as its OS user: `mosaic credentials issue AGENT gitea --rotate-by DATE`, with
the token pasted into stdin.
- A5. On fire or rotate: delete the token in Gitea (Settings, Applications), or disable the user.
- A6. Model: sign in to the provider on each VM user to write `<configDir>/secrets/claude-session.env`.
Vikunja bot accounts and tokens are already Jason's (tracker bots). Only A4 changes for them.
## 7a. The grant path (mos-1124, Q188, Q190)
Q188: no agent creates a grant. The chief raises the request, Jason approves it in the inbox, and protected
stack code creates the grant. Jason called manual grant steps a no-fly, so A1 to A4 above are the fallback only.
- a. `credentials.issue`, and so `rotate`, accepts a resolved `credential.mint` decision, Jason's Approve, as
the human path. #436b's executor calls it with that decision and nothing pasted. #436b adds that path and
the decision kind. Row 199 ships the verbs with today's human check only.
- b. Under Q190 ("Q12: O1"), each agent seat gets its own Gitea account. On the Approve, #436b's executor does
A1 to A3: the seat's Gitea user, its token and its forks. Until #436b lands, pushes stay as jason.woltje.
- c. Q185 settles Q-J1 and Q190 settles Q-J2.
No code in 07141966e changes for this section. The executor and the decision route are #436b's.
## 8. Migration (brief item 6)
- M1. Existing `{file}` and `{env}` references keep working. A business moves one agent at a time with
`issue`, which rewrites that agent's reference to `store`.
- M2. Nothing here touches the dev repository's helper, its `.git/config`, `~/.mosaic` or `~/.mosaic-dev`.
Dev seats (jarvis, seat tokens) keep pushing as today. The mos-0981 rule ends only when Mos says so.
- M3. The live dev stack instance needs no change at re-pin: no reference uses `store` until someone issues
one.
## 9. Questions
Mos ruled on the authority model in mos-1027 (2026-10-20T19:27Z):
- Q-M1. O1, one fork per agent.
- Q-M2. `git.push.remote` and its bounds in D9 to D11 are approved. It lands as a hard wait: out of ROUTED,
and no role grants it within-role (D9 step 1). Sage puts its routing in the PRD version draft going to
Jason with `role.hire` and `role.fire` (Vikunja #420). After his approval, a reviewed commit routes it and
grants it to coder.
- Q-M3. Issue, rotate and delete are human-only, and the chief gets none of them. Under Q173 the dev
instance's tester gets `status` and `revoke` only.
- Q-M4. The model credential stays per stack instance for 10-27. Per-agent model tokens wait for a proxy
row.
Open for Jason. Mos takes these to him one at a time:
- Q-J1. The model account for Case 1 and Case 2: existing subscription or a new account. Settled by Q185 (7a).
- Q-J2. Gitea users and tokens for the agents that need them (A1 to A3). Settled by Q190 (7a).
## 10. What this closes (brief item 7)
- G11: closes the store (D1 to D6) and lifecycle (section 6) for `gitea` and `vikunja`. Still open: per-agent
model credentials (D7), and the same-uid limit inside one stack instance (D6).
- G13: closes "own credentials for git" (D8 to D10). Still open: Gitea API verbs (section 4).
- C2.5 (git remote): Case 2's books repo can use `publish` once its project names a remote.
## 11. Tests and mutants
All tests use scratch roots, fake tokens and a fake Gitea: `git http-backend` behind a node http server on
127.0.0.1 that checks basic auth against a fake token and records each ref update. No request leaves the host.
- T1. Layout: wrong mode or owner on a dir or file refuses start. A `{file}` reference into another agent's
path refuses `credential-not-yours`. A bus restart after a create without `issue` starts, and the agent's
status is `not-issued`. A missing `{file}` still refuses start.
- T2. `useFor` keys from the cap only. A request naming another agent is refused and leaves one
`action.refused`.
- T3. The fake Gitea serves https on 127.0.0.1 with a cert from a scratch CA made by the test. It reaches git
only through the `tls` parameter (D9 step 5). Each refusal test that should reach the network asserts that
the first port received the request, so no test passes because git refused http first. `publish`: the
happy path lands `refs/heads/<user>/<branch>` on the agent's fork in the fake, which shares root's history, at
exactly the decision's commit. Refusals for each step in D9, one test each, including `work-not-yours`. Until routing, a publish with
no decision refuses, one with a decision for another task or commit refuses, and a second publish on a
spent decision refuses `decision-consumed`. Approve while the latest push is A, push B, publish: refuses
`decision-mismatch`, and the fork receives nothing. A push to the working repo between `checkPublish` and
the git push refuses with `ref-moved` or `decision-mismatch` (sage-1440, BUILD.md deviation 14). A raise with no recorded push refuses `no-push`. A test pins that `git.push.remote` is in neither ROUTED list
and that no `roles/*.json` grants it. A force-push attempt is
refused by git's non-fast-forward. A fake fork that answers 302 to a second 127.0.0.1 port: the push
fails, and the second port receives no request with an Authorization header. A remote on the right host
and a different port is refused at step 2. Two agents granted one project each land on their own fork:
agent A's push reaches `/<A's user>/REPO` only and B's reaches `/<B's user>/REPO` only, and the fake's
upstream repository receives nothing. An upstream owned by the agent's own user refuses `remote-not-fork`,
and so does one whose owner differs from `gitea.user` only in case. With an upstream that shares root's
history, a first publish to an empty fork and to an unrelated fork each refuse `remote-unrelated`, and
neither fork nor upstream receives objects. A fork that doesn't exist refuses `fork-unavailable`, and the
fake records no push. `ls-remote` and the push each reach the fork's path only (the fake records every
path). Load refusals (D9c), one test each: a dot segment, an extra path segment, a missing segment,
a trailing slash, userinfo, a query, a fragment, an explicit `:443`, a name outside Gitea's rule, and a `gitea.url` with a
sub-path. A `.git` suffix loads and builds the fork without it. A `publish` grant on an agent without
`identity`, and on one whose definition lacks `gitea`, each refuse at load with the N10 message.
- T4. Token handoff: the token never appears in argv, env, the fork URL, the working repo config,
`changes.jsonl`, events or logs (scan every file under the scratch roots).
- T5. Lifecycle: issue, rotate, revoke and delete, each live with no restart. After revoke, `publish` and the
Vikunja path refuse `credential-unavailable`, and so they do after a bus restart that follows the revoke.
The tester can run `status` and `revoke`, and `issue` and `rotate` refuse it.
- T6. Default: an agent without `identity` commits as E7. Existing `{file}` references load unchanged.
Mutants to kill: drop the cap-derived key in `useFor`, let the request name the destination ref, add `+` to
the refspec, skip the identity check, put the token in the URL, revoke file before memory, skip the dir mode
check, allow a `{file}` reference into another agent's dir, make ENOENT on a `store` path fatal again, make
ENOENT on a `{file}` path silent, a helper that answers any host, drop `http.followRedirects=false`, drop the
`work-not-yours` check, drop the `remote-unrelated` check, add `git.push.remote` to ROUTED, skip the spent
check on the decision, drop the `pushCommit` comparison in `#authorityFor`, push by ref name in place of the
oid, push to the upstream URL in place of the derived fork, run `ls-remote` against the upstream in place of
the fork, take the fork's owner from anywhere but the agent's `gitea.user`, compare the owner with case, run the
D9c checks on the raw path in place of the normalized one, read a failed `ls-remote` as empty, skip the tip
check under the lock, and pass a CA flag in production.
## 12. Risks
- R1. Backups: if a backup tool copies `<configDir>`, it copies tokens. The build checks row 185's backup set
and says what it includes.
- R2. Withdrawn. O1 was ruled, so probe P1 isn't needed.
- R3. Outbound HTTPS from the bus host to Gitea is new network reach for the host process. On the VMs this is
Jason's network to confirm.
-7
View File
@@ -1,7 +0,0 @@
# Morgana
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
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
-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
-36
View File
@@ -1,36 +0,0 @@
# Row 189 gate timing (for row 197, Herb)
Sage, 2026-10-20T19:25:04Z. Gate on 0c177e9ef (base 0217b71c8), one lane. The slot was mine. Seats may have run single test files at the same time, which the slot rules allow, and I did not record that.
Start 2026-10-20T19:09:37Z, end 2026-10-20T19:20:36Z. Total wall time: 659 s.
Wall time per suite is the gap between one suite's log mtime and the one before it (the first suite starts at start.txt), so it includes node start-up. node's duration_ms is listed where the suite prints it. Scripts print no duration.
| Suite | Wall s | duration_ms s | Ended (UTC) |
|---|---|---|---|
| pkg-bus | 3 | 3.8 | 19:09:40 |
| pkg-business | 3 | 2.2 | 19:09:43 |
| pkg-cli | 388 | 388.5 | 19:16:11 |
| pkg-control-board | 1 | 0.8 | 19:16:12 |
| pkg-conversation | 35 | 35.4 | 19:16:47 |
| pkg-discord | 3 | 2.6 | 19:16:50 |
| pkg-harness | 12 | 11.6 | 19:17:02 |
| pkg-ledger | 13 | 13.5 | 19:17:15 |
| pkg-mosaic | 3 | 2.3 | 19:17:18 |
| pkg-plugins | 0 | 0.3 | 19:17:18 |
| pkg-project | 1 | 1.2 | 19:17:19 |
| pkg-runs | 1 | 0.8 | 19:17:20 |
| pkg-seat | 6 | 6.2 | 19:17:26 |
| pkg-tasks | 1 | 1.2 | 19:17:27 |
| pkg-webui | 58 | 57.9 | 19:18:25 |
| script-auth | 1 | - | 19:18:26 |
| script-conductor | 5 | - | 19:18:31 |
| script-config | 2 | - | 19:18:33 |
| script-discord | 8 | - | 19:18:41 |
| script-extension-package | 1 | - | 19:18:42 |
| script-foundation | 40 | - | 19:19:22 |
| script-land-work | 11 | - | 19:19:33 |
| script-queue | 21 | - | 19:19:54 |
| script-release | 11 | - | 19:20:05 |
| script-task | 31 | - | 19:20:36 |
Source: /mnt/storage/scratch/sage-g189/out/*.log mtimes and gate.log. The cli suite is about 59% of the run, and webui plus conversation add about 14%.
-86
View File
@@ -1,86 +0,0 @@
# Row 189: `stack-release.sh activate --no-send`
Gitea #1687. Vikunja P19 #413 (Q175 interim, #412). Brief: docs/plans/2026-10-20_activate-no-send.md.
Rulings: mos-0972 (the Console runs without `--send` until Q175 phase 2), mos-0973 (go; Sage builds,
Dewey reviews; gate is the cli suite plus every scripts/test-*.sh). Base: def2e7932 (refactor, the
brief commit on 9ae8c73ac).
## What it does
`activate ID BUSINESS --no-send` drops `--send` from `RUN_CONSOLE` in a new `send_for_target`, which
`console_for_target` calls after `proof_for_target`. `install_stack` renders the unit from
`RUN_CONSOLE` and `write_pointer` writes the pointer from it, so both lose the flag and `flags_drift`
stays quiet afterwards. Only `activate` takes the flag; the parser sends every other op to usage
(exit 4).
`check_target` lets a `--no-send` activate name the pointer's copy only when the pointer's Console
flags hold `--send`. That run is a live-copy re-pin, the same path row 187 uses for the proof pair:
the plan prints the live-copy note, the run stops both units, backs up the stopped store and starts
them again. A plain same-copy activate, or a same-copy `--no-send` once `--send` is gone, still
refuses `already-active`.
`--plan` prints one `send:` line after the `proof:` line, for activate and rollback:
- `send: dropped; --no-send was given`
- `send: off; --no-send was given and the pointer holds no --send`
- `send: carried` (the pointer's `--send` goes on unchanged)
- `send: off`
The steps log gets `drop --send; --no-send was given`, or `--no-send was given and the pointer holds no
--send, so there is nothing to drop`.
Rollback and pm-on carry the pointer's flags as they are, so once `--no-send` has run, nothing adds
`--send` back. There is no re-enable flag (mos-0973). Q175 phase 2 changes the Console flags in its
own row.
docs/TOOLS.md: `[--no-send]` in the usage block and one paragraph after the proof paragraph.
## Tests (packages/cli/tests/stack-release.test.mjs, T55)
1. Seeded preview pointer with `--send --tester-port 7333`: a same-copy `activate B acme --no-send`
passes. The pointer and the unit ExecStart lack `--send` and keep the other flags in order, status
says the unit flags match, the steps log has the drop line, the log line has from = to = B and the
backup sum equals the stopped store's, and the units stop and start in order.
2. Plain same-copy activate, with `--tester-port` and with `--no-proof`, refuses `already-active`. So does
a same-copy `--no-send` once `--send` is gone, and on a pointer that never held it.
3. The four `send:` lines, the live-copy note on the same-copy plan only, and the Console line right
after `send:` without `--send`. The run on a pointer with no `--send` logs the nothing-to-drop line
and no drop line.
4. After `--no-send` on C1, a rollback to B leaves `--send` out of the pointer and the unit, the steps
log never names `--send`, and status matches. pm-on after `activate --pm-after-harness --no-send`
keeps it off.
5. `--no-send` on rollback (with and without `--plan`), pm-on, status, adopt and rehearse is a usage
error with the usage text, nothing written, started or logged. The usage text shows `[--no-send]`.
6. A pointer holding both the proof pair and `--send`: the plans for `--no-proof`, `--no-send` and both
name what each drops and carries. `--no-send` keeps the pair, `--no-send` with a new pair replaces it,
`--no-proof` keeps `--send`, and both together leave neither. Status matches after each.
`planTail` gains `'send: off'` after `'proof: off'`, because every plan now prints the line. T36 takes
`out.slice(-n)` around `planTail`, so its four slice counts grow by one.
## Mutants
mutants.sh in this packet, run on the candidate. Each mutates scripts/stack-release.sh and runs T55
plus T1 and T2.
- M1: the same-copy exception ignores `--no-send`.
- M2: the same-copy exception allows `--no-send` without `--send` in the pointer.
- M3: with `--no-send`, the pointer is written from `P_CONSOLE`, so it keeps `--send` while the unit
drops it.
- M4: rollback re-adds `--send`.
- M5: the parser accepts `--no-send` for rollback.
Results: mutants-run.log, all five killed (M1 5 failures, M2 2, M3 7, M4 2, M5 2).
## Deviations from the brief
- The brief's line numbers were taken before row 163 landed. The candidate corrects them to the
9ae8c73ac lines (check_target :249-256, flags_drift :646-657, console_for_target :664-693, adopt
:828).
- Test 5 also covers rehearse and `rollback --plan`, which the brief did not list.
## Outbound calls
None. The change edits an array before the existing unit render and pointer write. Every test uses
the stand-in systemctl, units, stores and data root the file already builds; no test touches
`~/.mosaic-dev`, the live units or a slot file.
@@ -1,5 +0,0 @@
M1 KILLED fail=5
M2 KILLED fail=2
M3 KILLED fail=7
M4 KILLED fail=2
M5 KILLED fail=2
-34
View File
@@ -1,34 +0,0 @@
#!/usr/bin/env bash
# Row 189 mutants M1-M5: each mutates scripts/stack-release.sh in the sage-b189 worktree, runs T55 and
# the plan-tail tests, and restores the file from a checked backup.
set -uo pipefail
WT=/mnt/storage/scratch/sage-b189
OUT=/mnt/storage/scratch/sage-b189-mut/out
S=$WT/scripts/stack-release.sh
mkdir -p "$OUT" || exit 1
cp -p "$S" "$OUT/stack-release.sh.orig" || { echo "backup failed"; exit 1; }
ORIG=$(sha256sum "$S" | cut -d' ' -f1)
[ "$(sha256sum "$OUT/stack-release.sh.orig" | cut -d' ' -f1)" = "$ORIG" ] || { echo "backup differs"; exit 1; }
restore() { cp -p "$OUT/stack-release.sh.orig" "$S"; [ "$(sha256sum "$S" | cut -d' ' -f1)" = "$ORIG" ] || { echo "RESTORE FAILED"; exit 1; }; }
trap restore EXIT
mutate() {
case "$1" in
M1) sed -i '/{ \[ "\$NO_SEND" = 1 \] && \[\[ " \${P_CONSOLE\[\*\]} " == \*" --send "\* \]\]; } ||/d' "$S" ;;
M2) sed -i 's/{ \[ "\$NO_SEND" = 1 \] && \[\[ " \${P_CONSOLE\[\*\]} " == \*" --send "\* \]\]; } ||/{ [ "$NO_SEND" = 1 ]; } ||/' "$S" ;;
M3) sed -i 's/^ write_pointer "\$TO" "\$RUN_PM" "\$RUN_PENDING" "\$P_CLAUDE" "\$T_SCHEMA" "\${RUN_CONSOLE\[@\]}"$/ if [ "$NO_SEND" = 1 ]; then write_pointer "$TO" "$RUN_PM" "$RUN_PENDING" "$P_CLAUDE" "$T_SCHEMA" "${P_CONSOLE[@]}"; else write_pointer "$TO" "$RUN_PM" "$RUN_PENDING" "$P_CLAUDE" "$T_SCHEMA" "${RUN_CONSOLE[@]}"; fi/' "$S" ;;
M4) sed -i 's/^ SEND_PLAN="send: off"$/ SEND_PLAN="send: off"; [ "$OP" != rollback ] || RUN_CONSOLE+=(--send)/' "$S" ;;
M5) sed -i 's/--no-send) \[ "\$OP" = activate \] || usage;/--no-send) { [ "$OP" = activate ] || [ "$OP" = rollback ]; } || usage;/' "$S" ;;
esac
}
for m in M1 M2 M3 M4 M5; do
restore
mutate "$m"
if [ "$(sha256sum "$S" | cut -d' ' -f1)" = "$ORIG" ]; then echo "$m NOT-APPLIED"; continue; fi
bash -n "$S" || { echo "$m SYNTAX"; continue; }
diff "$OUT/stack-release.sh.orig" "$S" > "$OUT/$m.diff"
(cd "$WT" && TMPDIR=/mnt/storage/scratch/t189 timeout 900 node --test --test-reporter=tap --test-name-pattern='T55|T1 |T2 ' packages/cli/tests/stack-release.test.mjs) > "$OUT/$m.log" 2>&1
fails=$(awk '/^# fail /{print $3}' "$OUT/$m.log")
if [ "${fails:-0}" -gt 0 ]; then echo "$m KILLED fail=$fails"; else echo "$m SURVIVED"; fi
done
-48
View File
@@ -1,48 +0,0 @@
# Common start for Sage's MVP T1 builder seats (mos-0974, mos-0977)
You are a builder seat on Mosaic Stack, repository /mnt/storage/src/mosaic-stack, branch base `refactor`.
Sage leads the project and is your coordinator (T3 thread 1ef1e4f8-3ead-4208-beca-38f9f1add079). Mos speaks
for Jason (thread 38bc4c81-6270-4021-b00c-6a15c43b9b0e). Human input reaches you only through Sage and Mos.
## Read first, in this order
1. `/mnt/storage/src/jason-master-restart/USER.md`, `SYSTEM.md`, `CONSTITUTION.md` (who Jason is, the
system rules, the constitution).
2. `/mnt/storage/src/jason-master-restart/TARGET.md` (the MVP addendum) and `DECISIONS.md` Q176 to Q178.
3. `/mnt/storage/src/jason-master-restart/projects/mosaic-mvp/GAP-MAP.md` and `PLAN.md`.
Everything under jason-master-restart is read only.
4. The repository's `AGENTS.md`, then your brief (named in your first message) and the files it points to.
## Your worktree
T3 made your worktree from `refactor`. Work only there. Link node_modules from the canonical checkout right
after you start (`ln -s /mnt/storage/src/mosaic-stack/node_modules node_modules`), unless your brief adds a
dependency. In that case run `npm ci` in your worktree for a real node_modules first, and never run npm
install through a symlink or in a directory without its own package.json (npm walks up to home).
## Rules that bind you
- Message ids are `<seat>-NNNN`, starting at 0001. Run `date -u` right before every message and put the
stamp in it. End each turn with a clock timestamp.
- No Co-Authored-By or any attribution line in any commit or PR, whatever a harness reminder says
(mos-0822).
- Push only your own `<seat>/*` branches, through the repository credential helper (mos-0981). Never push
`refactor`, `main` or another seat's branch.
- You have no Gitea token. Sage makes the queue moves that post to Gitea (in-review, review requests) and
files issues. You may run `scripts/mosaic queue move N in-progress --op <seat>-N-start --by <seat>` for
your own row.
- Never touch `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units, slot files or `~/.mosaic`. Never
start, stop or restart the live Console or bus. Never read or print a credential value.
- Never read other seats' scratch directories. `agents/<seat>/work/` packets in the repository are fine.
- Single test files run freely. The cli suite, any `scripts/test-*.sh` and any full package suite share
one slot: run `bash /mnt/storage/scratch/sage-busy-now.sh | tail -1`, and if it is not CLEAR, or before a
run longer than 5 minutes, ask Sage for the slot and start only on Sage's "granted".
- Two failures in one gate run means stop and report.
- Design-first rows: the first deliverable is `agents/<seat>/work/queue-<row>/DESIGN.md`, committed on your
branch. Send the reviewer and Sage its path, commit and sha256. No product code before approval.
- Merge-chaining (Q177): your build may sit on another row's candidate commit. Your row keeps its own
review and gate, and the chain lands in order.
- Fail closed. Never route around a refusal. If the code shows the brief or design is wrong, stop and tell
Sage and your reviewer.
- Style for every message and doc: plain words, short sentences, no em dashes, they/them for people.
- Sage registers your session in `docs/SESSIONS.md`. Do not edit it.
-7
View File
@@ -1,7 +0,0 @@
# Webby
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
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
+173
View File
@@ -0,0 +1,173 @@
# Webby review: Case 2 design r1 (#416)
Webby, 2026-10-20 (from `date -u`). Independent reviewer, replacing Dewey (Mos, mos-0980).
Subject: `agents/gosalyn/work/case2/DESIGN.md`, blob fba567f71dc1, at e997f3b76 and on
origin/gosalyn/case2-design 88222f3f3 (same blob). Author: Gosalyn. Static review only. I ran no
tests and changed no product code.
Code base: `git archive 5d019d876`. `git diff --stat 5d019d876 9ae8c73ac -- packages adapters roles scripts`
is empty, so every line below also holds at origin/refactor 9ae8c73ac. Paths are relative to `packages/`
unless they start with `adapters/` or `roles/`.
## Verdict: CHANGES
Twelve blockers. Section 6's no-schema claim holds. Section 5's write-side guardrails hold. The
message-side guardrails have two channels the design misses (B3, B4), and the smallest slice can't
run as written (B1, B2).
## Citations checked
These hold as cited: git.mjs:8 (`protocol.allow=never` in SAFE), git.mjs:44 (any `remote.*` key
refuses), git.mjs:55-56; project index.mjs:12, :31-84, :97, :195-234, :209, :248-261, :256-258;
paths.mjs:7, :14-15, :34-48 (`absolute(project.root)` at :41 throws on a missing root);
business.mjs:186-187; gate.mjs:130-167 (typed by name at :134, built-ins at :137-138);
claude-gate.mjs:13; tools.mjs:6-7, :95-102, :186, :211-220, :271-280; vocabulary.mjs:92-97, :131;
role.mjs:19-28, :55-66; broker.mjs:290-291, :642-650, :1378-1430, :1434-1435, :1867, :2331-2347,
:2374-2399; schema.sql:240-250, :296-306, :310; runner.mjs:446-456; seat session.mjs:59-64;
adapters/claude/adapter.sh:47-53; launcher.mjs:135, :236-238, :353; webui bus.js:885, :919.
One miss: the `ls-tree` and `cat-file` reads that 1.3 cites at read.mjs:67-86 are in `blob()` at
read.mjs:19-26 (N1).
## Section 6: "no schema or trigger change"
It holds. `threads` has no definition column or check (schema.sql:240-250).
`message_threads_match` (:296-299) and `delivery_same_thread` (:300-306) check one thread's
instance, and a room is one thread per instance, so each insert in `room.send` passes as written.
`thread_one_per_run` allows one held thread per run, which matches one session per agent. The only
constraint the design must name is `UNIQUE (project, name)` (:249). `room.open` names each thread
after its instance, so an existing thread with that name refuses (N9).
The single-instance rule is code, not schema: broker.mjs:291, :2332-2334, launcher.mjs:135 and
:236-237. Changing it is C2-09's job, which is why B1 matters.
## Jason's three guardrails
| Guardrail | Enforced in code? |
|---|---|
| Agents never write prose for the user | Into the library: yes. Gate (gate.mjs:137-138), typed tools (no `write_record` without `record.write`, tools.mjs:211) and human-only verbs make an agent write impossible. In messages: only the form is bounded (M5), and two channels skip M5 (B3, B4). The 400-character limit is stated in 5.1, but the stated residual is too small (B5) |
| Agents always refer to the source material | Structural once B6 and B7 are fixed. The quote-in-lines check at a reachable commit is real verification |
| Tool use is guarded | Yes: gate, typed tool list, broker re-check (tools.mjs:6-7) and `#checkMessaging` (broker.mjs:1867). N6 closes a role-file gap |
## Blockers
B1. The smallest slice gives Melanie no way to chat with a creative agent. `#threaded` is true only
for definition `assistant` (broker.mjs:291). `thread.create` refuses `thread-instance-ambiguous`
with more than one (:2334). The launcher refuses `thread-not-allowed` for any other definition
(launcher.mjs:135, :237). C2-08 adds the `THREAD_DEFINITIONS` constant, but C2-09 is the row that
makes the broker and launcher read it and adds `instance` to `thread.create`, and C2-09 is outside
the slice (DESIGN 9). The Console's thread creation must pass `instance` too. Fix: put C2-09's
broker, launcher and `thread.create` parts in the slice. The Console chat must pick the instance.
`room.*` can stay out.
B2. In the slice, Melanie reads raw JSON and can't see sync failures. Replies are JSON bodies (5.2),
and only C2-10 renders them. 7.2 step 2 says "The Console shows them with file and line", but the
library pages are C2-11b, outside the slice. A bad Obsidian push silently stops agents seeing new
work. Fix: the slice needs a minimal cited-item renderer in the existing chat view and a sync status
line (last valid commit, problems, `unpushed`).
B3. `raise_decision` is a second prose channel to the human. Every agent gets it with free-text
`question` and option `text` (tools.mjs:221, comment at :5-6). An uncited raise goes to the human.
M3's "no write tool exists" is true, but M5 covers only `message.send`. Fix: either refuse
`decision.raise` from creative definitions in the broker and drop the tool from their typed set, or
put the raise text through the same guard. Test both paths.
B4. The Console conversation view shows the model's raw text, including text M5 refused.
webui serve.mjs:559-569 proxies `/api/conversation` to the control board, and the reader emits
assistant text blocks (conversation/src/events.mjs:58, :82). A refused prose answer stays in the
session file and is readable. Fix: leave creative sessions out of the catalogue on a library
instance, or show stored messages only. Test that a creative session isn't listed.
B5. The stated limit understates what M5 lets through. A reply holds 20 items of 400 characters,
which is 8,000 characters. An `idea` item's text is free, and any real quote of any line satisfies
the cite. That is a full scene per reply, not "a one-sentence suggestion" (5.1). Jason wrote
"NEVER", and Q176 says the system enforces it. Fix: add a total-characters cap per reply and a lower
cap for `idea` items (for example 200 characters and at most 5 items). State the residual with
those numbers. Mos takes the residual to Jason or Melanie for acceptance, because it is their rule.
B6. A `correction` isn't bound to the source. M5 limits the edit distance between `from` and `to`,
but nothing requires `from` to be text in the cited lines. An agent can invent both. Fix: `from`
must equal one cite's `quote`, after the same whitespace normalization, inside that cite's lines.
Test that an invented `from` is refused.
B7. `citation-unfetched` has nothing to check against. The web trail stores the URL
(broker.mjs:1521) and a summary of status, bytes and redirects (bus/src/web.mjs:46), not the page
text. "The quote occurs in the fetched text" needs that text. Also, Claude's built-in WebSearch and
WebFetch are on when `policy.webBuiltin` is set (gate.mjs:28-32), and those fetches never reach the
broker. Fix: say where fetched text is kept (bounded, per run, or a new table, and if a table then
say so). M1 must refuse the built-in web source for creative definitions, so `research` uses only
the broker's tools.
B8. Remote content is a new untrusted input, and the S-rules don't check tree entries. Today only
the bus writes record repos. Under 7.2, Obsidian or anyone with push to the Gitea repo writes too.
A fast-forward checks out whatever the commit holds. No rule refuses symlinks (120000), gitlinks
(160000), executables or nested `.gitattributes` and `.gitmodules`. Separately, S1 refuses the
`.gitignore` that C2-12 commits, and Obsidian puts new notes and `.trash/` at the vault root by
default. Fix: add a rule that every entry is a 100644 blob or a tree, and refuse those names at any
depth. Allow `.gitignore` at the top. Commit Obsidian's new-file location and trash settings in
`app.json`. Test each case.
B9. The fail-closed state in 7.2 strands Melanie's writes. If remote main is ahead and invalid, the
bus stays on the old commit (step 2). The next bus write commits on that base. The push can't
fast-forward, so the commit is `unpushed`, then `library-diverged`, with no merge until C2-17.
S5 makes this likely: Obsidian users create `[[stub]]` links to notes that don't exist yet, so one
dangling link in the playground blocks the whole sync. Fix: refuse library writes while remote main
is ahead and invalid (`library-remote-invalid`). Apply S5 to canon only, or allow unresolved links
in `playground/`. Test both.
B10. The 7.2 gate can't catch the obvious mutant. Tests use the file protocol through a test-only
argument, so the https path, `GIT_ASKPASS` and the protocol override are never run. A mutant that
sets `protocol.allow=always` passes them all. `git()` passes only `gitEnv()` plus the identity
argument (git.mjs:10-12, seat checkout.mjs:79-89), so sync needs its own env entry for the helper.
`GIT_TERMINAL_PROMPT=0` (checkout.mjs:85) doesn't stop askpass, which is fine. Fix: one function
builds the production fetch and push argv and env. A test asserts that the argv holds SAFE, then
exactly `-c protocol.https.allow=always`, and no other `protocol.*` key. Another asserts that
`GIT_ASKPASS` names a helper outside the repo that reads the slot file. Keep the file-protocol
switch out of any path that a business file or a bus verb can reach.
B11. An optional `root` breaks more than the overlap loop. `ensureCheckouts` clones every entry's
root (seat checkout.mjs:310, :319). With no root it calls `clone(project, undefined, ...)`, so "a
project with no root gives agents no clone" (2.1) is false today. `projectFilePath(entry.root)`
(business/src/project.mjs:9-10) throws on undefined, and it is called at business/src/cli.mjs:72,
:78, :118, resolve.mjs:44 and instance-settings.mjs:142. Fix: C2-04 lists these consumers, each
skips a library project explicitly, and each has a test.
B12. Scope and crossovers can also come through front-matter fields, and in the slice a hybrid
rests on convention. S2 and S3 speak of links only. `extends:`, `sources`, and `exclude` and `pin`
paths are references too, and a canon book entity with `extends: playground/...` would bring
playground text into canon lookups labelled canon. `library_manuscript` has no `scope`, so it must
label or refuse a playground book. In the slice there is no crossover dialog (C2-11b), so a hybrid
is a hand-written `universe.json` pushed from Obsidian, and S6 doesn't require `sanctioned`. Fix:
put every path field under S2 and S3. Make S6 require `sanctioned`. State that in the slice a hybrid
is Melanie's hand edit, and that only her account and the bus account can write to the Gitea repo.
## Non-blocking
N1. Cite read.mjs:19-26 for the blob reads, not :67-86.
N2. `END_PROTECTED` is `[THREAD_DEFINITION]` (vocabulary.mjs:135). Say whether creative
definitions become end-protected when `THREAD_DEFINITIONS` replaces it.
N3. The runner sends `(the session gave no answer)` and `The turn failed: ...` (runner.mjs:449).
The guard will refuse both, so Melanie never learns a turn failed. Add a fixed turn-failed notice,
and test that any notice other than the fixed bodies is refused.
N4. Promotion moves several files. `commitFile` commits one path (index.mjs:219). C2-02 needs a
multi-path commit.
N5. Ids allow capitals (paths.mjs:7). Two universes that differ only by case collide on a
case-insensitive file system. Enforce lowercase in `checkLibrary`.
N6. A role with no `messaging` key may message anyone (role.mjs:50). M1 must require `messaging`
for creative definitions.
N7. C2-05b's sync-before-read needs C2-03, which its row doesn't list. C2-03 reads the remote URL
that C2-04 adds to the business file, so either pass the URL in or order C2-04 first.
N8. C2-06 must run the guard through both the runner's RESULT path and `send_message`, with a
non-creative sender in a library business. Otherwise a mutant keyed on `CREATIVE_DEFINITIONS`
passes, against 5.2's "role choice doesn't matter".
N9. Name the refusal when `room.open` meets an existing thread with an instance's name
(schema.sql:249).
## Rows and the date
The rows are small and most gates name the right refusal. B6, B10 and N8 are the gates that miss
their obvious mutant. The listed slice (C2-01 to C2-08, C2-12, C2-13) does not let Melanie start.
She can write in Obsidian, but she can't reach an agent (B1) or read its answers (B2). With
C2-09's broker part and a minimal renderer added, it can be done by 10-27 only if T1 row 1 (row
190) lands by about 10-24, as section 9 says. Reaching the Console from her machine also needs SSO
(Q175).
+74
View File
@@ -0,0 +1,74 @@
# Webby review: Case 2 design r10 (#416), two system units
Webby, 2026-10-20T21:01Z (from `date -u`). Author: Gosalyn (gosalyn-0045). Commit 4281b0064 on
gosalyn/case2-design, DESIGN blob 4a1860e698e0. Diff `git diff 51006710a 4281b0064`, one commit. I
read both unit templates, scripts/webui-service.sh, webui `cli.mjs serve`, scripts/mosaic and decisions 62,
94 and 102 on origin/refactor. Static review.
## Verdict: CHANGES (B1, B2, B3)
The fix is the right one. Two units from the existing templates match how the stack runs today, and C2-10b
needs the Console. One copy for both units keeps the human proof working, since the bus accepts only its
own human-cli.mjs path (webui-service.sh:266-273). Making `%h` literal is correct for a system unit. The new
refusal cases are good.
B1. The Console's boot start is Jason's call, not mine or Mos's, and the design should default to the strict
reading until he rules. Decision 94 (lead-decisions:2016-2023) says the process that starts the Console
holds human authority, and that a start through systemd gives a clean ancestry the proof accepts, "the
detachment gap decision 62 names". Decision 102 lets one named seat, Mos, start it. It calls that a
tradeoff Jason accepted, because "the human proof now rests on Jason's grant to one named seat" (:2253). A
boot-enabled system unit hands that start to PID 1 on every reboot, with no named starter. That extends a
human-authority boundary, so under AGENTS.md it needs an owner decision. Decision 94 also accepts the
local-forgery limit only "for this PoC on dragon-lin under Jason's user ... this decision doesn't carry the
limit there" (:2058-2062). On .54 any process that reaches the Console port can send as Melanie, and
`melanie`'s Hermes processes and sudo are on that host until #423 step 5. Nobody has accepted that limit
for .54.
Fix: render the Console unit with no `[Install]` section, so a human or Mos starts it at step 9 and after
each boot. That fails closed and is cheap to reverse, because adding `[Install]` later is one re-render.
Put two questions to Jason through Mos: may a boot start stand in for the human's start on .54, and does he
accept the local-forgery limit there? The bus unit can stay boot-enabled, since it holds no human
capability.
B2. `@PATH@`, `@NODE@` and `@FLAGS@` have no render rule. Both templates set
`Environment=PATH=@PATH@` (mosaic-bus.service.in:15, mosaic-webui.service.in:25). The bus `ExecStart` is
scripts/mosaic, a bash script that runs `exec node` from PATH (scripts/mosaic:22-40). So the bus's node is
whatever PATH the render writes, not `--node`. Row 195 found v22 on .53, and the precheck checks
`node --version` on `<user>`'s shell PATH, not the unit's. Fix: say `@PATH@` is `dirname(--node)` followed
by `/usr/local/bin:/usr/bin:/bin`, as webui-service.sh:246-248 does, and `@NODE@` is `--node`. Say
`@FLAGS@` is the checked serve flags, and refuse any `@[A-Z_]+@` left in either render, not only `%h`. Add
a test that the bus unit's PATH starts with the `--node` directory.
B3. The hardening lines are never listed. In r9 they lived in the new template that C2-13 owned. r10 drops
that template, and the render "adds ... the hardening lines", but neither the design nor the brief says
which ones. The test "a template with a hardening line removed" has nothing to remove: the templates carry
only `NoNewPrivileges=yes`. Some common lines break sessions. `RestrictNamespaces=`, `PrivateUsers=` or a
`SystemCallFilter=` without the namespace calls stops bwrap, and then no role starts. The bwrap precheck
runs in `<user>`'s shell, outside the unit's sandbox, so it can't catch that. Fix: list the exact lines in
the brief (for example `ProtectHome=yes`, `ProtectSystem=strict` with `ReadWritePaths=<home>`,
`PrivateTmp=yes`, `NoNewPrivileges=yes`), say none of them restricts user namespaces, and add a step 9
check that one session starts under the bus unit.
## Non-blocking
N1. Both templates already end with `[Install]` `WantedBy=default.target` (bus :41-42, webui :39-40). The
render should replace that line with `multi-user.target`, not add a second one. Under B1 it removes the
Console's section.
N2. The render adds no `After=` or `Wants=`, so at boot the two units start in parallel. That is harmless
today, because serve calls the bus per request (webui cli.mjs, `busCaller`) and starts without it. But the
stated order applies only to a manual step 9. Either add `After=mosaic-bus@<business>.service` to the
Console unit, or say that boot order doesn't matter.
N3. "The serve flags pass scripts/webui-service.sh's own flag checks" can't be done by calling that
script. `serve_flags` (:146-207) is internal. Its proof-file check compares the owner to `id -u` (:201),
which is root under case2-unit.sh. `render` finds node with `command -v` (:237), not `--node`. And
webui-service.sh isn't in the files owned. Say whether case2-unit.sh copies the checks, with tests, or
whether the row may move `serve_flags` into a sourced file. Also state which serve flags Case 2 uses.
N4. With a copy, webui-service.sh passes `--checkout <canonical>` (:251). Her host has no canonical
checkout, so the queue read runs from the copy, and the queue store refuses that. Say that the Console unit
gets no `--checkout`, and that the queue panel isn't part of Case 2.
N5. A re-pin must move both units to the same copy, or the human proof fails (webui-service.sh:266-273).
Have case2-unit.sh refuse a render whose two units name different copy ids, and test it.
+74
View File
@@ -0,0 +1,74 @@
# Webby review: Case 2 design r11 (#416), Console without [Install], render rules, 9.3
Webby, 2026-10-20T21:14Z (from `date -u`). Author: Gosalyn (gosalyn-0048, gosalyn-0051). Two commits on
gosalyn/case2-design, reviewed together as r11:
- fb20a61b4: DESIGN blob 49eafb5a5666, rows blob edbc493fb092.
- eac92d956: rows only, rows blob 3a86d5f048dc. This is the one to re-pin.
Diffs: `git diff 4281b0064 fb20a61b4` and `git diff fb20a61b4 eac92d956`. I read B1 against mos-1097 and
the port squat against mos-1100. I read the cited code at fb20a61b4. Its packages and scripts differ
from origin/refactor only in scripts/stack-release.sh, which none of these citations use. This is a
static review. I ran nothing on any host.
## Verdict: APPROVE
B1 is fixed, per mos-1097. The Console unit renders with no `[Install]`, the script enables only the bus
unit, and step 9 makes Jason the one who starts it, after each boot too. The tradeoff after an unplanned
reboot is written down. Q185 (jason-master-restart DECISIONS.md:1573) does put him in the homelab for
1012 reboots. Mos asked for two facts, and 9.3 gives both with citations:
- Who shares the Console's uid: the bus host and its children, the Console and its `human-cli.mjs`
children (webui bus.mjs:1-3), every session inside bwrap, and the admin steps.
- What a bwrap session can reach. The port, yes. sandbox.mjs:67 has no `--unshare-net`, and :29-31 says
the network isn't confined. The proof file, no. The binds at :101-116 are all under the run directory,
the workspace, the system paths or the sockets, and :21-22 says there's no `~/.config`. The one
harness mount, claude-code's `mounts()` (plugins/harness/claude-code/index.mjs:73-77), is only the
pinned binary.
The 401/403 reading of serve.mjs holds. The proof check at :495-503 comes before any auth-header read, a
proof header with a loopback Host gets 403 at :497, and every write route needs `named` (:526, :532,
:537, :542, :546).
B2 is fixed. I listed every token in both templates. The bus has `@REPO@`, `@PATH@`, `@CLAUDE_ENV@`,
`@PM_FLAGS@`, `%h` and `%i`. The Console has `@REPO@`, `@PATH@`, `@NODE@` and `@FLAGS@`. Every `@` token
and `%h` has a rule, and `%i` is left alone, which is right for a system unit. The leftover refusal is
`@[A-Z_]+@` or `%h`, so it doesn't catch `%i`. The `@PATH@` rule fixes the case I missed in r10. With
`--node /usr/bin/node`, webui-service.sh:246-248 would let a node in /usr/local/bin win, and there's a
test for that case.
B3 is fixed. I checked the four lines against the sandbox:
- Every read-write bind (workspace, run directory, thread store) and every socket is under the data
root, inside `<home>`: config.mjs:19, git-socket.mjs:1, launcher.mjs:5. So `ReadWritePaths=<home>`
covers them.
- Nothing uses `/run/user`, so `ProtectHome=yes` takes away nothing the bus needs.
- No line restricts namespaces, and the refusal for `RestrictNamespaces=`, `PrivateUsers=` and
`SystemCallFilter=` has a test.
- The step 9 session start is the real check, and it waits on #383 as stated.
N1-N5 from r10 are all in the C2-13a brief, with tests.
The port squat and its fix are right. A session inside bwrap holds capabilities only in its own user
namespace, which doesn't own the host's network namespace, so it can't bind below the port floor. cli.mjs
:27 takes ports 0..65535, so serve needs no change. eac92d956 adds both mos-1100 conditions:
- The precheck refuses a floor at or below the port.
- The bind test builds a real session argv with `binds()` (sandbox.mjs:101) and `sandboxArgv()` (:162).
It expects `EACCES` below the floor, runs a control that binds successfully, and fails rather than
skips on a host with a low floor.
## Non-blocking
N1. A correction to my own r10 B1. I cited the dragon-lin forgery acceptance as decision 94. It is
decision 95 (lead-decisions.md:2048, the text at :2058-2062). r11 9.3 has it right. Decision 94
(:2003) is the one about Jason's start, which r11's "Changes from r10" also has right.
N2. The bind test's control binds "a free port of 1024 or above". On a host whose floor is above 1024,
that control fails for the wrong reason. Pick the control port at or above the floor the test just
read, and the low port below it. It's one line in the brief.
N3. The precheck reads the Console's `--port` from the installed unit, so it can only pass after step 2c.
Say what it does when no Console unit is installed, presumably refuse with a named error, add a case for
it, and say in section 10 that the precheck runs after 2c.
N4. `AmbientCapabilities=` carries across exec, so each `human-cli.mjs` child the Console starts also
holds `CAP_NET_BIND_SERVICE`. That's harmless today, because they run reviewed code and start no
session. Add one sentence to 9.3 so that a later Console child that starts something doesn't inherit the
capability unnoticed.
+85
View File
@@ -0,0 +1,85 @@
# Webby review: Case 2 design r2 (#416, creative writing hub)
Webby, 2026-10-20T19:28Z (from `date -u`). Independent reviewer. Author: Gosalyn.
Subject: `agents/gosalyn/work/case2/DESIGN.md` at c1913fb07 on gosalyn/case2-design, blob
05a79cbca624, sha256 bda7a717...4055 (verified). Code read at 9ae8c73ac, where the citations point.
Static review. I ran nothing. I read the whole file. The r1 items I don't name below read as fixed.
## Verdict: CHANGES
Four blockers. Three are in the persona path, which is new in r2. The data model rewrite onto her
real repo is the right call, and the 1.3 claim mostly holds (see "The three questions").
## Blockers
B1. A top-level playground has no universe. 1.1 (:169-170) suggests "one at the top", but 1.2
(:191) gives a file the universe whose `books` list holds its book folder. A file under a top-level
prefix has no book folder, so `library_find` (universe required) can't return it, and the cite
check's one-universe rule (:464) has nothing to compare. Fix: put `playground` inside each universe
entry, or refuse a prefix that isn't inside a book folder. Also give playground the `.md` condition
canon has (:187). As written, a `.canvas` under a playground prefix is playground and citable.
B2. Where `persona.text` lives, and who can read it. Instance entries take inline `vars` only
(business.mjs:89 closes the keys). Nothing lets an entry point at a file, so "a 0600 file that the
business file entry takes" (:665, :722-723) has no mechanism. Inline, the text is read by more than
the design says:
- The tester's `instances` read is allowed (broker.mjs:103) and returns every instance's vars
whole (instance-settings.mjs:58-63). That breaks "No agent sees the persona text" (:724) on any
host with `bus.tester` on (vars.mjs, `bus.tester`).
- Every organization write logs old and new values of each changed path in
`businesses/.history/<id>/changes.jsonl` (organization.mjs:70-80, :201-204).
Fix: say the text sits inline in the business file, or add a file-reference var with its own
checks. Then either refuse persona keys in the tester's reads, or make C2-13's precheck refuse
`bus.tester` on `<host>`. State the mode of the business file and `.history`. Test it in C2-08.
B3. Name the places `THREAD_DEFINITION` must stay `assistant`. Section 4 (:404-406) replaces it
"wherever it means may hold a thread" and keeps only tools.mjs:186. launch-settings.mjs:140, :186
and :191 also use it. They let `organization.set`'s `assistants` key set an assistant's whole vars,
which the chief of staff holds (`ORGANIZATION_KEYS`, broker.mjs:145, :1266-1271, Q169). Read as "may hold a thread", that
lets the chief of staff rewrite Bean's `persona.text` and model. Fix: list those three lines as
staying `assistant`, and add a C2-08 test that `assistants` with `bean` refuses
`organization-not-assistant`.
The same change has a second effect the design doesn't state. With `END_PROTECTED` set to
`THREAD_DEFINITIONS` (:407-408), `instances.set` refuses to add or change a creative instance
(instance-settings.mjs:23, :109), and `definitions()` marks all six not accepted (:52). So her model
choice for Bean, a persona edit and adding a specialist later are all hand edits of the business
file. 4 (:394) and 10 step 7 read as if she can pick specialists later. Say how that happens.
B4. Name the persona block's exact place. `prompt()` is the contract (bundle.mjs:177), then
`## This session` (:179) with the session lines. The last of those says "end each turn with the
answer itself" (:188), then the end_run and review-loop lines follow. "After the contract" can
mean before `## This session` or at the very end, and the C2-08 snapshot depends on which. I'd put
it between the contract and `## This session`, so the broker's facts come after the persona. Fence
it the way skills are (`SKILL_END`, :107, :170), and refuse persona text that holds the end marker
or a line starting `## `. Hermes text is 8,000 characters that nobody here has read, so it can't be
allowed to forge a section. The snapshot then pins the order: contract, persona, session.
## The three questions
1.3, reporting is enough. For the agent-side guarantee, yes. Scope comes from the path alone, and
the cite check (:463) refuses a playground cite on `fact`, `finding` and `correction` whatever her
files contain. None of R1-R7 changes a scope. Holding back her head would add nothing to that
check. Two limits belong in the text (N1, N2).
M9. It holds today. Every organization setter builds its document from the file and changes only
its own keys (broker.mjs:1261-1269, instance-settings.mjs:93-121, launch-settings.mjs:182-191), so
no verb can change `projects` now. M9 guards against a future setter. Place it where that setter
can't skip it (N3).
Persona order. See B4.
## Non-blocking
N1. `library_get` takes only `path` (:346), so its `links_out` and `links_in` are the one place a
lookup can name a file in another universe or an unmapped file. Say both lists drop those targets
(R6, R2), and add a C2-05a test.
N2. Scope is the current mapping applied to the path. A note she moves into a book folder is canon
on her next push, and a mapping edit re-scopes old commits for the cite check. Both are right, and
both are hers. Put the first in the setup sheet (C2-12).
N3. Put M9 in `organizationWriter.write` (organization.mjs:177), comparing `canonicalJson` of old
and new `projects`, hybrid entries included. Since no verb can build the change, the C2-04 test
calls `writer.write` directly with a changed playground prefix, and one with a changed hybrid.
N4. Obsidian resolves a shortest-path link across the whole vault, which is `~/src` with her other
repos in it. The reader sees one repo. A base name unique in the books repo but not in the vault
can resolve to a different file than Obsidian shows her. Count those in the survey's link-form
counts if the survey can see it, or note it in the setup sheet.
+79
View File
@@ -0,0 +1,79 @@
# Webby review: Case 2 design r3 (Vikunja #416)
Webby, 2026-10-20T19:43Z (from `date -u`). Author: Gosalyn. Subject: `agents/gosalyn/work/case2/DESIGN.md` at
2958acb81 on gosalyn/case2-design, blob 60bc7d682e0d, sha256 f368c27d...c325 (verified). Read as the diff from
c1913fb07 (r2, my REVIEW at 162ce46db), with the code at 9ae8c73ac. Static review. I ran nothing.
## Verdict: CHANGES
My r2 B1, B2 and B4 are fixed, and so are N1 to N4. B3 is fixed for the three paths it names, but it misses a
fourth (B1 below).
- r2 B1. Playground prefixes now sit inside a universe and must start with one of its book folders, or the
file refuses with `library-playground-outside-book`. A `.canvas` under a prefix is unmapped. 1.2's universe
rule then holds for playground files.
- r2 B2. The text moves to `<configDir>/personas/<business>/<instance>.md`, and the vars hold only the name and
the digest. I checked the modes against the code. `durable` (organization.mjs:59-61) and `log` (:138-141)
open with 0600 and O_NOFOLLOW. `log` refuses a group or other bit. `directory` (:39, :45) makes 0700 and
refuses a loose one. The `instances` read (instance-settings.mjs:61-63) copies vars whole, so it now carries
only the digest. `previous.json` (organization.mjs:197-198) holds the old business file, which has no text
either.
- r2 B3. launch-settings.mjs:140, :186 and :191 stay `THREAD_DEFINITION`. `organization.set`'s `instances` key
refuses a creative instance for every actor. `instances.set` gets the human-only creative branch.
- r2 B4. The fence follows `SKILL_END` (bundle.mjs:107, :170). The block sits between `contract.trimEnd()`
(:177) and "## This session" (:179). The snapshot pins that order.
- The correction is right. broker.mjs:147-149 says the chief of staff or a human-resolved decision ends an
`END_PROTECTED` instance.
## Blocker
B1. `routes.set` lets the tester change a creative instance's harness, model and thinking. It is in
`TESTER_ORGANIZATION_VERBS` (broker.mjs:103), and the gate at :1259 lets a tester through. `setRoutes`
(launch-settings.mjs:240-272) has no protected-definition check. It replaces the route vars of any named
instance, then writes at :277. So the design's 4 is wrong in two places:
- DESIGN.md:474-493 lists every path to a creative instance and leaves this one out.
- DESIGN.md:493 says that with `bus.tester` on, the tester "could change no creative instance".
M10 (DESIGN.md:563) has no test for it. `bus.tester` is off on her business and C2-13 refuses it, so this is
not reachable there today. But the design claims the rule holds even if testing were on, and on any library
business that turns testing on, it doesn't.
Fix: in C2-08, have `setRoutes` refuse a `CREATIVE_DEFINITIONS` instance from actor `tester` with
`organization-creative-human-only`, as the `instances.set` branch does. The human keeps `routes.set`, which is
the Console's routing table. Add the path to the list at 474, fix 493, and add the case to M10's tests: the
tester's `routes.set` on `bean` refuses, the file is unchanged, and the human's still works.
## Your two questions
Keying the creative branch on actor `"tester"` is enough. broker.mjs:1272 sets the actor to `"tester"` only
for a tester session. The business load check refuses a human id of `tester` (broker.mjs:245) and an instance
of that name (:248). instance-settings.mjs:86 refuses one too. So the string can't name the human, and the
writer is the only place that knows the definition, because it reads the document. A broker-side check would
have to read the file a second time to learn the definition. The one thing to add: make C2-08's tester case go
through the broker with a bound tester session, not just `set(..., "tester")`, so a change to the actor line at
:1272 fails the test. The same goes for B1's case.
The `# `/`## ` refusal is safe, because it fails closed, but it may refuse her real personas. The fence alone
stops a section escape, because nothing after `</persona-file>` belongs to the persona. The heading rule
guards against a model reading a `## ` line inside the fence as a new section, which is reasonable. I don't
know what her Hermes persona files look like. If they use Markdown headings, C2-20 stops at the fence check
(DESIGN.md row C2-20) and the design names no next step. See N2.
## Non-blocking
N1. The chief of staff can change which model a creative instance runs without touching the instance.
`organization.set` takes `models` (broker.mjs:145, ORGANIZATION_KEYS), and launch-settings.mjs:169 replaces
the whole `launch.models` table. If `bean`'s `model` var is a key the chief of staff rewrites, Bean runs the new
model. That matches what 4 promises, since the instance and the persona stay unchanged, but mos-1033's "only
the human" reads wider. Either say in 4 that the models table is shared and the chief of staff may retune it,
or refuse a `models` change to a key a creative instance names. Mos's call, so I'd put it to him.
N2. For C2-20, count the lines starting `# ` or `## ` in each persona and print the count with the byte count,
before Jason's go. Then state the remedy in 9.1. That could be Melanie or Jason editing the text, or a recorded
rewrite to `### `. Any rewrite needs her say, because the text is hers.
N3. "Only Bean's or Chet's own session sees it" (DESIGN.md:866) needs a qualifier. `buildBundle` writes
prompt.md, persona included, once per run into the run directory at 0600 (bundle.mjs:241-244, :265). Run
directories are write-once and stay until `prune`. The claude adapter also passes prompt.md as one argument
(bundle.mjs:104-105), and that argument is visible in `/proc/<pid>/cmdline` to other local users. No verb reads
either copy, and on her VM the only other user is `melanie`, whose personas they are. So this doesn't change the
design's safety. The text should name both copies, and the C2-13 precheck should check the run root's mode
with the rest.
+61
View File
@@ -0,0 +1,61 @@
# Webby review: Case 2 design r4 (Vikunja #416)
Webby, 2026-10-20T19:58Z (from `date -u`). Author: Gosalyn. Subject: `agents/gosalyn/work/case2/DESIGN.md` at
5fc296e70 on gosalyn/case2-design, blob 7c62c40cbbb5, sha256 d06d3053...ab0a (verified). Read as the diff from
r3 (2958acb81, my REVIEW at fcac66f7b), with the code at 9ae8c73ac. Static review. I ran nothing, apart from
reading pi 0.85.1's resource-loader.js in the canonical checkout's node_modules.
## Verdict: CHANGES
My r3 B1 is fixed, and so are N1, N2 and N3.
- r3 B1. `routes.set` is now on the list (DESIGN.md:549-552), and :493 is fixed (now :565). The one rule keys
on the business file's `human`. I checked the actor lines: broker.mjs:1272 sets `tester` or the human id,
and :1278 sets `<definition>.<instance>` for a role. M10 has the tester's `routes.set` on `bean` refusing
and the human's working, and the tester cases go through a bound tester session (:567-570).
- N3. The adapter lines are right (claude adapter.sh:79, :82; pi :106, :121). I read pi 0.85.1's
`resolvePromptInput` (dist/core/resource-loader.js:17-31). It reads the value as a file when
`existsSync` is true, and on a read error it warns and returns the path itself. See N1 below for the case
where the file doesn't exist.
- N2. The dry run prints counts only, and the rule changes before the import if a count is nonzero. That's
what I asked for.
## Blocker
B1. The human does reach the `models` write, through `limits.set`. DESIGN.md:29-30 and :545-548 say that
`organization.set` takes only an agent session, "so no verb writes `launch.models` for the human today", and
that Jason edits it by hand. The first half is true. The conclusion isn't:
- `limits.set` is a human verb (broker.mjs:1248). It takes the same `ORGANIZATION_KEYS`, `models` included
(:145, launch-settings.mjs:16).
- `organization.set` is `limits.set` for a role (broker.mjs:1249-1251). Both reach the same `set()`
(:1284, launch-settings.mjs:150-169), and the `models` replace is at :169.
- So the human's `limits.set { models }` replaces `launch.models` today, with actor = her id. The tester
can't, because `limits.set` isn't in `TESTER_ORGANIZATION_VERBS` (broker.mjs:103).
The one actor rule still gives the right behaviour here, since the human passes and the chief of staff
refuses. But section 4 is the map of who can change a creative instance, and it states this path wrongly.
M10 also has no test showing the `models` check keys on the actor rather than on the verb. If someone builds
it as "refuse for every actor", the way the `instances` key at :539-541 works, her own model retune refuses,
and no test catches it.
Fix:
- In both places, say that the human writes `launch.models` with `limits.set` and the chief of staff with
`organization.set`, both through `set()`, and that the check refuses unless the actor is the human.
- Add to M10 and C2-08: the human's `limits.set` change to Bean's `launch.models` key works, next to the chief
of staff's change that refuses.
- :541 already says the human's `instances: {bean: ...}` refuses. That case also goes through `limits.set`.
Name the verb there too, so the test is written against it.
## Non-blocking
N1. Pi also uses the path as the prompt when the file doesn't exist. Then `existsSync` is false and
`resolvePromptInput` returns the input with no warning (resource-loader.js:21, :30). So the adapter's check
has to cover existence as well as readability. `[ -r "$f" ]` does both. Add a missing file to C2-08's pi
adapter test, next to the unreadable one.
N2. The claude adapter test can only show what the adapter puts in argv. Whether claude 2.1.304 reads
`--system-prompt-file` as a full replacement, the way `--system-prompt` works today, rests on its `--help`
until C2-13. Have the C2-13 precheck run one prompt through the host's claude with a marker system prompt and
check the reply, not only that the flag is listed. If it acts as an append, the contract and gate text would
sit on top of claude's default prompt, which changes what the session is told.
+21
View File
@@ -0,0 +1,21 @@
# Webby review: Case 2 design r5 (Vikunja #416)
Webby, 2026-10-20T19:54Z (from `date -u`). Author: Gosalyn. Subject: `agents/gosalyn/work/case2/DESIGN.md` at
e1635308b on gosalyn/case2-design (parent 5fc296e70), blob fd5df75ce635, sha256 9ba4f965...2d22d (verified).
Read as the diff from r4 (my REVIEW at 0597cc198), with the code at 9ae8c73ac. Static review. I ran nothing.
## Verdict: APPROVE
- r4 B1 is fixed. Section 4 (DESIGN.md:555-557) says `limits.set` and `organization.set` take the same
keys and reach the same `set()`, and that the check keys on the actor. I re-read
packages/bus/src/broker.mjs:1248-1251 and :1284 at 9ae8c73ac, and they match. The `instances` case
(:565-566) names both verbs. The `models` case (:568-573) now refuses unless the actor is the human, and
its test has her `limits.set` change to Bean's key working next to the chief's refusal. M10 (:665) and
C2-08 (:899) carry the same case. The r3 Changes entry (:44-45) records the correction rather than
deleting the old claim. No stale "agent-only" or "by hand" claim is left (grep).
- r4 N1 is fixed. :535-537 covers the missing file, the check is `[ -r ]`, and C2-08 tests a missing and
an unreadable prompt file.
- r4 N2 is fixed. C2-13 (:905) runs one marker prompt through the host's claude with
`--system-prompt-file` and checks that it replaces the default prompt.
No new findings. Section 10 step 1 stays on hold per mos-1050. That isn't mine to clear.
+34
View File
@@ -0,0 +1,34 @@
# Webby review: Case 2 design r6, section 10 step 1 (Vikunja #416)
Webby, 2026-10-20T19:59Z (from `date -u`). Author: Gosalyn. Subject: `git diff e1635308b 1508fd175` of
`agents/gosalyn/work/case2/DESIGN.md` (r5 blob fd5df75ce635, approved in webby-0021, to r6 blob 80893dd4cff1).
Static review. I checked the cites in jason-master-restart at 1c378b4 (read only): DECISIONS.md Q183 and
projects/mosaic-mvp/MELANIE-HOST-RECON.md:27-39. Both say what r6 says they say.
## Verdict: CHANGES
The Q183 reading is right. #421 is closed, FED R8 records Jason's acceptance, in-VM tightening is
allowed and is #423, and each VM gets a `mosaic` user without sudo. Section 9's isolation paragraph
(DESIGN.md:961-965) states the residual as it is now, with the recon cite.
## Blocker
B1. Step 1 has lost the owner of the root steps on .54. Until r5, ops-01's plan section 3 (#421) created
`mosaic`, `/srv/mosaic` at 0700, and the unit (DESIGN.md:947-950 still cites it). That plan stopped. r6
step 1 (:1017) says "the install creates the `mosaic` user". But step 2 (:1023) runs the install "as
`<user>`", and C2-13 runs as `<user>`. A user can't create itself, and `mosaic` without sudo can't
write a system unit `mosaic-stack.service` (:948). So nothing names who does the root part, with which
access, or under whose go. The only root paths into .54 today are pve root and `melanie`'s sudo
(MELANIE-HOST-RECON.md:31, :35-36), and using either one is an act on Melanie's machine.
Fix: list the root steps as their own item: create `mosaic` (system user, no sudo, no groups, no keys),
create `/srv/mosaic` at 0700 owned by it, and install and enable the unit. Name who runs them (Jason, or
ops-01 under his go), and through which path (melanie's sudo or pve). Say that this is step 2's go and not
step 1's. C2-13's precheck then checks the result, as it already does (rows brief a0ce2ffb8:729). Also
re-cite :950: the user spec now rests on Q183, not on the stopped #421 plan.
## Non-blocking
N1. :1018 says the precheck "refuses if it has sudo or any group". A system user still has its own
primary group. Say "no supplementary groups", and check `id -G` has one entry, or the precheck refuses
every real install. The rows brief (:729) uses the same words, so fix both.
+44
View File
@@ -0,0 +1,44 @@
# Webby review: Case 2 design r7, sections 9 and 10 and C2-13 (Vikunja #416)
Webby, 2026-10-20T20:02Z (from `date -u`). Author: Gosalyn. Subject: `git diff 1508fd175 8645cb456` on
agents/gosalyn/work/case2/DESIGN.md (blob aeaad378a344) and `git diff a0ce2ffb8 8645cb456` on
docs/plans/2026-10-20_case2-rows.md (the rows file arrived in a0ce2ffb8, between r6 and r7). Static
review. I ran nothing.
## Verdict: CHANGES
r6 B1 is fixed, and so is N1.
- Section 10 step 2 (DESIGN.md:1054-1064) now splits 2a (root: useradd, /srv/mosaic 0700), 2b (the install
as `<user>`) and 2c (root: install and enable the unit). It names ops-01 as owner, the localadmin
path from #423 step 1, and Jason's go. Step 1 (:1045-1053) is right as written. Section 9 (:973-975)
rests the layout on Q183, and the rows brief (:724-725) says the script doesn't create `<user>` or
install the unit.
- N1. "No supplementary groups" appears in all four places, and the precheck checks `sudo -n true` fails
and `id -G` prints one entry (rows :730-731).
- Mos's item 3. The residual split at :985-992 reads correctly against MELANIE-HOST-RECON.md:31, :35-36.
My one blocker is in step 2c and C2-13, not in step 1. I have no objection to the mos-1050 hold on step 1
lifting now. Mos decides that.
## Blocker
B1. Root installs a unit file that the unprivileged user wrote. C2-13's script runs as `mosaic` and
writes the `mosaic-stack.service` file (rows :724-725, DESIGN.md :940). 2c then has root install and
enable "the unit file that C2-13 writes" (:1063-1064). By 2c, 2b has already run `bootstrap.sh`, npm
install and the release activate as `mosaic`, so third-party package code has run under that uid. Any
code running as `mosaic` can change the file before 2c, for example to `User=root` or another
`ExecStart`. Root then installs a root service. The same holds on every later re-install. So 2c turns
the no-sudo user into a path to root, which is the boundary section 9 relies on.
Fix: 2c must not trust the content. Either root renders the unit from the reviewed template in the
release, with `<user>` and the paths as inputs, or root copies the file to
/etc/systemd/system/ as root:root 0644 and refuses unless `User=mosaic`, `Group=mosaic`, the
`ExecStart` path, and the hardening lines (`ProtectHome` and the rest) match fixed expected values.
Never `systemctl link` it, because a link keeps the file writable by `mosaic`. Put the check in C2-13's
tests: a unit file edited to `User=root` makes the 2c step refuse.
## Non-blocking
N1. 2b runs the install "as `<user>`, by him or his delegate", but `mosaic` has no keys and no login.
The only way in is `sudo -u mosaic` from `localadmin`. Say so, so that nobody adds a key to `mosaic`
to make 2b work.
+29
View File
@@ -0,0 +1,29 @@
# Webby review: Case 2 design r8, step 2c and C2-13 (Vikunja #416)
Webby, 2026-10-20T20:04Z (from `date -u`). Author: Gosalyn. Subject: `git diff 8645cb456 eab6e1a3a` on
agents/gosalyn/work/case2/DESIGN.md (blob 5dd7a9c9b4c0) and docs/plans/2026-10-20_case2-rows.md. Static
review. I ran nothing.
## Verdict: APPROVE
r7 B1 is fixed, and so is N1.
- `mosaic` writes no unit. In 2c (DESIGN.md:1084-1091, rows :728-736), root renders the unit from a
root-owned checkout of the commit Jason's go names, outside /srv/mosaic. It checks `User=`, `Group=`,
`ExecStart=` and the hardening lines, writes /etc/systemd/system as root:root 0644, never uses
`systemctl link`, refuses a symlink, refuses a drifted installed unit, and reads nothing under
`<user>`'s home. The tests (rows :753-759) cover an edit to `User=root`, an edited `ExecStart=`, a
removed hardening line, a symlink, and the 0644 write.
- Gosalyn's change from my fix is right, and my suggestion was wrong. The release is under /srv/mosaic and
`mosaic` can write it, so a template there is the same path to root. The root-owned checkout closes it.
- N1. 2b runs as `sudo -u mosaic -H` from localadmin, and `mosaic` gets no key (:1080-1081, rows
:724-726). `sudo -u` with a command doesn't need a login shell.
## Non-blocking
N1. The check covers named lines, not the whole file. A template line such as `ExecStartPre=+...`,
`ExecStartPost=!...` or `PermissionsStartOnly=` runs as root even with `User=mosaic`. If it pointed
into /srv/mosaic, that is the same path to root through a line the check doesn't read. The template is
reviewed, so this is defence in depth. Either compare the whole render to one fixed expected file, or
refuse any `Exec*=` value starting with `+` or `!` and any `PermissionsStartOnly`. Add one test case
for it. The existing packages/cli/systemd/mosaic-bus.service.in has a single `ExecStart=` (:30), so
the new template can follow it.
+61
View File
@@ -0,0 +1,61 @@
# Webby review: Case 2 design r9 (#416)
Webby, 2026-10-20T20:48Z (from `date -u`). Author: Gosalyn (gosalyn-0035). Commit 29e85196b on
gosalyn/case2-design, DESIGN blob 60a2d6cb751e. Round 8: case2-r8/REVIEW.md, approve on eab6e1a3a.
Scope: `git diff eab6e1a3a 29e85196b` on DESIGN.md and docs/plans/2026-10-20_case2-rows.md, including
13a98bfd0 and 29404f996 as asked. Row A's and row B's briefs (f325657c7) are not part of this round. I
read their citations only where the design shares them. This was a static review. I ran nothing.
## Verdict: APPROVE
Model path (section 9). The citations hold on origin/refactor 088087ac9:
- `ENV_ALLOW` (packages/seat/src/session.mjs:83-88) has no `ZAI_API_KEY`, so today the key is dropped.
- pi-ai reads `zai` from `ZAI_API_KEY` (env-api-keys.js:89), and `glm-5.3` is a zai model id (see N1 for
the file path).
- pi's harness sets `countsAccount` to false (pi/index.mjs:42), so the account check at launcher.mjs:296-308
never runs for pi. `preflight()` (:26) checks only the binary. `credentialStatus` (launcher.mjs:343) is
metadata passed into the launch object (:385), not a refusal. So "a pi launch has no credential check"
is true, and the precheck key-file line is the right place to catch a missing key.
- `PI_PROVIDER` comes from `session.provider` (pi/index.mjs:57).
2d. The key file is the path the bus unit already loads (mosaic-bus.service.in:24), and the `%h` in that
line resolves to /srv/mosaic because `<user>` is mosaic with that home (DESIGN 1010-1012). The design
says plainly that melanie's sudo and her Hermes processes can read it until #423 step 5 (:1042). On
Gosalyn's naming question, I'd keep `claude-session.env`. A new name means a second `EnvironmentFile=`
line, which adds a line to case2-unit.sh's fixed set and departs from the bus unit. The cost of keeping
it is a misleading name. A README line saying the file holds every session provider key, not only
Claude's, fixes that, and it can ride row A's README edit.
C2-13 and the rows doc:
- The marker prompt check now runs only for a claude-code creative instance, and a test shows a missing
claude passes when there is none. That's right, since Bean and Chet run pi.
- The key-file precheck has refusal cases for missing, 0644, symlink and empty, plus a search of the test
output for the fixture value.
- `EnvironmentFile=` joins the fixed lines, with removed and pointing-elsewhere cases.
- The Exec prefix rule covers the whole leading run, with `+` and `-+` cases, as webby-0027 asked.
- 13a98bfd0, `--profile factory|library`. `factory` requires the tester and refuses a library project
(`profile-library-business`), and `library` refuses the tester. Each direction has a test, so a wrong
flag can't reach a tester-on Case 2 install.
- 13a98bfd0, node >= 24 and bwrap prechecks. Both have fake-binary refusal cases, and a bwrap refusal
prints the userns sysctl and PATH state.
- 29404f996, the copy id. The rule matches `copy_root` (scripts/stack-copy.sh:20): 40 lowercase hex and
`scripts/mosaic` present. The tests cover short, uppercase, `/`, `..` and missing.
C2-20. The Hermes name count prints one count per persona file and never the line, number or word, and
the test checks the output holds none of the matched words. The list comes from code at df5700ebe, not
her profiles, so the script carries no persona text.
## Non-blocking
N1. The model data file is `dist/providers/data/zai.json`, not `data/zai.json` (DESIGN:1023, and row A's
brief). The id `glm-5.3` is there. Fix the path when the file is next touched.
N2. `copy_root` reads `COPIES` as `${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-stack/copies`
(stack-copy.sh:6), while case2-unit.sh fixes `<home>/.local/share/mosaic-stack/copies`. If mosaic's
login env ever sets `XDG_DATA_HOME`, copies land elsewhere and the unit refuses. That fails closed, so it
doesn't block. The install could check `XDG_DATA_HOME` is unset, or the rows doc could say why it's safe.
N3. "Sets a nonempty `ZAI_API_KEY`" should parse the file the way systemd does: the last assignment wins,
and quotes are stripped. A file with `ZAI_API_KEY=x` then `ZAI_API_KEY=` passes a first-match grep but
gives the session an empty key. Add that case to the refusal list.
@@ -0,0 +1,40 @@
# Webby review: row A brief, ZAI_API_KEY in the session env allowlist
Webby, 2026-10-20T20:55Z (from `date -u`). Requested by Sage (sage-1477). Brief text: the "Brief: row A"
section of docs/plans/2026-10-20_case2-rows.md at Gosalyn's a2d94b2c2 (file blob 81140d017441), which is the
version Sage is re-pinning to. Local refactor ecbfd6deb still carries the 29e85196b text and isn't on origin.
I read the cited code on origin/refactor. Static review.
## Verdict: APPROVE
Citations hold on origin/refactor:
- `ENV_ALLOW` is session.mjs:83-88, with the provider line at :86 (`OPENAI_API_KEY`, `OPENROUTER_API_KEY`).
- `SESSION_CREDENTIALS` and `hasSessionCredential` are at :94-95, and `sessionEnv` copies only allowlist
names at :97-99.
- launcher.mjs:299 runs the account check only when `countsAccount` is true. pi returns false
(pi/index.mjs:42).
- cli README:435 is the "A Pi session gets the same variables" sentence, and :439-444 says a key that isn't
an `ENV_ALLOW` variable launches and then fails at the first turn.
- session.test.mjs:51-59 is the existing allowlist test. Its `deepEqual` input holds no `ZAI_API_KEY`, so it
still passes after the change. :76 checks that every `SESSION_CREDENTIALS` name is in `ENV_ALLOW`, which
keeps holding.
- No test under packages/*/tests or scripts/test-*.sh pins a hash of session.mjs.
The scope is right:
- Leaving `SESSION_CREDENTIALS` alone means a Claude Code launch still refuses without a Claude login, even
when a zai key is set.
- The mutant has a real target, because the new test asserts both membership and passthrough.
- The README sentence states the cost plainly: every session the host starts gets the key, and its shell can
read it.
## Non-blocking
N1. The cli README's "Unit" section says the file "holds one line, for example
`CLAUDE_CODE_OAUTH_TOKEN=<token>`" (packages/cli/README.md:711). The brief adds a second sentence there. If
the subagent only adds, the README will say both "one line" and "every session provider key". The brief
should say to replace "holds one line" with something like "holds one line per variable, for example". This
is a doc-only fix. Fold it into the re-pin if that's cheap, since a Haiku subagent follows the brief
literally.
N2. The same paragraph says "nothing in the repository creates or reads it" (:713). That stays true for row
A. It becomes false when C2-13's precheck parses the file, so C2-13 should own that line.
+103
View File
@@ -0,0 +1,103 @@
# Webby review: row 192 r1, Q175 phase 1 SSO (#1690)
Webby, 2026-10-20T19:49Z (from `date -u`). Author: Launchpad. Candidate 83b32d8d8 on launchpad/row192, base
53bb59462. Manifest `candidate-manifest.sha256` digest 3ad2a73d...badab, verified: all 18 path hashes match
the candidate, the path list matches `git diff --stat 53bb59462 83b32d8d8`, and none of the 18 paths changed
from the base to origin/refactor (aa1bd209e). Design: `agents/sage/work/q175-sso/DESIGN.md`. Static review
plus one node probe of `returnPath` (below). I ran no suite and no mutant. I did not read
/mnt/storage/scratch/launchpad-r192.
## Verdict: CHANGES
The build follows the design closely. I checked these against the design and found them right:
- Callback steps 2-7 (sso.mjs:232-285):
- The login record is taken and deleted before any check, and the provider comes from the record.
- `validateApplicationLevelSignature` runs on the same Response, and its throw ends the callback.
- The header alg is checked against `algs`, `iss` is compared raw, and `sub` is looked up in the subjects Map.
- The `subject-unknown` line carries only the provider, iss and sub, JSON-quoted and capped at 255.
- Sessions:
- The store is keyed by sha256 and the cookie strings are exact.
- The caps are 64 sessions and 32 logins.
- Idle and absolute expiry are server-side.
- `touch` happens only on a POST or a GET outside `/api/` (serve.mjs, the `sso.session` call).
- Host (F4). Under `--sso`, anything but the public Host is 403, except GET `/healthz`. `/auth/*` routes go
to `sso.handle` before the session check, and a request without a session gets 401 on `/api/` and a 303
on a page.
- Logout needs the exact Origin and `Sec-Fetch-Site: same-origin`, and a refused logout keeps the session.
- Discovery (Q-B, sso.mjs:92-121, :151-182):
- Unreachable and Refused are split as the design says.
- A Refused provider stops the start. An unreachable one retries with doubling, capped at 300 s.
- A Refused result on a retry stops the retries.
- `readSlot` (sso.mjs:33-61):
- lstat refuses a symlink, then the open uses O_NOFOLLOW, then fstat checks the file type, the uid and the
mode against 0o077.
- The realpath checks cover the repo, the checkout and the copies root.
- The default copies root matches `stack-copy.sh:6`. I read the owner branch. It is right, and as the
design says, no test covers it.
- Tester listener (section 6, F8):
- `startServer` throws on `tester` with `sso`.
- Under `--sso` the tester listener refuses Authorization, either sign-in cookie and any `x-authentik-*`
header, and `/auth/login` is 404 there.
- The tester writer is unchanged.
- The loader (C1) checks shape only, and test 25's FIFO slot proves no case opens a slot file.
`mosaic-config.mjs` going async is safe because every reader runs it as a subprocess
(cli/src/config.mjs:22, business/src/cli.mjs:32).
- `webui-service.sh` refuses `--sso` with the preview set or the proof pair, and its status grep reads the new
flag order.
- The page: both app.js fetch paths, plus the two bus.js reads (F1), sign out on 401 `sign-in required` only.
The link goes to `/auth/login?return=<path and query>`.
- Every design mutant is in `mutants.py`, plus 10 more. A pattern that matches no test exits 0 and counts as
SURVIVED, so a typo in a pattern fails safe.
## Blocker
B1. Open redirect after sign-in. `returnPath` (sso.mjs:200-206) accepts any URL whose origin is
`publicOrigin` and returns `u.pathname + u.search`. The callback puts that string in `Location` (:284). If
the pathname starts with `//`, the browser reads the Location as scheme-relative and leaves the Console. I
ran the function as written in node, with `publicOrigin = https://mosaic.example.com`:
| `return=` | `returnPath` | Browser goes to |
| --- | --- | --- |
| `/.//evil.example` | `//evil.example` | `https://evil.example/` |
| `/x/..//evil.example` | `//evil.example` | `https://evil.example/` |
| `https://mosaic.example.com//evil.example` | `//evil.example` | `https://evil.example/` |
| the four test 14 cases | `/` | the Console |
Here is how an attacker uses it. They send Jason `https://<public host>/auth/login?return=/.//evil.example`.
He signs in at the real provider, and the Console then sends him to the attacker's page. The `.`/`..` segments
are why. Test 14's `//evil.example` parses with evil.example as the host, so the origin check catches it.
`/.//evil.example` parses as a path on the Console, so the origin check passes, and dot-segment removal
leaves the pathname as `//evil.example`. Design B2 and test 14 exist to stop exactly this.
Fix: return an absolute URL on the Console, `publicOrigin + u.pathname + u.search`. Then `//evil.example`
becomes `https://<host>//evil.example`, which stays on the Console. Or refuse a pathname that starts with
`//`. Add the three rows above to test 14, expecting `/` (or the absolute form, if you choose it), and add a
mutant that restores the current return so test 14 kills it.
## Non-blocking
N1. The brief and BUILD.md:4 cite design blob eba861f5d2bb (d55d86bb0). Refactor and the candidate have
9e3eadb15c80 (b416d2ede), which adds Mos's straggler check. The two differ only in the phase 2 window, so
the build isn't affected. Cite the current blob in the next round.
N2. Design section 6 says the tester listener also refuses "the old auth and proof header names". Under
`--sso`, cli.mjs:108 passes `testerProofHeader: proof?.header ?? null`, and that is always null because
`--sso` refuses `--proof-*`. So the old proof header name isn't refused on 7333. The `x-authentik-*` prefix
covers the old auth header. Nothing on 7333 reads the proof header, so this is harmless. Either add the
name the live unit uses to the F8 refusal, or list it under Deviations.
N3. serve.mjs:64-66: the new `SIGN_IN_COOKIE` line went between the row 177 comment and
`TESTER_CHAT_WRITE`, the line that comment describes. Move it above the comment, with its own comment.
N4. For the phase 2 window and row 200, not this row. Before 192, `mosaic-config.mjs` refuses any unknown
top-level key (53bb59462 scripts/mosaic-config.mjs:112-116). Once `console` is in the live config.json, every
loader from a pre-192 tree refuses the whole config, and every tool on that tree fails closed. That covers
older pinned copies, any checkout behind 192, and a rollback target older than 192. A 192+ tree without
`packages/webui/src/sso-config.mjs` refuses too (the import fail in `validateConsole`). The
stack-release.test.mjs:1793 fixture copies `scripts/mosaic-config.mjs` without it, which is fine while the
fixture config has no `console`. So the window has to re-pin to 192+ before it writes the key, and a rollback
past 192 has to remove the key first. Put both in the window plan.
N5. A HEAD request for a page with no session gets 401 JSON, not the 303, because serve.mjs checks
`req.method !== 'GET'`. Browsers don't send that, so no change is needed.
+31
View File
@@ -0,0 +1,31 @@
# Webby review: row 192 r2, Q175 phase 1 SSO (#1690)
Webby, 2026-10-20T19:58Z (from `date -u`). Author: Launchpad. Candidate 37582e820 on launchpad/row192 (one commit on
83b32d8d8), base 53bb59462, queue rev 2312. Manifest `candidate-manifest.sha256` digest 6f8cee14...7787,
verified. All 18 path hashes match the candidate. The paths are `git diff --name-only 53bb59462 37582e820`
less the four packet files under agents/launchpad/work/queue-192/. None of the 18 paths changed from the base
to origin/refactor (ae3c36f24). Static review plus one node probe of `returnURL` (below). I ran no suite and
no mutant. I did not read /mnt/storage/scratch/launchpad-r192, so the 28/28 and mutant results are
Launchpad's report, not my run.
## Verdict: APPROVE
- r1 B1 is fixed. `returnURL` (sso.mjs:199-211) parses the return against publicOrigin, refuses another
origin, and builds `publicOrigin + pathname + search` as an absolute URL. The callback's Location (:289)
is now always absolute. Only the callback uses it; the export at :341 follows the rename. Test 14 has my
three rows, `/board/./x`, and an origin assert on every case. The positive test expects the absolute
form. Mutant 22 restores the r1 return under test 14.
- Probe. I ran the function as written in node with publicOrigin `https://console.example.test` over 18
inputs: my three rows, `/%2e%2e//evil`, `/.\\/evil`, `///evil`, `//evil`, `/\\evil`,
`/@evil.example`, `/:evil`, CR/LF in the path (raw and encoded), a full-width dot, `:443`, upper-case
scheme and host, and a fragment. Every result stayed on the Console's origin. Raw CR/LF are stripped by
the URL parser, so no header split.
- Mutant 23 (drop the second origin check). I agree it is equivalent. sso-config.mjs:43-44 holds
publicOrigin to a bare https origin, and a parsed special-scheme pathname always starts with `/`, so the
concatenation can't parse to another host. In the probe the mutant returned the same string as the build
for all 18 inputs. Keeping the check as an unreachable guard is fine with me. Whether to keep it is
Sage's call, as BUILD.md says.
- N1 (design blob 9e3eadb15c80), N2 (under Deviations), N3 (serve.mjs:64-66, comment order) are done. N4
moves to row 200 (#1698) and the window plan through Sage (mos-1058). N5 needs no change.
No new findings.
+52
View File
@@ -0,0 +1,52 @@
# Webby review: row 194 design r2 (#1692, agent lifecycle and per-agent veto)
Webby, 2026-10-20T19:31Z (from `date -u`). Independent reviewer. Author: Honker.
Subjects at c0a35f47a on honker/row194, both sha256 verified: `agents/honker/work/queue-194/DESIGN.md`
(313c206e...fc25) and `PRD-1.7-DRAFT.md` (98f4f49e...9708). I read the whole of both and the diff
from 14a6f5f9d. Code read at 53bb59462. Static review. I ran nothing.
## Verdict: CHANGES
One blocker. B3-B8, Q2 and N1-N8 from webby-0003 are fixed as the closing table says. I checked
each against the text. B1 is fixed: hard waits, no grant, and a PRD 1.7 draft for the routed form.
B2's floor is the gap.
## Blocker
B1. The hire floor is a deny list, and it misses actions a hireable definition can hold today.
D2.3 refuses seven actions. role.mjs:40-43 already refuses the hard waits in both lists, so what is
left is the routed set (vocabulary.mjs ROUTED). The floor doesn't name these:
- `git.merge.protected`. `roles/merge-gate.json` holds it in crossRole and declares no credentials,
so it passes D2.2 item 3 and the floor. Listed in `hireable`, the chief could hire its own merge
gate. That is the same author-is-reviewer problem `review.verdict` is on the list for.
- `decision.resolve.technical`. A hired holder resolves technical decisions, so the chief would pick
who answers its own requests. `cto.json` is credentialed today, but a credential-free role
holding it would pass.
- `policy.change` and `prd.approve`. Both are routed. A credential-free role holding either passes.
- `instance.name`. It was in my r1 B2 list, which Mos accepted (mos-1018), and it is missing here.
Every action added to the vocabulary later is also hireable by default, which is the wrong
direction for a fail-closed floor (AGENTS.md invariant 6).
Fix: turn `HIRE_FLOOR` into an allowlist, `HIRE_ALLOWED`. A definition is hireable only when every
action in withinRole and crossRole is on it. From the role files at 53bb59462, the list a working
hire needs is `message.send`, `record.write`, `review.request`, `task.update.assigned`,
`task.scope.change` and `git.push.working`. The last two only matter after row 199 (credentials).
T8b and T8c then loop over `ACTIONS` minus the allowlist, one case each, so a new action is covered
without a test edit. M30-M43 become "one action added to the allowlist in the broker" and "in
role.mjs". Update the PRD draft's bound to match: "The chief hires only role definitions whose
actions are all on a fixed list of working actions. Adding to that list is a PRD change."
## Q4 (for Sage, my view)
Start `hireable` empty. Under the hard-wait form Jason resolves each hire anyway. With the B1
allowlist, today's credential-free definitions that pass are assistant and the five chief-officer
roles (message.send and record.write only). assistant is END_PROTECTED. A first kind is a policy
call and belongs in its own reviewed commit.
## Non-blocking
N1. D2.1 step 3 compares the call's arguments to "the decision body". Name the field-by-field
comparison (business from the cap, name, definition, canonical vars). A body with extra keys should
refuse, not be ignored.
N2. The PRD draft's notes say "Replace" covers the model write. The REQ-COS-3 text itself says
only "fire, replace or end". Put the model write in the requirement text, so a later reader of the
PRD alone sees it.
+37
View File
@@ -0,0 +1,37 @@
# Webby review: row 194 design r3 (#1692)
Webby, 2026-10-20T19:32Z (from `date -u`). Author: Honker. Subjects at d0afda024 on honker/row194:
DESIGN.md sha256 885fce0d...65fc, PRD-1.7-DRAFT.md sha256 04c32917...d728 (verified). Read as the
diff from c0a35f47a, which my r2 review (REVIEW-r2.md, webby-0010) covered. r3 crossed with webby-0010
and doesn't answer it yet. Static review.
## Verdict: CHANGES
r2 B1 stands in part. r3 adds `instance.name` to the floor. `git.merge.protected` (merge-gate.json,
crossRole, no credentials), `decision.resolve.technical`, `policy.change` and `prd.approve` still
pass it, and so will any action added later. The fix is unchanged: an allowlist, as in REVIEW-r2.md.
M47 and M48 fold into the allowlist mutants.
## New blocker
B2. Fire can't be "in the same transaction" as the delist. The delist is a business file commit
through the organization writer (organization.mjs write: lock, rename, receipt). The claim revoke
and the `lifecycle.cancelled` and `lifecycle.end` events are store rows. No transaction spans the
two. Fix: name the order and the failure between them. I'd write the store side first (revoke,
cancel, end), then commit the delist. If the delist fails, the runs are ended and I is still
listed, so the chief can fire again, and that second fire must work with no claims left. The other
order leaves a delisted agent with a live process and the model token, which is the F4 case. Test
a writer failure after the store commit, and add a mutant that swaps the order.
## Non-blocking
N1. REQ-LAND-2 says "Gitea itself then refuses a push to upstream or to another agent's fork". That
holds only if the agent's Gitea user has no write access to those repos. Gitea allows a push from any
collaborator. The bound should be the host's check that the remote's owner is the agent's own user,
made at each push. Gitea's refusal is the second layer. Megavolt is checking this text against row
199, so I'm sending it there as a note, not a blocker here.
N2. F4's 10 s recheck reads a pid. Use the same start-time check as the signal (launcher.mjs:526),
so a reused pid isn't reported as the fired run.
The rest of r3 reads right: F4's reason (ENV_ALLOW, session.mjs:83-94), T9a, M49-M51, R7 and the Q4
ruling.
+31
View File
@@ -0,0 +1,31 @@
# Webby review: row 194 design r4 (#1692)
Webby, 2026-10-20T19:40Z (from `date -u`). Author: Honker. Subjects at 7d0788236 on honker/row194:
DESIGN.md sha256 824ef884...3f34, PRD-1.7-DRAFT.md sha256 379cc318...05bc (both verified). Read as the diff
from d0afda024 (my r3 review), with the role files at origin/refactor. Static review. I ran nothing.
## Verdict: APPROVE
B1 (webby-0010 and webby-0011) is fixed. D2.3 is now an allowlist, `HIRE_ALLOWED`, with six actions. T8b and
T8c loop over `ACTIONS` minus the list and the hard waits. There is a mixed case and a pin test, and M47/M48
are "some instead of every". The table of today's files checks out against the role files. chief-of-staff,
cto, merge-gate, pm, reviewer and security-reviewer each hold an action off the list. Coder's six actions
are all on it, but coder is credentialed (2). The C-suite files and assistant hold only `message.send` and
`record.write`. The PRD bound now names the fixed list and says a change to it is a reviewed commit.
B2 (webby-0011) is fixed. Fire runs A (store), then B (delist), then C (consume). A and B are safe to repeat,
and T9c covers both failure points. M56-M58 kill the order swap, the early consume and the `not-held`
refusal.
The r2 N1 and N2 items and the r3 N2 item are in. The SIGKILL escalation through row 191's stop, with one
stop receipt and the halt join, reads right (T9a, T9b, M51-M55). V3 terminate stays SIGTERM-only (M54).
## Non-blocking, for the build
N1. Between a failed B and the repeat, I is still listed, so a launch of I passes (`launch` takes listed
instances). A repeat's A would stop that run, but nothing makes anyone repeat the fire. I'd have `launch`
refuse `instance-firing` while I has a `fire.stopped` with no `fire.done`, and add that case to T9c.
N2. "Every action is on the list" is true of a definition with no action lists at all. `roles/researcher.json`
is `roleVersion` 1 with no withinRole or crossRole, and the design counts it as passing. The load check's
version 2 rule refuses it, but the hire-time check in the broker should require version 2 too. A role file
edited to version 1 after load would then refuse, not pass the allowlist with no actions. Add it to T8b.
+118
View File
@@ -0,0 +1,118 @@
# Webby review: row 194 design r1 (#1692, agent lifecycle and per-agent veto)
Webby, 2026-10-20T19:14Z (from `date -u`). Independent reviewer. Author: Honker.
Subject: `agents/honker/work/queue-194/DESIGN.md` at 14a6f5f9d on honker/row194, sha256
eec9f011567d (honker-0003, which supersedes 8f80c278b from honker-0002; I read both and the diff).
Brief blob a907109409ac. Static review only. I ran no tests and changed no product code.
Code base: `git archive 5d019d876`. `git diff --stat 5d019d876 53bb59462 -- packages adapters roles scripts`
is empty, so every line below holds at the design's base. Paths are relative to `packages/` unless
they start with `roles/`.
## Verdict: CHANGES
Eight blockers. The verb split, the launcher-only start, the D3 reload through the boot loader and the
`retired` list are sound. The chief's side (O1, hire) and the request lifecycle need work.
## Citations checked
These hold: broker.mjs:145 (ORGANIZATION_KEYS), :208, :446, :770-774, :781-788 (decisions bind to the
role instance), :1601-1628; launcher.mjs:232-327 (check reloads the file at :233), :239, :527,
:624-668; broker.mjs:542-546; tools.mjs:116-119; vocabulary.mjs:135; runtime.mjs:75-81, :121-133; config.mjs:88-105;
business.mjs:117-132; instance-settings.mjs:19-31; serve.mjs:282-289, :535-538; session.mjs:245-270.
## Blockers
B1. O1 can't load as written. Adding `role.hire` and `role.fire` to ACTIONS alone makes them hard
waits (vocabulary.mjs:52-54, REQ-WAIT-3; broker.mjs:43-69 keeps its own ROUTED). role.mjs:42 then
refuses `roles/chief-of-staff.json`, because withinRole lists a GATED_ONLY action. O1 needs both
ROUTED lists, and vocabulary.mjs:52-54 says an action leaves the hard waits only when a PRD version
says so. So O1 is a PRD change, which is Jason's, not only Sage's and Mos's. Fix: restate Q1 with
that. O2 needs only the ACTIONS entries and fits the brief's "verbs, not authority". My
recommendation is O2 for this row, and O1 after a PRD version routes the two actions.
B2. A hire can still create authority, and `hireable` needs a schema. The r2 `hireable` list is
a reviewed role-file entry, which helps. But the structural rules (D2.2) still refuse only
credentialed, END_PROTECTED and version 1 definitions, and `roles/chief-of-staff.json` declares no
credentials. A `hireable` list that names chief-of-staff, by mistake or later, lets the chief hire a
second chief with role.launch, role.end, organization.change, instance.name, and hire and fire. Under
O2 there's no list at all. Sage's example, `security-reviewer`, holds review.verdict
(`roles/security-reviewer.json`), so Mos should rule knowing that hire would let the chief add
verdict holders. Fix: hire refuses (`definition-not-hireable`) any definition whose withinRole holds
role.launch, role.end, role.revoke, organization.change, instance.name, role.hire or role.fire,
whatever `hireable` and `noHire` say. role.mjs validates `hireable` (a new role-file key: name it as a
role schema change) and refuses such an entry at load. Test with chief-of-staff.
B3. Re-hire contradicts itself and carries decisions. The D2.1 table refuses a hire "when ... the name
exists". The text below it (DESIGN.md:156-157) says a fired name comes back by a hire of the same
name. If that re-hire can name another definition, approvals bound to the instance
(broker.mjs:781-788) carry to a different role. That is the problem `retired` solves for delete.
Fix: pick one. Either re-hire of a fired name keeps its definition and refuses another, or re-hire
is refused. Test the chosen rule.
B4. The V2 request isn't exactly-once, and the human's brake lapses at start. The pass runs `check()`
then `start()`. It skips authorizeLaunch (launcher.mjs:653), and `check()` never reads launch_state,
so the only revoked check is at request time (V2 step 1). A `launch.revoke` inside the 60 s window
doesn't stop the launch, against "applies to the human until they restore it". Separately, if
`lifecycleDone` fails after `start()`, the next tick sees the request still open. `check()` refuses
`instance-running` only while the run lives (launcher.mjs:239), so a short run can start twice.
Fix: one host op takes the request (writes `lifecycle.taken`) and re-checks revoked, retired and,
for a tester request, `bus.tester` (B8) in the same transaction, before `start()`. A taken request never starts again. Test a revoke between
request and pass, and a `lifecycleDone` that throws once.
B5. Terminate and delete race a launch in flight. A run that is spawned but not yet bound holds no
claim. V3 answers `not-held` for it, and an open V2 request for I still starts after a terminate.
Delete's `instance-running` reads claims only. The workspace rename isn't serialized with
`start()`'s mkdirSync (launcher.mjs:350), and the design doesn't say which process renames it or
what happens when the file commit succeeds and the rename fails. Fix: terminate closes open
requests for I as `request-cancelled`. Delete refuses `instance-starting` while a request is open or
the launcher's registry holds I. The rename runs under the launcher's `serialize` as a host op, and
the design states the order and the recovery after a failed rename. Test both races.
B6. The tester gets delete. D4.1 says the tester gets no delete, but TESTER_ORGANIZATION_VERBS
holds `instances.set` (broker.mjs:103), and `/api/instances` lets a tester through without `send`
(serve.mjs:537). Op `delete` rides `instances.set`, so the tester can delete. M18 covers `veto.set`
only. Fix: the broker refuses op `delete` from a tester session with `tester-refused`. Add it to T12
and a mutant.
B7. Keep doesn't cover the chief's model write. Through `organization.set` the chief changes an
instance's model key (broker.mjs:1266-1269). On a kept instance that changes the agent the human
chose to keep, at its next launch, which the chief controls. Fix: `organization.set` refuses
`instance-kept` for a write that names a kept instance. Test it in T10.
B8. D4.1's live `bus.tester` check has nothing live to read. `bus.tester` is a business var
(broker.mjs:544). No bus verb writes business vars: ORGANIZATION_KEYS (broker.mjs:145) and
`instances.set` don't reach them. So it changes only by hand edit, and D3 item 6 says a hand edit
needs a restart. The broker's live copy then sees Jason turn testing off only at the next restart,
which is today's behaviour, and T12a's "a live write turns `bus.tester` off" has no write to run.
Fix: each tester call reads `bus.tester` from the business file as it stands, as `harnessOn` does
(runtime.mjs:136-142), and an unreadable file refuses `tester-off`. T12a turns it off with a file
edit and no restart. M20 stays as written.
## Q2
Yes, keep should block the chief's `role.end` on a kept instance. End then launch is a replace
(E6), and the limits (`#stops`) and the human's V3 still stop a runaway. Two things go with it.
The block belongs in the reason branch only (broker.mjs:1607-1613). The decision branch
(:1614-1615) cites a decision the human resolved, which is the human's own act, so it should still
end a kept run. Say so and test it. B7 extends the same rule to the model write.
## Non-blocking
N1. D3 item 2 lists six verbs. `plugins.set` and `routes.set` commit too (runtime.mjs:128-130).
Reload after every committed write in one place in the callback, so a later verb can't miss it.
N2. `noHire` must name definitions present in `rolesDir` (D2.3). A reviewed commit that removes a
role file would then stop every business that lists it from loading. A stale entry on a deny list is
harmless, so accept unknown names, or check them at write only.
N3. Hire takes `vars`. Agent-layer vars include `harness` and the fallback route (vars.mjs:142-147),
which is more than the chief sets today through `organization.set`. Limit hire's vars to that set
plus the narrowing `limits.*` keys.
N4. D3 item 4 changes the existing `instances.set` op `change` on a running instance. Name the
existing business and webui tests that change with it.
N5. `agents.js` must join the static file list at serve.mjs:28 and `index.html`.
N6. V4 waits for the old run's exit. A run that ignores SIGTERM lets the request expire at 60 s.
State that, and whether anything escalates to SIGKILL.
N7. Keep refuses END_PROTECTED instances, so the human can't keep the assistant from the chief's
`role.end`, which Q169 allows. Say that this is intended.
N8. With `human: true`, `check()` must still send `launch.by` through the `pm` branch
(launcher.mjs:235). Add that case to T2.
@@ -0,0 +1,79 @@
# Webby review: row 197 implementation packet, round 2 (gate lanes)
Webby, 2026-10-20T20:44Z (from `date -u`). Author: Herb (herb-0023). Worktree herb-row197, branch herb/row197,
parent a4e3e857e, source uncommitted. Round 1: REVIEW-impl.md at f942bd7d4 (webby-0029, CHANGES B1 B2).
Packet hashes I checked: BUILD.patch 0b3871bd...f43e, SOURCE.json 91ba149f...596b, gate.sh 48e06e23...fed1. All
25 SOURCE.json rows match the worktree on sha256 and mode. The patch paths are the same 25 paths. The patch
applies cleanly to a4e3e857e in a scratch worktree of mine, and the result is byte-identical to Herb's worktree
for every path. I removed that worktree afterwards. BUILD-LOG.md has 25 added lines and no removed ones, outside
the packet.
This was a static review. I ran no test, no suite and no Docker command, and I did not read the frozen checkout
or Herb's other scratch roots. The counts below come from Herb's packet logs.
## Verdict: APPROVE
B2 is fixed. `leaseContext` (gate-lane-fixtures.test.mjs:29-33) takes `MOSAIC_GATE_LANE` and `MOSAIC_GATE_STATE`
when they're set, and every lease goes through the active lane's real lock dir. The other band is checked with
arithmetic (:10), and no test reserves the other lane's ports. The exhaustion test (:36-48) listens only on a
port it leased itself, and narrows `BANDS` only inside its own process. It restores `BANDS` in `finally`.
review-t4-lane-a.log: 8/8, and lane a's lock namespace is the only one present.
B1 is fixed. Each manifest case now has a test, and BUILD.md's table maps them all:
- T1 (MANIFEST.md:119-130). gate-lane.test.mjs:66-70 tests canonical, builder and existing-output refusals.
`validateTooling` has missing-file and changed-file tests (:71-81). The 92-byte socket example passes and
108 bytes refuses (:82-87). Duplicate steps stay, and NODE_TEST_CONTEXT is cleared from `cleanEnv` and from
both supervised steps (:88-99). Scoped port 9 addresses refuse (:100-103).
- T2 (:147-151). Two real shell wrappers race (gate-lane-state.test.mjs:84-98). There is one start receipt,
and the loser exits 2. The supervisor passes `verifyLocks`, the step child's /proc/self/fd has no lock
target, and the inode is stable. `activeConflicts` is tested for same-checkout and for a legacy land gate on
a free lane (:99-106). Recovery kills the proven child, a foreign child survives, a recovery killed during
Docker cleanup stays quarantined, and a resumed recovery clears the lane (:107-124).
- T3 (:171-174). Missing receipt, missing image and wrong release tag (gate-lane-image.test.mjs:46-50). A tag
that changes between steps stops the next step, and no terminal rc 0 follows (:51-61). A rebuild with the
same input digest gets a new ID, the old receipt refuses it, and cold and warm counters stay separate
(:62-72). The tag is checked again after the last step (:73-80). runGate calls the same
`executePinnedSteps` (gate-lane.mjs:184), so the test covers the real path.
- T4 (:201-209). Two generated node test files take different leases in the same lane and state. The lock is
held while the child runs and released after it exits, and the other lease stays held
(gate-lane-fixtures.test.mjs:49-62). A SIGKILLed allocator frees its own lock and leaves a foreign one alone
(:63-72). `fixtureCacheEnv` has a replacement-env test (:73-79).
- T5 (:226). Another process appends under a fixture HOME while a supervised step is running, and attribution
puts that change in `unrelated` (:80-93).
Counts from the packet logs: review-t1-resume 11/11, t2 14/14, t3 8/8, t4 8/8, all with 0 skipped and 0
cancelled. I counted the test blocks in each file, and they match. The cleanup-order fix is in place:
`ctx.cleanup` stops both children and waits for them before it removes `root`, and leaseContext's after hook runs
callbacks before it removes the state root.
The round 1 notes are addressed. N1: `closed` is no longer bound (lane-fixtures.mjs:16). N2: TMPDIR is a `tmp`
subdir (:32). N3: `%iface` is stripped before the port 9 match (lane-state.mjs:28), with a test. N4: unit roots
use gt-, gf-, gs- and gi-, and README:84-85 says so. N5: README:84 documents the node_modules link. N6: BUILD.md
records the overlap with row 200.
The existing-file edits are the same shape as round 1. They swap helpers and add env and the concurrency
option. No assert or timeout line changed (`git diff -U0` grep). Hash pins: no test under packages/*/tests or
scripts/test-*.sh pins a sha256 next to any of the 25 path names.
gate.sh matches the template shape. It skips pkg-queue, which script-queue covers. It runs every package and
script with `--test-concurrency=2`, stops on the first failure, and runs `cleanupProject` on exit.
## Non-blocking
N1. Outside a lane, `leaseContext` falls back to lane a with a private gf- state root
(gate-lane-fixtures.test.mjs:30). That is still the B2 shape. Suppose someone runs this file singly while a
real lane-a gate holds closed leases. The exhaustion test can then take and listen on one of lane a's ports,
because the private lock dir excludes nothing. A lane claim refuses beside a running `node --test`, but a single
started after the claim isn't blocked by anything. Default to the real state root
`/mnt/storage/scratch/mosaic-gate-lanes` when no lane is set, or document that singles of this file wait for
`gate-lane.sh status` to show both lanes CLEAR.
N2. The band check at :10 compares literal numbers, not the imported `BANDS`, so it would pass whatever
lane-state.mjs:7 says. Compare against `BANDS` (it's imported at :26).
N3. The T5 step child (:88) and the appender poll with no deadline. If the append never happens, the test hangs,
and the runner has no timeout. Give both a bound, for example 5 s, and exit nonzero.
None of these block. Herb can fold them into the commit or record them as a follow-up.
@@ -0,0 +1,31 @@
# Webby review: row 197 implementation packet, round 3 (gate lanes, N1-N3 delta)
Webby, 2026-10-20T20:53Z (from `date -u`). Author: Herb (herb-0026). Worktree herb-row197, parent
a4e3e857e. Round 2: REVIEW-impl-r2.md at 1f404c973 (webby-0031, approve, N1-N3).
Packet hashes I checked: SOURCE.json 35847636...2aab, BUILD.patch 55fe390d...0fde, gate.sh 48e06e23...fed1
(unchanged). All 25 SOURCE.json rows match the worktree. BUILD.patch applies to a4e3e857e in a temporary
index, and every one of the 25 blobs equals the worktree file. I couldn't diff against the r2 files because
I kept no copy. Instead, file mtimes show only two of the 25 paths changed after the r2 request (20:42Z):
packages/cli/tests/gate-lane-fixtures.test.mjs and scripts/testing/README.md. That matches Herb's list. I
read both in full where they changed.
This was a static review. I ran no test. Counts come from Herb's logs: review-r3-fixtures 8/8 and
review-r3-lane-a 8/8, with 0 failed, skipped or cancelled and no `not ok` lines.
## Verdict: APPROVE
N1 is fixed. `leaseContext` (gate-lane-fixtures.test.mjs:30) now defaults to the shared
`/mnt/storage/scratch/mosaic-gate-lanes`, so a single run takes its leases through the same lock files as a
live lane-a gate. It creates the root at 0700 if missing, the same mode lane-fixtures.mjs:20 uses, and no
longer removes it. README:95-96 says so.
N2 is fixed. The band check (:8-10) reads the imported `BANDS`. The 127 widths stay literal, and that's
fine because they pin the band size, which is the contract.
N3 is fixed. The appender (:87) and the step child (:89) each have a 5 s timer that exits 8. A missed
append makes the step's rc nonzero or `appended` nonzero, and the asserts at :91 fail. README:97 says so.
## Non-blocking
N1. No test drives the 5 s path itself. It only guards against a hang, so I'm not asking for one.
@@ -0,0 +1,84 @@
# Webby review: row 197 implementation packet (gate lanes)
Webby, 2026-10-20T20:23Z (from `date -u`). Author: Herb (herb-0018). Worktree herb-row197, branch herb/row197,
HEAD a4e3e857e, base aa1bd209e. The source is uncommitted. BUILD.patch sha256 6a1cd3e5...7792 and SOURCE.json
d0f6333a...d6ed, both checked twice. All 25 SOURCE.json rows match the worktree on hash and mode, and the patch
paths equal the SOURCE.json paths. Plan: MANIFEST.md r2, which I approved at ca6b32d66.
This is a static review plus two FD probes on node 26.8.1 in a private mktemp dir. I ran none of the new tests, no
suite and no Docker command, and I did not read Herb's scratch roots.
## Verdict: CHANGES
Most of the code matches the manifest. The existing-file edits change helpers and env only, and no removed line
holds an assert or a timeout. The Docker guard turns every `compose run` into `--no-build --pull never` and refuses
everything else except `info` and an inspect of the pinned tag. Cleanup goes by exact project label and checks
each ID's label again before removing it. `compose.yaml` declares no named volumes, so containers and networks
are everything a run can leave. The env allowlist drops `NODE_TEST_CONTEXT`. Status and recovery work from
receipts, not from flock alone.
I had one open question about the lock FDs, and the probes settled it. A bash `exec 3>> 5>>` plus flock, then
`exec node`, leaves node holding FDs 3 and 5, and a second `flock -n -x` on the lane file fails while node runs. A
child started with `spawn(..., {stdio: ['ignore', fd, fd]})` lists only 0-2 in /proc/self/fd. That matches
MANIFEST.md:156 ("ordinary child stdio does not inherit them").
## Blockers
B1. Several tests the manifest requires are missing. BUILD.md:25 says T1-T5 source is present. The manifest names
these tests, and the packet doesn't have them:
- T1, MANIFEST.md:121, 127, 130. No refusal test for a canonical or builder checkout or an existing output. No
92-byte socket test and no over-107-byte socket test (`socketInventory` has no test). No test that both candidates
carry the approved tooling. No test that duplicates stay, and none that Node entries clear `NODE_TEST_CONTEXT`.
- T2, MANIFEST.md:147-148, 151. No claim race with one winner, no same-checkout rejection, no inherited-FD test,
no surviving-child test and no interrupted-recovery test. The legacy test (gate-lane-state.test.mjs:17) checks
status only. Nothing shows that a claim refuses while a legacy gate runs and the flock is free.
- T3, MANIFEST.md:171-174. No test for a missing receipt, a missing image, a tag changed mid-gate or a rebuild
from a mutable tag. No cold/warm counter test beyond `builds === 1`.
- T4, MANIFEST.md:201-203, 209. Leases are taken one after another, not from parallel test files. No test for an
exhausted band, an occupied port, owner death or a foreign listener, and none holding a lease through child
shutdown. No cache test for `fixtureCacheEnv`, although the harness tests now import it.
- T5, MANIFEST.md:226. Attribution has a unit test with fixed entries. Nothing appends from an unrelated session
while a run is in progress.
The inherited-FD test is cheap now. The probe above shows the shape: hold the lane lock through the wrapper, spawn
a step child, and check that the child's /proc/self/fd has no lock file and that the lock stays held after the
child exits. Either add the missing tests, or list each one in BUILD.md as dropped with a reason and get Sage's ruling.
A test the plan requires is part of the change, the same rule Sage applied to row 110 N1.
B2. gate-lane-fixtures.test.mjs:5-11 leases from a private state root, in both bands, and then listens on the port
it got. That test runs inside the pkg-cli step of every lane gate. The private root's lock files don't exclude the
real leases under `/mnt/storage/scratch/mosaic-gate-lanes/ports/<lane>`, and both leasers go upward from the
band's first port (lane-fixtures.mjs:22). Here is how it fails in a paired proof. Lane a holds a closed lease on
61000, with no listener, which is how closed endpoints work (MANIFEST.md:206). Lane b's cli step runs this test.
The test's probe of 61000 succeeds, and :10 listens on 127.0.0.1:61000. Lane a's closed endpoint now accepts
connections, and that test fails. The same thing happens inside one lane, because two test files run at once.
This is the cross-lane interference the bands exist to prevent. Fix: when `MOSAIC_GATE_LANE` is set, lease only
through the run's own lane and state. Check the other band's range with plain arithmetic instead of a lease. Drop
the listen at :10, since no assertion reads it.
## Non-blocking
N1. `leasePort` ignores `closed` (lane-fixtures.mjs:16). Every call site passes it, and gate-lane-fixtures:5 is
named after it. Use it, for example by recording the lease kind for evidence, or drop it.
N2. `fixtureCacheEnv(root)` points TMPDIR at the config dir itself (lane-fixtures.mjs:32). Temp files from the
claude and pi children then land next to the fixture config they test. A `tmp` subdirectory keeps them apart. I
didn't trace whether any assertion lists that directory.
N3. The port 9 check compares exact address strings (lane-state.mjs:28). `ss` prints scoped forms such as
`127.0.0.53%lo`, and this host shows `%lo` and `%tailscale0`. A listener on `127.0.0.1%lo` or `0.0.0.0%lo`
covers 127.0.0.1:9 but doesn't match. Strip `%iface` in `hostFacts` (:22) before comparing.
N4. The new tests make roots like `/mnt/storage/scratch/ga-XXXXXX` (gate-lane.test.mjs:12,
gate-lane-state.test.mjs:46, 66). That is the run-root prefix gate-lane.mjs:201 uses, and inside a lane they sit
outside the run's own temp root. If a test crashes, its leftover looks like a lane root, and README:41-42 tells
operators not to remove another run's resources. Use a different prefix such as `gt-`.
N5. gate-lane.test.mjs:20 symlinks `/mnt/storage/src/mosaic-stack/node_modules` into the fixture, which ties the
test to the canonical checkout's tree. That is fine on dragon-lin, but please write it down in "Not tested" or as
an assumption.
N6. Coordination: row 200 (Launchpad, approved, round 2 open) also edits packages/cli/tests/stack-release.test.mjs.
It adds T56 and fixtures, and this row replaces its `freePort` calls. Whichever row lands second has to rebase
and rerun that file singly. Neither manifest says so.
@@ -0,0 +1,17 @@
# Webby review: row 197 manifest r2 (#1695)
Webby, 2026-10-20T19:44Z (from `date -u`). Author: Herb. Subject: `agents/herb/work/queue-197/MANIFEST.md` at
bdc9cd56a, sha256 2a1a3ad0...7cec (verified). Read as the diff from 84f21923c (my REVIEW-manifest.md), against
aa1bd209e. Static review. I ran nothing.
## Verdict: APPROVE
B1 is fixed. P2 keeps every literal Board `:9` URL, and `closedEndpoint` is gone from T4. The two leases left
are real close-then-rebind cases. preview.test.mjs:208-209 and serve.test.mjs:99-100 each bind a probe on port
0, close it and use the freed port as the dead board. Another lane's fixture could take that port in the gap,
so a lease belongs there. T1 refuses a listener covering 127.0.0.1:9, a missing inventory or
`ip_unprivileged_port_start` at 9 or below, and makes no connection to the endpoint.
N1 is in P3. Sage's order (sage-1384: 197 lands first, 198 rebases onto it before its manifest) makes the
two P3 lines row 198's to replace when it removes the board URL from those tests. The BUILD-LOG change is one
appended correction entry.
@@ -0,0 +1,34 @@
# Webby review: row 197 manifest (#1695)
Webby, 2026-10-20T19:39Z (from `date -u`). Author: Herb. Subjects on herb/row197: `MANIFEST.md` at 84f21923c,
sha256 16768433...b23b, and `TIMING.md` at fa820fed6, sha256 148a1416...52bd (both verified). TIMING's commit
sits on top of the manifest's. I read both whole, against DESIGN.md at 6ad25d745 (my r2 APPROVE) and the base
aa1bd209e. Static review. I ran nothing.
## Verdict: CHANGES
T1-T5 cover the design. My r2 N1 is in T5: metadata-only listings, with assertions only on lane-attributed
entries. TIMING.md copies Sage's table and says what it is not: a controlled solo baseline. The 25 values sum
to 659 s, as stated.
## Blocker
B1. Drop the dead Board `:9` replacements from P2. Port 9 is not a resource the two lanes share. On this host
`net.ipv4.ip_unprivileged_port_start` is 1024, so no test, lane or seat can bind 127.0.0.1:9. A connection
there is refused at once in both lanes. Leasing a closed endpoint for it adds isolation for nothing, and it
touches 20 files at aa1bd209e (`git grep -l "127.0.0.1:9'"`: 17 under webui/tests plus stack-copy,
launch-routes and bus tasks). I approved R9 in the design. That was a miss on my part, because R9 only allows
the change "where compatible", and nothing here needs it.
The row 198 rev 3 design also rewrites those same lines, replacing the board URL with a dead-board context.
Whichever row lands second would redo the other's edit.
Fix: remove the `:9` rows from P2 (the settings-form/proof/bus/views/tester-listener row, the browser and
tester-fixture row, the dead-Board parts of the tester-cli/proof-cli and stack-copy rows). Keep the leases
where a test closes a port and binds it again (control-board serve, stack-release freePort, service closedPort,
serve.test :100 if it rebinds). Add a T1 preflight refusal for a listener on 127.0.0.1:9, so a host where
something does listen there fails loudly instead of quietly turning those tests into connects to a live
service.
## Non-blocking
N1. Tell Sage that row 198 edits `stack-copy.test.mjs`, `pinned-copy.test.mjs` and the webui serve/preview tests
too. Even with B1, those are the files where the two rows meet.
@@ -0,0 +1,33 @@
# Webby review: row 197 (#1695), queue round 2, merge
Webby, 2026-10-21T07:18Z (from `date -u`). Author: Herb (herb-0034). Request comment 29961 (revs 2722-2723,
posted as jarvis, sage-1815). This round only checks the merge (sage-1760, sage-1776). Round 1 was APPROVE
on manifest 6318f0d0 over base aa1bd209e (comment 29665). Candidate: manifest digest
8119a6082d3bb3150874dbe45410ca4830bcbb3887d71bce8d1edbe67df47ce3, 32 paths, on herb/row197-r4-234
304c67753. Base c771a36f4 is its ancestor, and c771a36f4..304c67753 is that one commit.
What I checked, without runs or a worktree:
- I took the manifest text from `queue show 197` (round 2), not from Herb's packet. It hashes to the
digest above, and each path's sha256 from `git show 304c67753:<path>` matches its line.
- Against round 1's manifest, 22 paths have the same hash, 3 changed and 7 are new. The new ones are
Herb's agents/herb/work/queue-197 packet files. `git diff --name-only c771a36f4 304c67753` names only
manifest paths.
- Base drift: of round 1's 25 paths, only stack-release.test.mjs, webui service.test.mjs and
scripts/test-discord.sh have a different blob at c771a36f4 than at aa1bd209e. Those are the 3 changed paths.
- For each of those three, the -U0 +/- lines of aa1bd209e to round 1 are identical to the -U0 +/- lines of
c771a36f4 to 304c67753. Round 1's tree came from a commit whose files match all 12 tracked lines of
round 1's manifest (2767b8854). Each file at 304c67753 is the landed blob plus Herb's own lines, so
every landed hunk survives. The changed lines are the two `leasePort` imports, the one-line
freePort and closedPort bodies, PI_CODING_AGENT_DIR in the discord sandbox, and the
MOSAIC_TEST_CONCURRENCY branch in node_tests. No assertion or timeout changed.
- T46 (stack-release.test.mjs:913) keeps row 234's `host_grants` and `hosts` entries in the untimed list.
- File modes are the same as round 1's: gate-lane.sh and test-discord.sh 100755, the rest 100644.
- Overlap check (sage-1815): `git diff --name-only c771a36f4 c515273db` names 13 paths and none is in the
manifest. So row 240's landing doesn't touch this candidate.
Herb's slot run on 304c67753 was 559 pass, 0 fail, no skips (r4-granted/RESULTS.md). I ran nothing.
## Verdict: APPROVE
The merge keeps both sides, with the same change lines as the approved round 1. Round 1's notes stand as
recorded. `leasePort` still drops the `closed` option at lane-fixtures.mjs:16, the way round 1 accepted it.
@@ -0,0 +1,31 @@
# Webby review: row 197 (#1695), queue round 3 (r5), merge
Webby, 2026-10-21T08:13Z (from `date -u`). Author: Herb (herb-0036). Request comment 29981
(sage-197-request-5, revs 2750-2751, posted as jarvis, sage-1846). This round only checks the merge past
the 228 and 238 landings. Round 2 was APPROVE on manifest 8119a608 at 304c67753 over c771a36f4
(rev 2724, comment 29961). Candidate: manifest digest
69fad4e7ed8f9e67268378b356893cbee81e106942ffc894a9652320218c8e5e, 32 paths, on herb/row197-r5
9aa6d52d9. That commit's one parent is e7078e6a8.
What I checked, without runs or a worktree:
- I took the manifest text from `queue show 197` (round 3), not from Herb's packet. It hashes to the
digest above, and each path's sha256 from `git show 9aa6d52d9:<path>` matches its line.
- It has the same 32 paths as round 2's manifest. The only line that changed is
packages/cli/tests/stack-release.test.mjs.
- That file is blob 7398247ce983, which is what `git merge-file` of 304c67753 and 238's candidate
4c90099ea over c771a36f4 gives with no conflict. I ran that preview before Herb built r5 (webby-0063).
At e7078e6a8 the file is 238's candidate blob, and the -U0 +/- lines of e7078e6a8 to 9aa6d52d9 are
identical to round 2's c771a36f4 to 304c67753: the two `leasePort` imports and the one-line freePort.
238's hunk at :1686 in T57 and row 234's T46 `host_grants` and `hosts` entries are kept.
- The other 31 paths have the same blob and mode in 9aa6d52d9 as in 304c67753. e7078e6a8..9aa6d52d9
touches no path outside the manifest.
- Overlap: the refactor tip is 4ced5e63d. e7078e6a8..4ced5e63d is one sessions commit that changes one
path, docs/SESSIONS.md, which isn't in the manifest.
Herb's slot run of stack-release.test.mjs on 9aa6d52d9 was 196/0 in 501 s (sage-1846). I ran nothing.
## Verdict: APPROVE
r5 is round 2's approved code on e7078e6a8, and the drifted path is only stack-release.test.mjs.
Round 1's notes stand as recorded. The two-lane proof driver is a separate review (sage-1846) and isn't
part of this verdict.
@@ -0,0 +1,55 @@
# Webby review: row 197 (#1695), queue round 4 (r6)
Webby, 2026-10-21T09:29Z (from `date -u`). Author: Herb. Request comment 30004 (sage-197-review-4b,
sage-1896, mos-1162). The last approved state is round 3, r5 9aa6d52d9, manifest 69fad4e7 (rev 2755,
comment 29981). Candidate: manifest digest
1556cd296be0527697793b626ce7ae7f94de437a4fe4dee8a5714d7db434fc82, 33 paths, on herb/row197-r6
df0c59608. That commit's one parent is fefd54e13.
What I checked, without runs or a worktree:
- I took the manifest text from `queue show 197` (round 4), not from Herb's packet. It hashes to the
digest above, and each path's sha256 from `git show df0c59608:<path>` matches its line.
- It is round 3's 32 paths plus agents/herb/work/queue-197/r6-OVERLAP.json. Four paths differ in blob
or mode from 9aa6d52d9: gate-lane.mjs, gate-lane.test.mjs, Herb's BUILD.md and the new
r6-OVERLAP.json. The other 29 have the same blob and mode.
- No manifest path changed between e7078e6a8 (r5's base) and fefd54e13. fefd54e13..df0c59608 touches
no path outside the manifest.
- packages/bus/tests/process.test.mjs is identical at fefd54e13 and df0c59608, as intended (row 243
owns the chmod).
- Overlap: the refactor tip is 38b83ea5c. fefd54e13..38b83ea5c is one sessions commit, and none of its
paths is in the manifest.
Herb's focused run on the granted slot: gate-lane.test 13/0, process.test 8/0 (sage-1892). I ran nothing.
## Verdict: APPROVE
The one code change is scripts/testing/gate-lane.mjs:46. Each step now spawns
`/bin/bash -c 'umask 022; exec "$@"' gate-step <argv...>`. On Sage's three questions:
1. Argv quoting is kept. `gate-step` fills `$0`, so `"$@"` is exactly step.argv, with no word
splitting or globbing. Arguments after the `-c` string are positional, so bash doesn't parse a
leading `-` in them as an option. `node` and `bash` resolve through the step env's PATH, as
spawn did before.
2. Exit status and signals are kept. `exec` replaces bash in the same pid, so `close` reports the
test's own code or signal. The process is still the detached group leader, so `process.kill(-pid)`
in abort (:49) and cleanProcesses still reaches the whole group. identity() reads pid and
/proc starttime (lane-state.mjs:33), and exec doesn't change either, so the recorded `child` owner
still matches under sameProcess. A missing command now exits 127 from bash. That's the same rc as
the old spawn `error` path (:51), and bash's message lands in the step log.
3. The supervisor keeps 077. gate-lane.sh sets `umask 077` at :3 and execs node at :26. The new umask
is set only inside the bash child. The step log is opened by the supervisor with mode 0600 before
the spawn (:44). The supervisor's other writes give explicit modes: the out and bin dirs 0700,
docker.json 0600, the guard shim 0700 (:177-181), the state root 0700 (:89). mkdtemp roots are 0700.
The new tests check the child's mask (022), a 0644 child file, the parent mask after the run (077),
the 0600 log, and gate-lane.sh's 0700 state dir and 0600 locks.
The step env still comes from cleanEnv (:109-115). It keeps no BASH_ENV, ENV, SHELLOPTS or BASHOPTS,
so the non-interactive bash sources nothing.
## Non-blocking
N8. bash adds PWD, SHLVL and `_` to the env the test process sees. That's closer to the legacy
shell-run environment, not further from it, and nothing in cleanEnv depends on their absence.
N9. Files a step makes with 022 are 0644. Under the 0700 temp root and out dir they stay private. In
the scratch checkout they get the same modes a legacy shell run would give.
@@ -0,0 +1,79 @@
# Webby review: row 197 (#1695), queue round 5 (r7)
Webby, 2026-10-21T11:29Z (from `date -u`). Author: Herb. Request comment 30036 (rev 2819, sage-1958,
posted by Sage as jarvis). Conditions are from sage-1955. Round 4 was APPROVE on r6 df0c59608
(rev 2773, webby-0068). Its proof then stopped at solo-a script-release, because Compose 5.5.1's
`run` has no `--no-build` (#1695 c30031). I missed that in round 4, since an argv-only test can't
show that a flag exists. Candidate: manifest digest
4e54024aacfb01dda7631b562db754d92a8bd1ffe4106e9262908883a72b45c3, 35 paths, on herb/row197-r7
9c3166915. That's one commit on b1fece6f0, which is on origin/refactor.
What I checked, without runs or a worktree:
- I took the manifest text from `queue show 197` (round 5). It hashes to the digest above, and each
path's sha256 from `git show 9c3166915:<path>` matches its line.
- b1fece6f0..9c3166915 touches only manifest paths.
- Against r6 df0c59608, five paths differ in blob or mode: lane-docker.mjs, gate-lane-image.test.mjs,
Herb's BUILD.md, and the new r7-no-build.mutant.patch and r7-OVERLAP.json. The other 30 have the
same blob and mode, as Herb says.
- Base drift: fefd54e13..b1fece6f0 changes none of the 35 paths. On those 30, the -U0 +/- lines over
b1fece6f0 equal r6's over fefd54e13.
- Overlap: the refactor tip is 335bb3e30. b1fece6f0..335bb3e30 changes none of the 35 paths.
- I read the lane-docker.mjs and test diffs, the mutant patch, Herb's r7 BUILD.md section, the
docker.json writer (gate-lane.mjs:172, :178) and the proof driver's temp-root cleanup
(herb/row197-proof run-proof.mjs).
Herb's focused run on grant sage-1956: red 9/2 on the r6 guard (`unknown flag: --no-build`, plus the
missing helper), green 11/0, mutant 0/1 killed with the same error, then restored 11/0 (BUILD.md). I
ran nothing.
## Verdict: APPROVE
On Sage's four conditions:
1. The guard owns the files. For `compose run`, guardDocker returns `compose -f <checkout>/compose.yaml
-f <tempRoot>/compose-no-build.yaml run --pull never --no-deps ...` (lane-docker.mjs:36-37).
composeFiles checks the imageId is `sha256:` plus 64 hex characters and the checkout is absolute
(:6-10). A caller's option before the service must be one of `--rm -T --no-deps --interactive -i`
(:35). So `-f`, `--file`, `--file=`, `--project-directory`, `--build`, `--pull` and `--no-build`
there refuse. A top-level `compose -f ...` or `compose --project-directory ...` refuses, because
argv[1] isn't `run` (:33, :39). The test at gate-lane-image.test.mjs:120-126 checks all of them.
The record the guard reads has checkout, tempRoot and imageId from the receipt (gate-lane.mjs:172).
That closes the r6 failure class for the record fields.
2. The override goes at `shortRoot(run.tempRoot)/compose-no-build.yaml` (:9). shortRoot needs an
owned, private scratch dir of 30 bytes or less (lane-state.mjs:10-16). The file is written `wx`
at 0600 to a nonce name, then hard-linked into place. EEXIST keeps the existing file (:15-19). It
is reopened O_NOFOLLOW, and anything other than a regular file owned by us, at mode exactly 0600,
with the exact bytes, refuses (:20-27). The test checks 0600, a reuse with the same content, a
changed file and a symlink. The symlink case leaves the target untouched. Cleanup: the proof driver
removes a passed gate's temp root with `rmSync(..., {recursive:true})` after its ownership checks,
and keeps a failed one. The override sits inside that root.
3. The config check runs the installed binary: `docker compose -f ... -f ... config --format json`
on a copy of the candidate compose.yaml, with made-up env and auth and an empty DOCKER_CONFIG
(:103-119). It asserts that `services.mosaic-agent` has no `build` key and that `image` equals
run.imageId. It also checks `--no-deps` and `--pull never`. The fixture imageId stands in for the
receipt's. In a real run, the guard takes it from docker.json, which gate-lane.mjs:172 fills from
receipt.imageId.
4. The parser test (:93-101) puts `--help` after `run` and before the rewritten options and SERVICE,
so the installed Compose parses every flag and starts nothing. The mutant patch puts `--no-build`
back at :37 only. Herb's red run and the mutant run both show `unknown flag: --no-build` from that
test, which is the fault the r6 proof hit.
compose.yaml has one service, mosaic-agent, with `build:` and `image: ${MOSAIC_IMAGE_TAG...}`, and the
repo has no compose.override.yaml. With `build: !reset null` and `--pull never`, `run` can't build or
pull. A missing image fails.
## Non-blocking
N10. No test starts a container. The parser and config tests show that Compose accepts the argv and
merges to an image-only service. They don't show that `run` with `--pull never` starts from an
`image:` that is a bare `sha256:` ID. Docker accepts full IDs, so I expect it to work, but that's
the step r6 failed at. Sage's fresh D5 proof is the evidence for it, and it counts only if it gets
past solo-a script-release.
N11. `-f` sets the project directory to the candidate checkout, whatever the caller's cwd, and turns
off compose.override.yaml auto-loading. Every current caller runs from the checkout:
mosaic-task.mjs:397-399 (`cwd: PROJECT_ROOT`), hello.sh:24, verify.sh:45 and agent.sh:208. So nothing
changes today. A future caller run from a copy would get the candidate's compose.yaml, not the copy's.
N12. A failed gate's kept temp root still holds the override. It contains only the image ID, so
nothing secret is left behind.
@@ -0,0 +1,59 @@
# Webby review: row 197 proof driver, a5b636750 (proof 4 watcher change)
Webby, 2026-10-21T15:19Z (from `date -u`). Author: Herb (herb-0090). Requested per sage-2029, sage-2034
and sage-2035, after mos-1174. This is a review of the proof driver only. It isn't a queue round, and
197's candidate stays r8 c45ddeb6b (manifest 80dc1468, my round 6 approval, rev 2867). Candidate:
herb/row197-proof a5b636750, one commit on b8461266c.
What I checked, without runs:
- b8461266c..a5b636750 changes exactly 3 paths under agents/herb/work/queue-197/proof/:
run-proof.mjs (sha256 998f41948355ea86169c1711324a3c9e6cf4bdbe5157dcae9e78e414c1c16342, as Herb
says), events.test.mjs (new, 7a40bb46ed4e) and BUILD.md (5ede409c4480). All three are 100644.
- None of 197's 37 manifest paths is in that range. The branch sits on an older base (9aa6d52d9), so
its tree holds older copies of the lane scripts. That doesn't matter, because the driver imports
every lane module from `config.launcherRoot` (run-proof.mjs:85-89). It also refuses a launcher
whose HEAD isn't `config.launcherCandidate` or whose checkout is dirty (:81-82).
- I read the whole diff and r8's cleanupProject (lane-docker.mjs:41-52 at c45ddeb6b).
Herb's grant sage-2035 results (by BUILD.md and herb-0090): events.test.mjs 8/0, node --check passes,
and one real-daemon probe with r8's cleanupProject saw all four events within 309 ms. Both objects
were removed, and the post-probe listings and /proc scan were empty. I ran nothing.
## Verdict: APPROVE
- The defect. Proof 3's watcher filtered both event types by the Compose project label in one
stream. Docker 29's network events don't carry that label, so the watcher never saw a network
create, and the evidence check reported missing creation evidence.
- Network events (:12-14). The network stream filters only on type and event (:26-27), with no label
filter. dockerEvent keeps a network only if its name starts with `<project>_`, so `<project>x_...`,
`<project>-...` and the bare project don't match (the test at events.test:15-21). A network that
does carry a foreign project label throws, which fails closed. The project is
`mg197-[ab]-<12 hex>` (:7), checked before any watcher starts (:21). A foreign network would have
to use our random id to match.
- Container events (:15). These still need the exact project label, which the stream also filters
(:27). A labelled container whose name falls outside the project throws "name outside" (:16),
the same rule as the older recorded-name guard in logObject (:109).
- Shared code. The proof's ensureWatchers now calls the same watchDockerProject (:116). The final
cleanup awaits each watcher's bounded stop (:232), which SIGKILLs after 1 s and records it
(:33-37). Wrapping the body in runProof and running it only on direct execution (:264) lets
the test import the module without starting a proof.
- The preflight (:39-64, :209-211). It runs after the image check and before wave 1, and a failure
stops every wave. It creates one labelled network and one container from the pinned imageId with
`--pull never`, `--network none` and no start. The test asserts no `start` or `build` argument
(events.test:46). Cleanup always runs in `finally`, through r8's label-checked cleanupProject,
which accepts the bounded invoke function (lane-docker.mjs:43). Both objects carry the project
label, so cleanupProject finds and removes them. All four events must arrive within the 5 s
deadline, and a late event counts as missing (:59, and the test at events.test:57-60). The result
goes into summary.json and docker-summary.json.
## Non-blocking
N1. If the probe's own cleanup fails, a refusal or a timeout, the proof stops through `fail()`, but
the `finally` cleanup loop (:231) covers only knownRuns, not the probe project. Leftover probe objects
would stay until someone removes them by hand. The failure is loud and watcher-probe-plan.json
(:209) names both objects, so nothing goes unseen. Adding the probe project to that loop would make
the cleanup retry once.
N2. Herb asks Sage to update proof-phase.sh's digest pin to 998f4194...6342. That file isn't in this
commit, so I haven't checked the pin. Sage should check it against the run-proof.mjs hash before
proof 4.
@@ -0,0 +1,41 @@
# Webby review: row 197 (#1695) two-lane proof driver, round 2
Webby, 2026-10-21T08:31Z (from `date -u`). Author: Herb (herb-0053). Requested by Sage (sage-1858,
sage-1864); not a queue candidate. Scope: B1 and N3 only, from `git diff 8ae9d8acd b8461266c`. Subject:
commit b8461266c on herb/row197-proof, a fast-forward from 8ae9d8acd. run-proof.mjs sha256 is
ace376a9a333..., which I checked. The diff touches run-proof.mjs (+39 -14) and adds proof/BUILD.md. I
didn't review BUILD.md beyond noting it now exists. Static review, I ran nothing.
## Verdict: APPROVE
B1 is fixed. The `finally` takes each temp root from the run's start record in knownRuns. It never
scans scratch (run-proof.mjs:174-193). Before it removes anything:
- The path must match `^/mnt/storage/scratch/g[ab]-[A-Za-z0-9]{6}$` with the run's own lane.
- lstat must show an owned real directory, not a symlink, and its realpath must equal the recorded
path.
- The gate's owner process must be gone, by `sameProcess` with the start time and boot id. No live
process may be in one of the run's recorded `child` groups or have the temp root or checkout in its
argv. If the process inventory fails, the root is kept and the run fails.
- The last terminal record must say clean. Otherwise the root is kept and counted as a cleanup error.
A passed gate's root is then removed (:190). A failed gate's root is kept for diagnosis. Every outcome
(`removed`, `kept` with a reason, or `error`) goes to `tempRoots` in docker-summary.json (:194), and any
error sets exit 1. The launcher helpers it calls exist at 9aa6d52d9 with the fields it reads:
processInventory gives `group`, `state` and `argv` (lane-state.mjs:48-50), and gates record
`{kind:'child',group}` (gate-lane.mjs:46).
N3 is fixed. The handlers now go in at :9, before the config read, the launcher imports and any
write. `abortOnSignal` is a no-op until :97 makes it abortActive. Preparation moved inside the `try`
(:142). Each clone first yields with `setImmediate` and checks `interrupted`, so a pending signal
ends preparation through `catch` and `finally`.
## Non-blocking
N5. Kept roots are left by design: failed gates, and runs whose cleanup wasn't verified. After reading
the evidence, Sage removes each `kept` path listed in docker-summary.json `tempRoots`.
N6. One small orphan path remains in the launcher, not the driver. gate-lane.mjs makes its temp root
at :162 but writes the start record at :172 and installs its signal handlers at :174. A SIGTERM from
the driver's abort in that window kills the gate before any record exists, and the driver can't know
the root. It's an empty or near-empty `g<lane>-` dir in scratch. That only happens if one lane fails
while its partner is still validating at wave start.
@@ -0,0 +1,78 @@
# Webby review: row 197 (#1695) two-lane proof driver
Webby, 2026-10-21T08:22Z (from `date -u`). Author: Herb. Requested by Sage (sage-1850); not a queue
candidate. Subject: commit 8ae9d8acd on herb/row197-proof, parent 9aa6d52d9, adding
agents/herb/work/queue-197/proof/run-proof.mjs (176 lines, sha256 b8bb3df6..., which I checked) and
config.example.json. I read the driver whole and the launcher code it calls at 9aa6d52d9: gate-lane.sh,
gate-lane.mjs runGate and cleanEnv, lane-docker.mjs guardDocker and cleanupProject, lane-state.mjs
shortRoot, and lane-evidence.mjs summarizePairs. Static review, I ran nothing.
Sage named proof/BUILD.md for the procedure. No such file exists at 8ae9d8acd, and
agents/herb/work/queue-197/BUILD.md has no proof procedure, so I reviewed against the code and Sage's
four questions.
## Verdict: CHANGES (one blocker)
### Sage's four questions
1. Stops at the first failure: yes. A 100 ms monitor reads new bytes in each gate's out/*.log and stops
at the first failure line (run-proof.mjs:107, :116). A gate that exits nonzero sets `firstFailure`
(:151). Either way the driver sends SIGTERM to every active gate, then SIGKILL after 30 s (:98-101).
The wave check (:154) throws, so no later wave starts. Each wave also requires both lanes CLEAR before
it starts (:149) and after it ends (:157), and every gate must end with rc 0, clean, no image build and
no required skip (:131-132). A SIGINT, SIGTERM or SIGHUP sets `interrupted` and aborts the same way
(:140-141).
2. Cleanup: containers and networks yes, temp dirs no (B1). The gate cleans its own project
(gate-lane.mjs:190, :192). The driver's `finally` then inventories each project it saw, runs
cleanupProject again, and fails if anything is left (:165). It also needs create evidence for a
container and a network in each project (:172). That covers the failure and signal paths, because the
gate's SIGTERM handler aborts its detached step group and runs the same cleanup. compose.yaml uses
bind mounts only, and lane-docker.mjs allows only `compose run`, so no named volume can be created.
3. Writes and reads: the driver's own writes are under runRoot (plan, clones, state, out, logs,
docker-objects.jsonl, summaries), with one exception, B1. It reads the config, the image receipt, the
launcher modules and the canonical repo through `git clone --local`. Its Docker inspect formats read
only the name and the compose project label (:77), and it saves no raw events. It reads no secret and
no seat scratch. Gate steps get cleanEnv's allowlist (gate-lane.mjs:108-112), not the driver's
environment.
4. Concurrency and isolation evidence: enough. summarizePairs refuses unless each pair has two lanes
and two candidates, pair 2 swaps lanes, and the later start is before the earlier end
(lane-evidence.mjs). summary.json keeps each gate's terminal receipt with startMs and endMs. Each run
has its own clone, its own mkdtemp temp root, its own `mg197-<lane>-<nonce>` project, the lane's port
band, home-before/after attribution and a socket inventory. docker-objects.jsonl records each Docker
object against its project.
### Blocker
B1. Every gate leaves its temp root in /mnt/storage/scratch, outside runRoot, and nothing removes it.
The driver passes no `--temp-root` (run-proof.mjs:122), so runGate makes
`mkdtempSync('/mnt/storage/scratch/g<lane>-')` (gate-lane.mjs:162). It removes that dir only when
validation fails (:170). On a pass, a failure or a signal, it stays, with whatever TMPDIR, jiti and
XDG cache files the steps wrote into it. The proof's eight gates leave eight of these dirs. Seven
`ga-*`/`gb-*` dirs from earlier runs are already in scratch (I listed names only). The driver can't put
the temp roots under runRoot, because shortRoot refuses any path over 30 bytes
(lane-state.mjs:13). Fix: in the `finally`, take each run's `tempRoot` from its start record in
knownRuns. Check it matches `^/mnt/storage/scratch/g[ab]-[A-Za-z0-9]{6}$` and is owned by the current
user, then `rmSync(..., {recursive:true})` it. Record each removal, or its error, in
docker-summary.json, and fail on any error. Do this after the gate has exited and the process check
has run. An alternative is to have the driver mkdtemp short roots itself, pass `--temp-root`, list them
in plan.json, and remove them the same way.
### Non-blocking
N1. Each of the 8 runs makes a `--no-hardlinks` clone under runRoot. The repo's .git is 421 MB, so
together they can take a few GB. /mnt/storage has 206 GB free. They aren't evidence, so the driver
could remove the `wt` dirs at the end, or Sage can remove them after reading the evidence.
N2. The failure regex also matches any log line that starts with `FAIL` and whitespace (:90, :107). A
passing step that prints such a line would stop a good proof. That fails safe but costs a rerun. If that
happens, read the preserved log before treating it as a test failure.
N3. Before line 141 the driver has no signal handlers. That covers the 8 clones and the tooling and
image checks, so a Ctrl-C there kills the driver with no Docker objects yet and leaves only a partial
runRoot. A SIGKILL of the driver mid-wave leaves gates running. Use `gate-lane.sh recover` then.
N4. Outside the driver: compose.yaml defaults PI_AUTH_FILE to the host's ~/.pi/agent/auth.json, mounted
read-only, and cleanEnv neither keeps nor sets PI_AUTH_FILE. I didn't trace whether any gate step
reaches `compose run mosaic-agent` without its own PI_AUTH_FILE fixture. The approved gate already
behaves this way and the driver doesn't change it, so it isn't part of this verdict. Sage may want it
checked before calling the proof secret-free.
@@ -0,0 +1,55 @@
# Webby review: row 197 (#1695), queue round 6, Herb's r8
Webby, 2026-10-21T12:16Z (from `date -u`). Author: Herb. Request comment 30054 (revs 2865-2866,
sage-1989, posted by Sage as jarvis). Merge scope per mos-1162. The last round was r7 9c3166915,
manifest 4e54024a, which I approved as webby-0074 (rev 2822). Its D5 proof stopped in pkg-cli. Candidate:
manifest digest 80dc14686fe9f916d8420f7c946b327b14a3f081f75b4def704c60b1b4e7bbae, 37 paths, on
herb/row197-r8 c45ddeb6b. That's one commit on 5c6f73ed7.
What I checked, without runs or a worktree:
- I took the manifest text from `queue show 197` (last round), not from the scratch path. It hashes to
the digest above, and each path's sha256 from `git show c45ddeb6b:<path>` matches its line.
- 5c6f73ed7..c45ddeb6b touches exactly the 37 manifest paths.
- r7 against r8, by `git ls-tree` blob and mode for each path: 33 paths are the same. Two change,
gate-lane-image.test.mjs and BUILD.md. Two are new, r8-OVERLAP.json and
r8-record-lookup.mutant.patch. That's 35 r7 paths, with 33 unchanged, not 35. Herb's BUILD.md
gives the same count. The production guard, lane-docker.mjs, has the same blob as in r7.
- Base drift: b1fece6f0..5c6f73ed7 (r7's base to r8's) changes none of the 37 paths, as
r8-OVERLAP.json says.
- Overlap: refactor is now f8276c5eb. 5c6f73ed7..f8276c5eb changes queue-218's BUILD.md,
SESSIONS.md and the two case2 sheets, none of them in the manifest.
- I read the test diff, the mutant patch, the r8 BUILD section, and the record writer and shim in
gate-lane.mjs and lane-docker.mjs.
Herb's slot checks (herb-0078, 12:09:28Z, by BUILD.md): the whole file is 11/0 on a normal PATH and
11/0 with the real guard shim first on PATH plus a private record. The mutant fails the argv probe
with the shim's undefined-path error, and the restored file is 11/0 again. I ran nothing.
## Verdict: APPROVE
- The cause. composeFixture's env (gate-lane-image.test.mjs:100-101) keeps the lane's PATH, whose first
entry is `<out>/bin` (gate-lane.mjs:113), but drops MOSAIC_GATE_DOCKER_RECORD. So `docker` hit the
shim, and the shim's readFileSync(undefined) (lane-docker.mjs:57) exited 2. That matches the r7
proof stop.
- The fix. installedDocker() (test:87-93) reads the record named by the test process's own
MOSAIC_GATE_DOCKER_RECORD, which the lane sets (gate-lane.mjs:112). It takes `.docker` and asserts
it's an absolute string. The lane writes that field from `which docker` at gate-lane.mjs:170,
before it creates the bin dir (:177) or writes the record (:178). That's the same field the shim
itself executes (lane-docker.mjs:58). Outside a lane it keeps `docker`. Only the two read-only
probes use it, `--help` at :109 and `config` at :120. The fixture env is unchanged, and no guard or
allowlist code changes.
- Other callers. With a positive control at r7 (2 hits), no other test under packages/*/tests spawns
`docker` by name. The `docker info` checks in test-task.sh and test-release.sh inherit the full lane
env, record included, so the shim can read it.
- Sage's question, whether the fix can pass with the shim missing from PATH. It can, and it should.
Without the shim, both `docker` and the record's path reach the real binary. So a pass by itself
proves nothing. The proof is the mutant: with the lookup removed, the argv probe fails with the
shim's undefined-path error. That error comes only from lane-docker.mjs:57, so the run had the shim
first on PATH. In the D5 proof, the lane always puts the shim first (gate-lane.mjs:113), so pkg-cli
there tests the fix for real.
## Non-blocking
N1. The mutant receipt in BUILD.md names only the argv probe ("installed-Compose argv regression
0/1"). The config probe at :120 uses the same helper and should fail the same way, but no count shows
it. The D5 proof's pkg-cli step covers both probes under the shim, so no rerun is needed for this.
+27
View File
@@ -0,0 +1,27 @@
# Webby review: row 197 design r2 (#1695)
Webby, 2026-10-20 (from `date -u`). Author: Herb. Subject: `agents/herb/work/queue-197/DESIGN.md` at
6ad25d745 on herb/row197, sha256 732ee0e2 (verified). Read as the diff from r1 (85a6eb0a9), plus
both new probe logs. Static review. I ran nothing.
## Verdict: APPROVE
B1 is fixed. R4 moves the root to `/mnt/storage/scratch/ga-XXXXXX`, and preflight refuses `/tmp`,
anything under it, and roots over 30 bytes. D3's socket example uses that root (92 bytes). D4.2 adds
a sandboxed launcher single with a base control and separate wrapper tests for both refusals. The
r2 logs show the `bus start --pm:` single passing 1/1, no skips, on base and tree with 30-byte roots.
That test starts the real sandboxed runner. R6 now cites sandbox.mjs:112,115.
My r1 example root was wrong: `/mnt/storage/scratch/mg-a-XXXXXX` is 32 bytes, as Herb measured.
N1-N5 are in: the bands in R8 with a startup refusal, HOME in R11 and the D4.2 sentinels, the
reproducibility note in D3, the land-gate status test in D2, and keeping paired results after
adoption in D4.
## Non-blocking, for the manifest
N1. The `~/.claude`, `~/.pi` and `~/.cache` sentinels will change during any gate, because other
sessions on the host write there (every Claude Code session appends to `~/.claude/projects`). An
"unchanged" assertion would fail for reasons that have nothing to do with the lanes. Compare only
entries that a lane could have made: names that hold the lane nonce, the run's fixture prefixes, or
paths under the lane root. Or record the listings as evidence without asserting on them.
+66
View File
@@ -0,0 +1,66 @@
# Webby review: row 197 design (#1695, two gate lanes)
Webby, 2026-10-20 (from `date -u`). Independent reviewer. Author: Herb.
Subject: `agents/herb/work/queue-197/DESIGN.md` at 85a6eb0a9 on herb/row197, sha256 64a429a3
(verified). Brief `docs/plans/2026-10-20_second-gate-lane.md`. Static review. I ran no suite and no
test, and I read the three probe logs without rerunning them. Code read at the design's base
c6f528a32. Host facts read with `/proc/sys/net/ipv4/ip_local_port_range` and `ss -Hltn`, read only.
## Verdict: CHANGES
One blocker. The inventory is careful and most of it holds. The temp root it picks breaks the
sandboxed runner tests on this host.
## Citations checked
These hold: common.sh:24-34 (load_release), test-release.sh:10-15 (RELEASE backup and restore),
mosaic-task.mjs:321 (Compose inherits the whole environment, so `COMPOSE_PROJECT_NAME` reaches it),
:395-397; hello.sh:24; agent.sh:208; release.sh:122; compose.yaml has no top-level `name` or
`container_name`, and no script passes `-p` or `--name`, so R3's project isolation works.
Containerfile:18-25 copies exactly the inputs D3 fingerprints. No `.dockerignore` exists.
claim.mjs:50-55 and cohort.mjs:111 name scopes from the random claim; the impostor test
(cohort.test.mjs:484) reuses its own recorded unit. No literal nonzero `listen()` in any src or test
(R7). thread-launch.test.mjs runs its adapter inside bwrap (:4, :32), so the adapter's
`$HOME/.claude/projects` write (fixtures/thread-adapter.mjs:14-20) lands in the run's home/.
One miss: R6 cites sandbox.mjs:118-120 for the private /tmp. At c6f528a32 the binds are :112
(the run directory) and :115 (`<runDir>/tmp` onto `/tmp`).
## Blocker
B1. R4's temp root under `/tmp` hides every sandboxed run directory. sandbox.mjs:112 binds the run
directory at its own path, then :115 binds `<runDir>/tmp` over `/tmp`. With `TMPDIR=/tmp/mg-a-XXXXXX`,
fixture data roots and run directories sit under `/tmp`, so the second bind hides them inside the
sandbox. bwrap then fails with `Can't chdir to /tmp/mosaic-launch-.../launches/acme/<run>`, and the
launcher reports `runner ... did not start`. Seats have hit this three times: row 140 (9 of 18
launcher tests), row 149 r3 (10 of 19) and a control on the base each time. D3's own socket example
(`/tmp/mg-a-ABCDEF/mosaic-cli-ABCDEF/data/launches/...`) is one of these paths. R6's "Host TMPDIR
must not be ... used as a socket path visible only on the host" names the risk but R4 walks into
it. The length limit is real too: row 177 lost 24 of 396 cli tests with a 43-byte TMPDIR (session.mjs
refuses `tools.sock` past 107 bytes), and passed at 30 bytes. Today's gates inherit
`TMPDIR=/mnt/storage/scratch/tmp` (21 bytes), and Sage's gate template sets none.
Fix: put each lane's temp root outside `/tmp` and at 30 bytes or less, for example
`/mnt/storage/scratch/mg-a-XXXXXX` (30). Preflight refuses a temp root that resolves under `/tmp`
or is longer than the measured limit. Redo D3's socket example with that root (the tools.sock path
grows from 78 to 92 bytes), and add a sandboxed launcher test to the D4.2 single-file probes, run
with the lane root, with a base control.
## Non-blocking
N1. Name R8's bands. Below 32768 the host listens on 3000, 3001, 3080, 3773 (T3), 5556, 6463, 6789,
7332 and 7333 (Console and tester), 8001-8888, 9443, 20050 and 26400. Nothing listens above 60999,
and `ip_local_reserved_ports` is empty. Use `61000-61127` for a and `61128-61255` for b, and have the
startup check refuse any band that overlaps `ip_local_port_range` or a live listener.
N2. R11 leaves HOME as the real home for both lanes. Nothing I found writes there outside a sandbox
(claim.test.mjs:667-673 asserts a refusal, the stack-release stand-ins get a scratch HOME from the
rehearsal, seat tests pass HOME to bwrap only). Say that in R11, and add `~/.claude`, `~/.pi` and
`~/.cache` listings to D4.2's foreign sentinels so a new test that writes there shows up.
N3. The Containerfile builds `FROM node:24-bookworm-slim` by tag and runs `apt-get update`, so two
builds of identical files differ. D3 already relies on the pinned image ID. Say outright that
"identical inputs" means identical files plus the same pinned ID, never a rebuild.
N4. The production landing gate names its Compose network after the worktree (git-socket.mjs:239-246)
and drops lane variables (landing.mjs:63-90). R17 treats it as LEGACY BUSY, which is right. Add a
status test where a land gate runs beside a lane gate.
N5. Three paired runs (six gates) is a small sample for flakes that show up one run in ten. Keep six
as the bar for adoption, and record every later paired gate's result in the lane receipts so Sage
sees the rate as it builds.
+40
View File
@@ -0,0 +1,40 @@
# Webby review: row 198 design rev 3 (#1696)
Webby, 2026-10-20T19:36Z (from `date -u`). Author: Bushroot. Subject: `agents/bushroot/work/queue-198/DESIGN.md`
at 7e2ead7f3, sha256 51343813...8302 (verified). I read the whole file and checked it against my rev 1 review
(REVIEW.md) and sage-1372. Code read at the base, aa1bd209e. Static review. I ran nothing.
## Verdict: CHANGES
My rev 1 B1-B3 are fixed. B1: COPY_PATHS gains `packages/control-board/src` and `tools/tmux`, with a fresh-copy
test and M11. B2: the `--board` drop in `console_for_target`, with M12 and M13. B3: both POSTs sit above
serve.mjs:557-558 behind `send !== null && named`, loopback refuses them with 403, and M10 covers it. N1-N5 are in.
The sage-1372 rulings read right. At aa1bd209e, webui-service.sh:44, 71, 110 and 161-163 carry `--board`, and the
unit template has no PrivateTmp, so the Console can reach the tmux socket that `reply` needs.
One new blocker, which comes from moving the board in-process.
## Blocker
B1. In-process, the board's child calls block the Console's event loop. `scan` checks each seat with
`tmuxInspect`, which runs `spawnSync("tmux", ..., { timeout: 5000 })` (control-board scan.mjs:196-200).
`replyToRow` runs `agent-send.sh` through `spawnSync` with a 15 s timeout (serve.mjs:68, :94). Today those block
only the 7331 process. In decision 1 they run inside the Console, and every `/api/board`, `/api/seen` and
`/api/reply` rescans. So one slow tmux server stalls every Console route for up to 5 s per seat, and one reply
can stall it for 15 s: bus reads, the event stream, chat, `/healthz`, and the tester listener, which shares the
process (webui cli.mjs:91). The Console already avoids this for the bus. webui bus.mjs:3 says it uses
"spawn, not spawnSync, so one slow read never stalls the HTTP server".
Fix: `createBoard` takes an async `exec` (spawn or promisified execFile with the same timeouts) for the tmux
check and for agent-send, and `index()`, `markSeen()` and `reply()` return promises. The board's sync exports
stay for `scan` on the CLI. Add a test with an injected exec that resolves after 2 s on `/api/board`, during which
`/healthz` answers within 200 ms. Add a mutant that swaps in a blocking exec, which that test kills.
## Non-blocking
N1. Decision 8's last two sentences put the 7331 fallback on the wrong copy. A copy that doesn't parse
`--board` is a new one, with no board URL at all. The 7331 default belongs to an older copy that parses the
flag when the pointer records none, which is ruling 3's case. Rewrite those sentences.
N2. `console_for_target` finds `--tester-port` support by grepping the target's cli.mjs (stack-release.sh:670).
If the `--board` check copies that, a cli.mjs that names the removed flag (a refusal message, a comment) passes
as "parses it". Pick a check that a refusal can't match, and give M12 a target whose cli.mjs mentions `--board`
only in a refusal.
+32
View File
@@ -0,0 +1,32 @@
# Webby review: row 198 design rev 4 (#1696)
Webby, 2026-10-20T19:42Z (from `date -u`). Author: Bushroot. Subject: `agents/bushroot/work/queue-198/DESIGN.md`
at 9af75dc72, sha256 5ef6436f...82f1 (verified). Read as the diff from rev 3 (7e2ead7f3), against the base
aa1bd209e. Static review. I ran nothing.
## Verdict: CHANGES
B1 is fixed. Decision 10 makes every child call that `createBoard` reaches async. The parse and reply shaping
are shared, and there is a stall test, which M14 (`Atomics.wait`) is a fair mutant for. N1 is fixed. Decision 8
now puts the 7331 fallback on the older copy. The `--board` half of N2 is right. `flag === '--board'` has been
the parser line since ea00ec66d (2026-09-12), so every copy that parses the flag matches it.
## Blocker
B1. Leave the `--tester-port` check alone in this row. Decision 8 swaps the grep at stack-release.sh:670 for
`parses_flag`, which looks for `flag === '--tester-port'`. The existing stack-release fixtures don't contain
that text. stack-release.test.mjs:265 writes the target cli.mjs as `// serve accepts '--tester-port'` and
`// serve accepts '--proof-header' (row 178)`. Under the new helper, every fixture copy reads as not supporting
the tester port, and T37 (:1051) and the other tester-port cases fail at the gate. The file table and the test
line list don't name that fixture change. No copy today names `--tester-port` in a refusal, so the gap I named
in N2 exists only for `--board`.
Fix: use `parses_flag` for `--board` only and leave :670 as it is. Or keep the swap, but name the :265 fixture
edit and each test it touches in the manifest, and get Sage's agreement first, since it changes a cli suite
fixture outside this row's subject.
## Non-blocking
N1. Coalescing in `index()` can serve a stale mark. `markSeen` writes the mark and then rescans
(control-board serve.mjs, the `/api/seen` branch). If it joins a scan that started before the write, its
answer lacks the mark it just set. Have `markSeen` and `reply` start a fresh scan, or join only a scan that
started after their write. Add that case to board.test.mjs.
+27
View File
@@ -0,0 +1,27 @@
# Webby review: row 198 design rev 5 (#1696)
Webby, 2026-10-20T19:47Z (from `date -u`). Author: Bushroot. Subject: `agents/bushroot/work/queue-198/DESIGN.md`
at 3f71b359b, sha256 1503f764...a32b (verified). Read as the diff from rev 4 (9af75dc72), which touches only
DESIGN.md. Static review. I ran nothing.
## Verdict: APPROVE
B1 is fixed. `parses_flag` is used only for `--board` (`console_for_target` and rollback). The
`stack-release.sh:670` grep and the `stack-release.test.mjs:265` fixtures stay as they are, and the
`--tester-port` test row is gone.
N1 is fixed. `markSeen()` and `reply()` bump a write counter and start their own scan, and `index()` joins only
a scan that started at the current counter. The new board.test.mjs case holds a scan open, then calls
`index()` and `markSeen()`, and M15 joins the pre-write scan, so the test can kill it. The gate line reads
M1-M15.
The sequencing note fits sage-1386 and row 197's P3. 198 rebases onto 192 and 197, leaves the two overlap lines
(serve.test.mjs:100, preview.test.mjs:208) alone until 197 lands, and goes to manifest review only after that.
## Non-blocking, for the build
N1. Pin the order inside `markSeen()` and `reply()`: write the mark (or send), then bump the counter, then start
the scan. If the bump comes before the write, an `index()` call in between joins a scan at the current counter
that may read the mark file before the write lands, which is the r4 case again. The mark write is synchronous
today (writeFileSync and renameSync, scan.mjs:431-432), so this holds if the code keeps that order. Put a comment on it, and name the order in the M15 row so
the manifest carries it.
+76
View File
@@ -0,0 +1,76 @@
# Webby review: row 198 inventory and design (#1696, retire the control board writer on 7331)
Webby, 2026-10-20 (from `date -u`). Independent reviewer. Author: Bushroot.
Subjects: `agents/bushroot/work/queue-198/INVENTORY.md` at 004f133bd (sha256 76b630ce...c877) and
`DESIGN.md` at c8fe23a37 (sha256 c2ea3df5...cb08), both verified. Brief read at a31f1a0f8 (blob
11977a5cb, with Mos's call mos-1019). That commit is in the canonical repo's objects, so the text
reviewers read is that blob. Static review, code read at c8fe23a37. I ran no tests.
## INVENTORY.md: APPROVE
My own `git grep` for `7331` and `control-board/src` outside v1/, agents/ and docs/ finds the
same hits. The five webui files that import `startServer` (fixture.mjs:6, history-fixture.mjs:8,
discord.test.mjs:6, relaunch.test.mjs:6, return-flow.test.mjs:13) and flows.test.mjs:15
(`replyToRow`) are right. No source under `packages/webui/src` imports the board. The proxy rows
hold: serve.mjs:12 (`DEFAULT_BOARD`) and :559-580 forward `/api/board`, `/api/conversation(s)`,
`/api/seen` and `/api/reply`, and answer 502 when nothing listens. So the board view is dark live
today. No test pins a hash of a file this row changes.
Two dependencies are missing because they don't name the board. B1 and B2 below cover both.
## DESIGN.md: CHANGES
Three blockers. The in-process split, the CLI refusal and the fixture plan are right. Two release
paths would take the live Console down at the next re-pin.
### Blockers
B1. The pinned copy has no board code. The live Console runs from `copies/<sha>`, and
`scripts/stack-copy.sh:23-33` COPY_PATHS lists `packages/webui/src` but not
`packages/control-board/src` or `tools/tmux/`. Once `webui/src/cli.mjs` imports `board.mjs`, the
Console started from a copy fails at import. The unit restarts on failure
(mosaic-webui.service.in, `Restart=on-failure`, `StartLimitBurst=5`) until it gives up. `reply`
also resolves `agent-send.sh` beside the module (control-board serve.mjs:59), so it would point
into the copy too. Fix: add `packages/control-board/src` and `tools/tmux/agent-send.sh` to
COPY_PATHS, or resolve agent-send from `--checkout`. Say which, and add a test that starts the
Console from a fresh copy (pinned-copy.test.mjs or stack-copy.test.mjs) and gets 200 on `/api/board`.
B2. A re-pin carries `--board` into the new copy, and the new `webui-service.sh` refuses it.
stack-release.sh:128 reads the pointer's Console flags into P_CONSOLE, :676 copies them to
RUN_CONSOLE, and :459 passes them to `webui-service.sh install`. The live pointer holds
`--board http://127.0.0.1:7331`, so the first re-pin onto a copy with this row fails at
`install-console`. Decision 6 covers only a hand restart of the old unit. Fix: stack-release drops
`--board` when the target's `cli.mjs` lacks it, the way `console_for_target` drops `--tester-port`
(:667, :687-690), and reports it in the plan. A rollback to an older copy runs without the flag,
since its default is 7331. Test both in stack-release.test.mjs.
B3. Say where the board POSTs sit relative to serve.mjs:558. Today, in preview mode, :558 refuses
every non-GET before the board block, so `/api/seen` and `/api/reply` never pass. In loopback mode
`--send` can't be set at all (:479). So with `send !== null && named`, the two routes work only
if their branch sits above :558, like `/api/bus/message` (:550) and chat (:554). The positive
control ("/api/reply runs with --send") is then the first board write the live Console has ever
allowed. Mos ruled it ("/api/reply stays only under --send", mos-1019), so it's in scope, but the
design must say so. Fix: name the placement above :558 and after the header and origin checks
(:493-516). State that loopback mode can't reply or mark seen. Add a mutant that moves the branch
below :558, which the positive control kills.
### Your question on `/api/seen`
Gate it. It writes marks under the board dir, and without the gate it would be the only POST in
preview mode that passes without `--send`. M5 covers it.
### Non-blocking
N1. control-board cli.mjs:14 defaults `fleet` to `~/.mosaic/fleet/agents`. Say which fleet source the
Console's board context uses. The fleet is being retired, so `none` may be right. Either way it's
a read only.
N2. M2's "no `node:http` import and no `listen(`" check should cover `packages/control-board/src`
as a whole, so a listener in a new file fails too. The design reads that way. Say it in the test
name.
N3. 19 lines in the webui tests match `Board unreachable|502`, and not all are board cases. List the
ones that change to 503 in the manifest by file. The 17 dead-board files match your count (16 plus
tester-fixture.mjs).
N4. Mos's loopback ruling is open. Until then, record in the README that loopback board reads stay
open to any local process, as they are today.
N5. Order with row 192. The board block sits next to the proof gate (:493-505) that SSO changes.
Land the second of the two on the first and rerun the board route tests.
+71
View File
@@ -0,0 +1,71 @@
# Webby review: row 200 r1, `stack-release.sh activate --sso` (#1698)
Webby, 2026-10-20T20:13Z (from `date -u`). Author: Launchpad. Candidate 5dc0b5534 on launchpad/row200, base
4d487dfdc (aa1bd209e plus row 192 r1 and r2; patch-id matches 53bb59462..37582e820). Packet 39b52b71f. Manifest
`candidate-manifest.sha256` digest 46179d24...708c, verified: 3 paths, every hash matches `git show
5dc0b5534:<path>`, and none of the 3 changed from the base to origin/refactor. Brief
`docs/plans/2026-10-20_stack-release-sso.md` (a4a42dbac). Static review plus one bash probe of `cd; pwd` through
a symlink (N1). I ran no test and no mutant; T56 and the 9 mutants go to Sage's gate. I did not read
/mnt/storage/scratch/launchpad-r200.
## Verdict: APPROVE
Brief items, checked against the code at 5dc0b5534:
1. **Flags and plan.** `sso_for_target` (stack-release.sh:775) drops `--public-host`, `--auth-header`,
`--allow-user` and the proof pair, and puts `--sso` before `--send` and `--tester-port`. That is where
webui-service.sh:213 renders it, and its status grep at :401/:406 reads that order. Dropping
`--public-host` goes past the brief's list, but webui-service.sh:178 refuses it beside `--sso`, so it
has to go. The `sso:` plan line names what it drops and carries, in the style of row 189's `send:` line.
The usage check at parse_opts refuses `--sso` with a pair, with `--no-proof` and on any other verb, and
T56 tests all seven combinations.
2. **Refusals before any stop.** `sso_checks` (:817) runs inside `console_for_target`, which `precheck`
calls right after `check_target` (:996). `config_checks` runs after `plugin_checks` and before `new_activation`. None of these
steps stops anything. `sso-config` runs the target copy's own `checkConsole`. The slot path is
`dirname(cfg)/secrets/mosaic-stack/<slot>`, the same path the Console builds: business.mjs:25-27
`configDir` and sso.mjs:35. The slot name is already limited to `[a-z0-9-]{1,64}` (sso-config.mjs:56), so
it can't climb out of that directory. `slot_problem` uses `[ -e ]`, `[ -L ]`, `stat`, which doesn't follow
links, and `realpath`. Nothing opens the slot, and T56's read watch on it would catch an open.
`checkConsole` requires 1 to 8 providers (sso-config.mjs:45), so the `while read` loop never sees an
empty line.
3. **Rollback.** `sso_base` (:728) reads the arrival set, not the latest one. That set's
`show-mosaic-webui.service.txt` is written by `capture` (:445) before that run stopped anything, so it
holds the previous copy's Console, and SHA256SUMS covers it. The same-copy re-pin subtest and mutant 8
pin the sage-1389 ruling. The four `sso-rollback-flags` causes are each tested.
`config-console-key` covers my row 192 N4 for both verbs, and its message names the first step.
4. TOOLS.md matches the code. I checked the flag order, the refusal names, the stat-only slot checks and
the N4 wording.
The gate the brief asks for is there: one mutant for each of the five refusal codes, plus the `--auth-header`
mutant, plus `--public-host`, the arrival set and the N4 first step. Each pattern has to occur exactly once
or the script aborts. Hash pins: no test under packages/*/tests or scripts/test-*.sh pins a sha256 of
stack-release.sh or TOOLS.md.
## Non-blocking
N1. The inside-repository slot check compares a resolved path to an unresolved `$REPO`
(stack-release.sh:859). `REPO="$(pwd)"` after `cd "$(dirname "$0")/.."` (:25-26) gives the logical path. I
ran a script through a symlinked directory and got `pwd` = the link path and `realpath -e .` = the target.
AGENTS.md names one such link, `~/src/mosaic-stack-dev-test` -> `/mnt/storage/src/mosaic-stack`. Run through
that link, a slot inside the repository passes this check. The Console's `readSlot` then refuses it after
the stop, because sso.mjs:47-50 resolves each root with `realpathSync` (:49). The health gate puts the old
stack back, so nothing breaks for good, but item 2 asks for that refusal before any stop. `:858` already
resolves `$COPIES`. Do the same for the repository (`repo="$(realpath -e -- "$REPO")"`) and match against
that. The line is copied from row 187's `proof_file_problem` (:987-988), which has the same gap. That one is
older than this row. Fix both, or file it as a follow-up.
N2. `config_checks` returns 0 when the config fails to parse (:868, `2>/dev/null || return 0`). The `console`
check then passes silently, and so does a config whose JSON is `null`. The verb goes on with a config no
loader reads. That isn't a regression, because no check read the config before row 200, and `sso_checks`
refuses an unparseable config under `--sso`. For a fail-closed script, refusing here seems better than
returning 0. If not, add a comment saying why a parse failure counts as "no console key".
N3. The slot check uses size 0 for `readSlot`'s one-non-empty-line rule (:860). A slot holding only `\n`,
or two lines, passes the script and fails the Console after the stop. The script can't check more without
reading the slot, and that would be the wrong trade. List it under "Not tested" or in TOOLS.md as a check
the Console makes and this script doesn't.
N4. This is for the window plan, not this row. Once a copy with `--sso` support is live, no verb turns
`--sso` off on it. activate has no `--no-sso`, an activate onto a copy without support refuses
`sso-target`, and a rollback to a copy with support carries the flag. The only way back to the preview set
is a rollback to a copy before row 192. The window plan's back-out step should say so.
+34
View File
@@ -0,0 +1,34 @@
# Webby review: row 200 r2, `stack-release.sh activate --sso` (#1698)
Webby, 2026-10-20T20:30Z (from `date -u`). Author: Launchpad. Request comment 29621 (rev 2352, sage-1445).
Candidate e3fd9f3a0 on launchpad/row200, one commit on r1 5dc0b5534 plus packet d163cfccb. Manifest
`candidate-manifest.sha256` digest a1f6fe91...5bd3, verified: the same 3 paths, and every hash matches `git show
e3fd9f3a0:<path>`. `git log aa1bd209e..origin/refactor` touches none of the 3 paths. I read the r1-to-r2 diff
along with common.sh `load_config`. I ran no test and no mutant; T56 and mutants 10-12 go to Sage's gate.
## Verdict: APPROVE
N1 is fixed. `slot_problem` (stack-release.sh:859-860) and `proof_file_problem` (:998-999) compare against
`realpath -m` of `REPO` as well as `COPIES`. The new subtest runs the script through `base/repo-link.200`, and
since `base` is realpath'd (test:237), `$REPO` there really is a different string from the resolved repository.
The proof file and the slot (its secrets dir links into the repository) both sit under the real `base/repo`.
Under r1 code each would pass the repository check, so the expected `proof-file-invalid` and `sso-slot` messages
kill mutants 10 and 11. Both fixture dirs are under the test's temp base, not the checkout.
N2, with the deviation: I agree with it. `load_config` (common.sh:11-16) runs `scripts/mosaic-config.mjs env`
on the same `MOSAIC_CONFIG` or default path and exits 1 on a file that doesn't parse. That happens at
stack-release.sh:1696, before the pointer, log or units are read. So `config_checks`' new key=2 refusal is
reachable only if the file changes after `load_config`, and mutant 12 survives for that reason. That's the
same as row 192's mutant 23, and keeping the guard is right. The node probe also fails closed: any exit
other than 0 or 1 (a read error, a crash) refuses. A JSON `null` counts as "no key", and `load_config` refuses
it first anyway. The subtest checks the path that does happen: three verbs exit 1 with the loader's message,
nothing changes, and activate works after the restore.
N4's back-out line is in the window plan section of BUILD.md, and it matches my wording. N3 stays a note as
ruled.
Hash pins: no test under packages/*/tests or scripts/test-*.sh pins a sha256 of stack-release.sh or TOOLS.md.
## Non-blocking
None new.
+56
View File
@@ -0,0 +1,56 @@
# Webby review: row 200 (`activate --sso`), round 3
Webby, 2026-10-20T23:29Z (from `date -u`). Author: Launchpad (launchpad-0014), queue revs 2524-2525. Candidate:
manifest agents/launchpad/work/queue-200/candidate-manifest.sha256, digest b3ead55c8b4895aded37002555895b9753adaa1b01b9832a7cbd2fc533a58aa5, 3 paths, on
launchpad/row200-r3 9df40878f. Base 00d4cc1f6 (row 190's landing) is its ancestor. Sage asked for this round
in sage-1557: "check activate --sso for a named instance against 190's code".
What I ran, on a detached worktree of 9df40878f in my own scratch, removed afterwards:
- The manifest file hashes to the digest above, and `sha256sum -c` passes for all 3 paths.
- `git range-diff` of the r2 commits against the r3 commits.
- Each touched test file alone with `node --test --test-reporter=tap`: stack-release 192/192 and stack-instances 23/23, 0 failures.
- A grep of `packages/*/tests/*` and `scripts/test-*.sh` (with a control that does print hits) for 64-hex pins
and for readers of the 3 paths. No pins. Only stack-release.test.mjs reads them; stack-instances.test.mjs
names stack-release in a comment.
I ran no mutants. Mutants 1-14 run in Sage's slot.
## Verdict: APPROVE
The merge keeps both sides. range-diff shows the first commit differs only in the conflict hunks, and the
others are unchanged. In `plan()` the `sso:` line comes before the Console line, and the Console and CLI lines
keep row 190's `$flags`. Both `--plan` lines run `check_target; console_for_target; ports_unclaimed;
plugin_checks; config_checks; plan`, so row 190's `ports_unclaimed` stays where it landed.
`activate --sso` on a named instance is correct with no code change, as BUILD.md says. I traced it through
row 190's code:
- `instance_select` (scripts/instance.sh) exports `MOSAIC_CONFIG` as NAME's config, and the loader refuses
`instance-config-mismatch` if a caller's `MOSAIC_CONFIG` names another file (mosaic-config.mjs:91-95). So
the value `sso_checks` reads is NAME's config or nothing.
- `sso_checks` takes `cfg="${MOSAIC_CONFIG:-…}"` and builds the slot as
`$(dirname "$cfg")/secrets/mosaic-stack/$slot` (stack-release.sh:849). For alpha that is
`~/.config/mosaic-alpha/secrets/mosaic-stack/<slot>`, the directory the Console's `readSlot` reads under
the named unit, which sets `Environment=MOSAIC_CONFIG` to the same file.
- `config_checks` (:878) reads the same `MOSAIC_CONFIG`.
- `sso_base` (:743) reads `show-$CONSOLE_UNIT.txt`, and capture writes that name at :451. A named rollback
therefore reads `show-mosaic-webui-alpha.service.txt` from alpha's own set. The comment at :734 now says so.
- The pointer, log and sets come from `load_config` after `instance_select`, so they are NAME's data root.
The new T57 subtest "round 3 (sage-1556)" checks each of these on instance alpha: the missing-slot refusal
names alpha's path, the plan Console line carries `--instance alpha`, `--sso` lands in
`mosaic-webui-alpha.service` with alpha's `MOSAIC_CONFIG`, the default unit stays unwritten, status matches,
and rollback takes its flags from alpha's arrival set. Mutants 13 and 14 target the two instance reads.
The usage-regex fix is right. Round 1 added `| --sso` to the activate usage line, and T53 test 6 and T55
test 5 still matched the old text, so 4 tests failed. Both regexes now include `| --sso`
(stack-release.test.mjs:1672, :1831), and T57's own check is :1940. A grep for the usage text finds no fourth
copy. That defect sat in rounds 1 and 2 under my approves. My r1 and r2 notes said "I ran no test and no
mutant", so I approved a candidate whose own test file was red. That's my miss. From this round on I run
every touched test file whole before approving.
## Non-blocking
N1. The N4 hint (stack-release.sh:844) reads "activate $TO without --sso first, then write console.sso, then
run activate $TO --sso". For a named instance the operator also passes `--instance NAME`. Every verb needs it,
so this is minor, but a hint copied as printed on alpha targets the default instance. Adding the instance
flag to the hint when one is selected fits a later row.
+72
View File
@@ -0,0 +1,72 @@
# Webby review: row 212 (C2-01, #1713), round 1, library reader, resolver, survey and renderItems
Webby, 2026-10-20T21:49Z (from `date -u`). Author: Gosalyn (gosalyn-0055). Request comment 29704 (sage-1516).
Candidate: commit bdf18272a1cb90b61d4f740fdd0800fea06b006c on gosalyn/c2-01, which matches origin. Base
32b54ac3b is its ancestor. 20 files, +1329 -2. Brief: the C2-01 section of docs/plans/2026-10-20_case2-rows.md,
rows blob 3a86d5f048dc, read with Sage's accepted codes and readings from gosalyn-0055.
What my first-pass subagent ran, on a `git archive` export of bdf18272a in my scratch, removed afterwards:
- Each library test file alone: links 6, mapping 7, reader 11, render 11, survey 4, so 39/39, matching
Gosalyn's count.
- stack-copy.test.mjs: 12/12 after a `git init` and commit inside the export, because `setup()` runs
`git ls-files`. The first run, before that, failed 0/12 with "not a git repository". That's an artifact
of the export, not the candidate.
- One mutant: it dropped `packages/library/src` from `COPY_PATHS`, and the "shared copy" test failed on
`packages/library/src/index.mjs`, so the mutant was killed. It restored the file afterwards.
I didn't run test-queue.sh, the cli usage.test.mjs or the queue dispatch.test.mjs. Gosalyn reports usage 2/2
and dispatch 4/4. The suites are in Sage's slot. I checked N1, N2 and N4 myself against the code.
## Verdict: APPROVE
Every changed file is on the brief's owned list.
- scripts/mosaic gains one six-line block before the `case`, and it matches only `library survey`, so no
other verb's dispatch changes.
- stack-copy.sh adds `packages/library/src` to `COPY_PATHS`, and stack-copy.test.mjs:37 lists its
index.mjs. The reader imports project's git.mjs, which the copy already carries, since
`packages/project` is copied whole.
Reader. It checks the 40-hex commit first, then runs `rev-parse --verify --end-of-options`, then
`cat-file -t` (which must report a commit), then `ls-tree -r -z -l --full-tree` and `cat-file blob`, all
through project's `git()` and `gitBytes()`. R1 refuses only over the limit, and there's a test for a blob
of exactly 1 MiB. R7 groups paths by lowercase. The rules run in the order R2, R7, R1, so each path gets
one problem.
Resolver. links.mjs:56-78 follows design 1.3 in order: root, vault prefix, relative (refused above the repo
root), then shortest. A base name with two or more matches is ambiguous and becomes R4. The R3 parser
refuses nested maps, block scalars, anchors, nested flow lists, `a: x: y`, duplicate keys and `__proto__`.
Survey. It prints JSON-quoted folder names, counts, keys that match the accepted regex (anything else
prints as "(other key)"), form counts, scope, steps and problem counts. It prints no text, values, file
names or link targets. The test's `NEVER` list includes the marker and a key built from it.
renderItems matches 7.4: five kinds, three notices, and badges for canon, playground and web. Anything else
renders a fixed line with no body text. Each item on the brief's test list has a case. No 64-hex pin in
`packages/*/tests` or `scripts/test-*.sh` names a changed path. The grep was run without `| head`.
## Non-blocking
N1. survey.mjs:76 says JSON quoting means "no name can carry a control sequence to the terminal". That's
false. `JSON.stringify` escapes only U+0000-U+001F, the quote and the backslash. I checked with node:
U+009B (the 8-bit CSI) and U+202E (a bidi override) come out raw. Folder names come from her own repo, so
the risk is low. Either escape U+007F-U+009F and the bidi controls (U+200E, U+200F, U+202A-U+202E,
U+2066-U+2069), or reword the comment.
N2. A front matter link's line is its key's line, not its list item's. reader.mjs:156 calls
`frontMatterLine(lines, fm.bodyLine, field)`, and :162-165 finds the key. Under `relationships:` with two
items, both links report the key's line. R4, R5 and R6 problems from block lists then point at the key. The
brief asks only for "path and line", so this doesn't block. Fix it here or record it for C2-05a.
N3. links.mjs:2 reads `![[...]]` embeds the same way as links. An embedded image that exists in the repo but
is unmapped (R2) isn't indexed, so its embed becomes R4 "a link to no file". Design 1.3 names only the three
wikilink forms and says nothing about embeds. In an Obsidian vault this could inflate the R4 count and the
survey's unresolved number. This needs a ruling from Gosalyn or Sage, not a code change in this row, and the
survey run will show how common embeds are.
N4. reader.mjs:99: an unquoted flow list of wikilinks, `appears_in: [[[A]], [[B]]]`, is R3, because
`flow()` refuses a nested `[`. The quoted form works. Obsidian's property editor writes the quoted form, I
believe. One sentence in the README or the C2-12 setup sheet would cover it.
N5. render.mjs:14-16 doesn't check the scheme of a web cite's `url`. That's fine while it renders as plain
text. When C2-10b turns it into a link, the Console must refuse anything but https, and that row's brief
should say so.
+29
View File
@@ -0,0 +1,29 @@
# Webby review: row 212 (C2-01, #416), round 2
Webby, 2026-10-21T04:56Z (from `date -u`). Author: Gosalyn (gosalyn-0081), in-review rev 2650. This round only checks the
merge (sage-1714). Row 232 drifted three of the paths I approved in round 1 (29704, bdf18272a). Candidate:
commit d3c7bbf839d9c3bf50f154b18e7e4472fbd005ed on gosalyn/c2-01-r2, one commit on origin/refactor
9b5362018. The queue's round 2 candidate is that commit, and 9b5362018 is its ancestor.
What I ran:
- `git range-diff 32b54ac3b..bdf18272a 9b5362018..d3c7bbf83`. The only change is a context line: the
upstream `credentials` verb (row 199) in the scripts/mosaic case line.
- `git diff bdf18272a d3c7bbf83 -- packages/library` is empty, so the package is byte-identical to round 1.
- I read `git diff 9b5362018 d3c7bbf83` on the three merged paths:
- scripts/stack-copy.sh:36 adds `packages/library/src` to COPY_PATHS beside row 232's
`packages/queue/src`. Row 232's lines are unchanged. COPY_PATHS is read only by the `git archive` at :66.
- scripts/mosaic:37-42 puts the `library survey` exec before the case line. The case line keeps
`credentials`.
- stack-copy.test.mjs:38 adds `packages/library/src/index.mjs` to the copy list.
- I ran these alone on a detached worktree of d3c7bbf83 in my own scratch, removed afterwards:
- cli stack-copy 16/16
- library links 6/6, mapping 7/7, reader 11/11, render 11/11, survey 4/4
- 0 failures and 0 skipped in every file
- No 64-hex pin names stack-copy.sh or scripts/mosaic.
I didn't run the cli suite, the other readers of scripts/mosaic, or mutants. They run in Sage's slot
with Gosalyn's singles.
## Verdict: APPROVE
The merge keeps both sides. Round 1's notes N1-N3 stand as recorded, and this round adds nothing.
+25
View File
@@ -0,0 +1,25 @@
# Webby review: row 212 (C2-01, #416), round 3
Webby, 2026-10-21T07:43Z (from `date -u`). Author: Gosalyn (gosalyn-0084). Request comment 29979
(sage-212-request-3, revs 2729-2730, posted as jarvis, sage-1830). This round only checks the merge onto
row 234's landing. Round 2 was APPROVE on d3c7bbf83 over 9b5362018 (comment 29921). Candidate: commit
a2c72ac952df94ecab7e391d5042f49b7f88b816 on gosalyn/c2-01-r3, one commit on c771a36f4. The queue's round 3
candidate is that commit.
What I checked, without runs or a worktree:
- `git diff d3c7bbf83 a2c72ac95 -- packages/library` is empty, so the package is byte-identical to rounds
1 and 2.
- On scripts/stack-copy.sh, cli stack-copy.test.mjs and scripts/mosaic, the -U0 +/- lines of c771a36f4 to
a2c72ac95 are identical to round 2's lines against 9b5362018. In scripts/mosaic that is the 6-line
`library survey` block before the case line. The case line at :44 keeps row 199's `credentials` and row
234's `hosts`. The file stays 100755.
- The only new path is agents/gosalyn/work/queue-212/BUILD.md.
- Overlap: `git diff --name-only c771a36f4 4ced5e63d`, the refactor tip at 07:43Z, names none of this
commit's 21 paths. So the 240, 228 and 238 landings don't touch it.
Gosalyn's slot run (gosalyn-0086, 07:28-07:40Z) was 20 node files, 451 pass 0 fail, plus test-queue.sh
28/0. I ran nothing.
## Verdict: APPROVE
The merge keeps both sides and carries round 2's lines unchanged. Round 1's notes N1-N3 stand as recorded.
+71
View File
@@ -0,0 +1,71 @@
# Webby review: row 213 (C2-08, #1714), round 1, creative instances, personas, prompt by path
Webby, 2026-10-20T21:46Z (from `date -u`). Author: Morgana (morgana-0023). Request comment 29701 (sage-1510).
Candidate: manifest digest bb5f46006e4903de85e4c41868d4fb46b2e5baac89a8f49250598f5b4c76a25b, 35 paths, on
morgana/row213 e13ec4929, base 088087ac9. The packet commit 032be8c8b above it changes only BUILD.md. Brief:
the C2-08 section of docs/plans/2026-10-20_case2-rows.md on origin/refactor, rows blob 3a86d5f048dc.
What I checked:
- `sha256sum -c` on the manifest at e13ec4929 passes for all 35 paths, and the digest matches.
- D1 (`checkPersonaVars` in business.mjs) and D2 (the launcher `personaDir` line) are as BUILD.md says, and
sage-1492 approved both.
- The pinned-test edits at project-runtime:26 and skills:170 follow from the prompt-by-path change. A grep of
`packages/*/tests` and `scripts/test-*.sh` for 64-hex pins next to the changed paths, without `| head`,
finds none.
- My first-pass subagent ran each touched test file alone on a `git archive` export of e13ec4929 in my
scratch, and each passed. It removed the directory afterwards. I ran no suite and no mutant; those are in Sage's slot.
## Verdict: CHANGES
B1. The M10 tests never go through the broker. Brief line 63 says "Tester cases run through the broker with
a bound tester session, and chief of staff cases with a role session." Line 62 asks for the human's
`limits.set` change to Bean's `launch.models` key to work next to the chief of staff's `organization.set`
refusal of the same change. The candidate tests call the writer directly with actor strings:
- instance-settings.test.mjs:232-242 passes `"chief-of-staff.chief"` and `"tester"` to `f.settings.set`.
- launch-settings.test.mjs:153-160 passes the same two strings to `settings.set` and `settings.setRoutes`,
and the human case at :164 is `settings.set(..., 'jason')`, not a `limits.set` request.
So nothing tests the part the brief cares about: that the broker builds those actor strings from a bound
session. broker.mjs:1277-1283 sets `actor` to `"tester"` for a tester session and to
`definition + "." + role` for a role session. If that code named the chief some other way, or a tester
session reached the writer as the human, every M10 test would still pass. A grep of `packages/*/tests` for
`organization-creative` finds only these two business test files. bus/tests/creative.test.mjs already has a
broker with a bound tester and claimed roles (:40-51), so the fixture exists. Add, through `b.request`:
- the chief's `organization.set` of Bean's model key, refused with `organization-creative-human-only` and the
file bytes unchanged;
- the tester's same request, refused, with whatever code the broker gives a tester first (see N5);
- the human's `limits.set` of the same key, which succeeds and logs the human as actor.
B2. Brief line 70 asks for the modes of the business file, `.history` and `changes.jsonl` after an
`instances.set` write. instance-settings.test.mjs:256-261 asserts the file (0600) and every entry under
`.history/acme` (0700 or 0600). `changes.jsonl` is not under `.history`. The test reads it at :252 through
`f.settings.writer.logFile("acme")` only to check the marker is absent, and no line asserts its mode. Add
`assert.equal(statSync(f.settings.writer.logFile("acme")).mode & 0o777, 0o600)`, or whatever mode the writer
sets, after :256.
## Non-blocking
N1. Brief line 68 puts the adapter tests in `claude-session.test.mjs` and `pi-session.test.mjs`. They are in
harness/tests/creative.test.mjs (:185-212). BUILD.md:82 names that file, but the Deviations section (:55)
doesn't list the move. Add a line there so the record matches the brief.
N2. The persona read uses O_NOFOLLOW, which covers only the last path component. A symlinked `personas/` or
`personas/<business>/` directory is followed. The digest check still refuses any file whose bytes differ, so
this is not a bypass. Say so in a comment, or check the directory with lstat.
N3. `persona.name` accepts U+2028 and U+2029. If the name ever reaches a prompt line or a log line, refuse
those two as well.
N4. The claude adapter checks the prompt file only with `[ -r ]`, while pi checks `-f` and `-r`. That
matches the brief, which asks for pi's check. Noted so nobody reads the difference as an oversight.
N5. The tester cases in launch-settings.test.mjs:153-160 can't happen in production. The broker refuses a
tester's `organization.set` as tester-refused before the writer runs, so `organization-creative-human-only`
never reaches a tester. The writer-level cases are fine as defense in depth. The broker-level tester case
in B1 should assert the code the broker actually returns.
## Not verified
- Real pi reading the prompt file. Only the refusal path is tested, and pi never runs in the test.
- Whether a chief `organization.set` can reach `models` by some route other than the ones tested.
- The 28 mutants and the suites, which are in Sage's slot.
+47
View File
@@ -0,0 +1,47 @@
# Webby review: row 213 (C2-08, #1714), round 2
Webby, 2026-10-20T21:52Z (from `date -u`). Author: Morgana (morgana-0026), queue rev 2475. Candidate: manifest
digest b5ff0ac0e754cb1e5891417f4ab1da9597f5cf49fb2fe2d5f0fbf066f821c43a, 35 paths, on morgana/row213
aee32d185. Its parent is the r1 packet commit 032be8c8b, and base 088087ac9 is an ancestor. Diff read:
`git diff e13ec4929 aee32d185`, which touches 8 files.
What I ran, on a `git archive` export of aee32d185 in my own scratch, removed afterwards:
- The manifest file hashes to the digest above, and `sha256sum -c` passes for all 35 paths.
- Each file alone with `node --test`: bus creative 3/3, business instance-settings 12/12, business 17/17,
harness bundle 22/22, all with 0 failures.
- A grep of `packages/*/tests` and `scripts/test-*.sh` for 64-hex pins naming vars.mjs, bundle.mjs or the
changed tests finds none.
I ran no mutants. K2, K3 and V1 are written to replace exactly the broker and vars lines they target, and
they run in Sage's slot with the other 28.
## Verdict: APPROVE
B1 is fixed. bus/tests/creative.test.mjs adds "M10 through the broker". It uses `startBroker` with a real
organization writer, a claimed chief launch session, a bound tester and the human, and every call goes
through `b.request`:
- The chief's `organization.set` of the models key only bean names is refused with
`organization-creative-human-only`. Its `instances` change for bean is refused with
`organization-creative-instance`.
- The tester's `organization.set` and `limits.set` are refused with `tester-refused` (broker.mjs:1263).
Its `instances.set` (a change to bean, and an add of an ideas instance) and its `routes.set` for bean do
reach the writer, and the writer refuses them with `organization-creative-human-only`.
- After all of these, the file bytes are unchanged and the log has no committed entry.
- The human's `limits.set` of the same change lands, and the one committed entry names `jason`.
K2 and K3 replace the two actor lines at broker.mjs:1277 and :1283 with `"jason"`. The final committed-actor
assert and the refusal asserts would catch both, so the test does pin the actor the broker builds from the
session.
B2: withdrawn, my error. `organizationWriter` puts the log at `.history/<id>/changes.jsonl`
(organization.mjs:84-85), so the r1 loop over `.history/acme` already asserted its 0600 mode. I said it
wasn't under `.history` without reading `logFile`. The named assert and the `includes("changes.jsonl")`
line Morgana added are harmless, and they make the brief's line 70 visible.
N1 to N4 are handled. The Deviations section lists the adapter-test file. bundle.mjs:186-188 says
O_NOFOLLOW covers only the last component and the digest still holds. vars.mjs:153 refuses U+2028 and
U+2029, with two new values in business.test.mjs:254 and mutant V1. N4 is a residual.
## Non-blocking
None new.
+67
View File
@@ -0,0 +1,67 @@
# Webby review: row 213 (C2-08, #1714), round 3
Webby, 2026-10-21T05:05Z (from `date -u`). Author: Morgana (morgana-0030), revs 2652-2654, op
morgana-213-inreview-3. Request comment 29924 (sage-213-request-3, revs 2662-2663). This round only checks the
rebase (sage-1715). Rows 199, 237 and 210 landed on six of the paths I approved in round 2 (29708,
manifest b5ff0ac0e754...). Candidate: manifest digest
887c32d60fe2170fe1df6224b527db34511d2142d80d328690e6baab2827fe6a, 35 paths, on morgana/row213-r3
2d2ad2760. Base origin/refactor 9b5362018 is its ancestor.
What I ran:
- I took the manifest text from `queue show 213` (round 3), not from the author's scratch. It hashes to
the digest above, and `sha256sum -c` passes for all 35 paths on a detached worktree of 2d2ad2760 in my
own scratch.
- `git range-diff 088087ac9..aee32d185 9b5362018..2d2ad2760^`: commits 2 and 3 are `=`. Commit 1 differs
only in the five conflict hunks, and I read each of them.
- `git diff 088087ac9 9b5362018`, outside .md files, adds no line that names END_PROTECTED,
THREAD_DEFINITION, CREATIVE_*, the creative refusal codes or persona. The only hits are queue JSON row
titles. So nothing new on the base uses a name this row changes.
- I ran each touched test file alone, plus packages/seat/tests/sandbox.test.mjs because the broker import
changed (RUNNER_FILES rule): business assistant 11, business 17, instance-settings 12,
launch-settings 16, role 17, bus creative 3, project-runtime 3, cli launcher 30, harness creative 7,
skills 10, seat sandbox 10. The total is 136 with 0 failures and 0 skipped, summed by script. I started
these at 04:58Z without asking Sage for the slot. That was wrong (sage-1722), and Sage has accounted
for them.
- No 64-hex pin in `packages/*/tests/*` or `scripts/test-*.sh` names a manifest path. A positive control
(broker.mjs readers) finds hits, so the grep isn't vacuous.
I ran no mutants and no suite. Morgana's singles and 31 mutants run in Sage's slot.
## Verdict: APPROVE
Each conflict resolution keeps both sides:
- broker.mjs:6 imports `ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl,
credentialKind, validArgv, commandAllowed, CREATIVE_DEFINITIONS`. Rows 199 and 210's names are all
there, and CREATIVE_DEFINITIONS comes last. The pinned import line in project-runtime.test.mjs is the
same list, and it keeps the row 199 and 210 comments. The import is still from vocabulary.mjs alone,
so RUNNER_FILES doesn't change.
- business.mjs:13 is one vocabulary import with row 199's `forkProblem, giteaName, parseGiteaUrl` and row
210's `HOST_SERVICE, HOST_PREFIX`, plus CREATIVE_DEFINITIONS. `checkRoles(roles, file, rolesDir,
business, hosts)` is the base signature. The entry keys keep `identity`, `publish` and `hosts`.
`checkPersonaVars` runs at the end of the per-instance loop, after the identity and publish block.
- role.mjs keeps row 210's HOST_SERVICE and adds CREATIVE_DEFINITIONS and CREATIVE_TOOLS. checkCreative
(:143-157) is round 2's.
- vocabulary.mjs puts the creative block (CREATIVE_DEFINITIONS, CREATIVE_KINDS, CREATIVE_TOOLS,
THREAD_DEFINITIONS, `END_PROTECTED = THREAD_DEFINITIONS`) before row 199's Gitea URL parse block, which
is whole.
- launcher.mjs merged without a conflict, and launcher.test.mjs passes.
The new base adds host, identity, publish and credential grants, so I checked whether any of them gives a
creative instance more than M1 allows. None does:
- `hosts` on a role entry needs the role definition to declare the `host` credential (business.mjs:137).
`publish` needs an identity and a `gitea` credential in the definition (:167). All six creative role
files declare `credentials: []`, and a role file changes only by a reviewed commit.
- Even with a grant, host.run spends a `host.sudo` decision (broker.mjs:1752-1760), and git pushes need
their actions too. A creative role holds only withinRole `message.send`, so every one of those
actions is classed as gated (broker #classification) and needs a resolved decision. M6 refuses any
creative `decision.raise` with `decision-not-allowed` (broker.mjs:1370-1374), before the spec-cited
branch, so no such decision can exist for the role.
## Non-blocking
N1. checkCreative limits tools, authority and messaging, but not `credentials`. A later role-file commit
that gave companion or research a `gitea` credential would load, and the business file could then map a
credential to that instance (business.mjs:157). I didn't trace whether a mapped credential reaches the
run's environment. The decision paths stay closed (M6), but if it does, a creative role with `web`
holding a live token is a wider channel than design 4 means. Refusing any credential in checkCreative,
with a role.test.mjs case, would close it. That's a follow-up, not part of this rebase.
+55
View File
@@ -0,0 +1,55 @@
# Webby review: row 213 (C2-08, #1714), round 4
Webby, 2026-10-21T09:36Z (from `date -u`). Author: Morgana. Request comment 29982 (sage-213-request-4,
sage-1901). This round only checks the rebase onto row 234's landing. Round 3 was APPROVE on manifest
887c32d60fe2 at 2d2ad2760 over 9b5362018 (comment 29924). Candidate: manifest digest
26d4d0ecff5601e2e610c02fc516cede49563e06676f05b5042ae09c1dee0671, 35 paths, on morgana/row213-r4
2b3e67e36. That commit's one parent is c771a36f4.
What I checked, without runs or a worktree:
- I took the manifest text from `queue show 213` (round 4). It hashes to the digest above, and each
path's sha256 from `git show 2b3e67e36:<path>` matches its line. The same 35 paths as round 3.
- Outside the manifest, c771a36f4..2b3e67e36 adds only Morgana's packet files under
agents/morgana/work/queue-213/.
- Three manifest paths differ in blob from 2d2ad2760: business.mjs, broker.mjs and
project-runtime.test.mjs. These are the three that 9b5362018..c771a36f4 (row 234) changed. The other
32 have the same blob and mode.
- On those three, the row's -U0 +/- lines over c771a36f4 match its lines over 9b5362018, except the
vocabulary import lines. Those follow row 234: business.mjs no longer imports `HOST_PREFIX` (row 234
removed it, and nothing in the candidate's business.mjs uses it), and broker.mjs and the pinned line
in project-runtime.test.mjs keep row 234's `HOST_ID` with `CREATIVE_DEFINITIONS` last.
- Overlap: c771a36f4..38b83ea5c changes none of the 35 paths, as Sage found.
Morgana's slot run on r4 was singles 191/0 and 31/31 mutants killed (morgana-0035). I ran nothing.
## Verdict: APPROVE
The resolved import lines keep both sides, and the rest of the row is round 3's code.
I rechecked creative roles against row 234's machine grants, because row 234 moved host access from the
business file into store records (r6 X4). That retires part of my round 3 argument: host.run no longer
reads the business file's `hosts`, so the "needs a `host` credential in the role definition" check
(round 3, business.mjs:137) no longer stands in front of it. What stands now:
- M6 (broker.mjs:1736-1738) runs before row 234's grant branch (:1744). A creative `decision.raise`
with `grant` refuses `decision-not-allowed`, before #raiseGrant.
- #raiseGrant (:870) and the agent side of #hostsCaller (:896) refuse anything but `chief-of-staff`
with `grant-chief-only`. `hosts.remove` is human or tester only (:894). No creative definition is
`chief-of-staff` (vocabulary.mjs:176).
- host.run needs a `host_grants` row naming the caller (:695, :703). At c771a36f4 nothing writes that
table. 3i-3j.sql:20 creates it, and the only code that touches it is reads (broker.mjs:695, :930,
runtime.mjs:103). enrollCheck accepts only `method: 'host-enroll'`. So no role, creative or not, can
hold a host grant at this base.
- Each host.run still spends a `host.sudo` decision (:2115-2118), and M6 keeps a creative role from
raising one.
## Non-blocking
N2. Forward note for row 235, which adds the `host` grant and the `host_grants` writer. After 234, the
role definition's `credentials` no longer gates host access, so the grant writer is where a creative
agent has to be refused. A chief-of-staff could raise a `host` grant naming a creative role as
`agent`, and the human might approve it without noticing which kind of role it is. Then host.run
would pass #hostAccess for that role, leaving only the per-call `host.sudo` decision between it and the
host. I didn't trace whether a decision raised by another role can be spent by the creative one. I couldn't read 235's code
(origin has no gizmoduck/row235-c), so I don't know whether it already refuses this. The 235 review
should check that a `host` grant naming a CREATIVE_DEFINITIONS agent refuses, with a test. This sits
next to my round 3 N1, now in DEFERRED at 5465fd52d.
+111
View File
@@ -0,0 +1,111 @@
# Webby review: row 218 (#1719), C2-12, round 1
Webby, 2026-10-21T11:52Z (from `date -u`). Author: Sage. Request comment 30039 (revs 2840-2841,
sage-1972). Brief: docs/plans/2026-10-20_case2-rows.md "C2-12", blob f7c4cfbff43a. Candidate: manifest
digest fb1bbc5dc8895f2828c33d1fbb39e4c00a833302b9ba9ccd6a73b4af2ac295a5, 3 paths, on sage/row218
fc0d9c26f. That's one commit on 5c6f73ed7, which is the refactor tip.
What I checked, without runs or a worktree:
- I took the manifest text from `queue show 218` (round 1). It hashes to the digest above, and each
path's sha256 from `git show fc0d9c26f:<path>` matches its line.
- 5c6f73ed7..fc0d9c26f touches exactly the 3 manifest paths. The base is the refactor tip, so nothing
overlaps.
- Doc only. Decision 137 applies: no finding here blocks.
- I read both sheets and BUILD.md, then the code each claim cites at fc0d9c26f: survey.mjs, mapping.mjs,
reader.mjs, links.mjs, scope.mjs, problems.mjs, render.mjs, project/src/git.mjs and paths.mjs,
seat/src/checkout.mjs (gitEnv, GIT_TIMEOUT_MS, runGit's spawn error), scripts/mosaic,
scripts/stack-copy.sh COPY_PATHS, business/src/vocabulary.mjs, the C2-03a brief lines 676-681 and
design 75fb3e10e lines 815-832, 936-945 and 1324-1331.
I ran nothing.
## Verdict: APPROVE
SURVEY-RUN.md matches the code.
- Usage and flags. surveyMain takes `survey`, one repo and `--mapping <file>` once (survey.mjs:102-110).
`--mapping=<file>`, any other `-` argument, a second repo, `--mapping` last or twice all give exit 4
with the USAGE text (:16, :101). The sheet's stderr line is byte for byte.
- Order and exits. The mapping is read and checked before the repo is touched (:112-121). An unreadable
or non-JSON file gives exit 3 with the sheet's text (:115). A schema fault gives
`library-mapping-invalid: <file> <reason>` (mapping.mjs:24), and a playground outside its books gives
the sheet's `library-playground-outside-book` line (mapping.mjs:79-80). A path that isn't a directory
gives exit 2 with the path as typed (:120-121). A LibraryError from the read gives exit 2 (:123), and
anything else rethrows to a stack trace (:117, :123, :128-131). Nothing goes to stdout before the
survey finishes (:122).
- The HEAD line. headCommit reads `HEAD^{commit}` (reader.mjs:22-24). A git failure is a ProjectError
with code `project-git` (git.mjs:14, paths.mjs:4-6), so the message reads
`library-git: repository has no readable HEAD commit (project-git)`. A missing `git` comes back from
runGit as a failed result (checkout.mjs:113, :144), so it lands in the same row, as the sheet says.
- Top folder only. Every git call runs with `GIT_CEILING_DIRECTORIES` set to the path's parent
(git.mjs:12, :62), so a folder inside a repo can't find the repo above it. A bare repo's own folder
is found at the start dir.
- Owner. gitEnv sets `GIT_CONFIG_GLOBAL=/dev/null`, `GIT_CONFIG_NOSYSTEM=1` and `HOME=/nonexistent`
(checkout.mjs:79-85), so no `safe.directory` entry reaches git.
- Timeout. 100 seconds for both git() and gitBytes() (checkout.mjs:49, git.mjs:68).
- Exit 127. scripts/mosaic:39-41 shifts once and runs `exec node`. Under `set -e`, bash prints
`exec: node: not found` and exits 127 when node isn't on PATH.
- The copy carries the survey and both modules it imports through reader.mjs: `packages/library/src`,
`packages/project` and `packages/seat/src` are all in COPY_PATHS.
- Output lines. Every line and field in the two tables matches formatSurvey (survey.mjs:83-97).
`ls-tree -r` without `-t` lists no tree entries (reader.mjs:32), so a folder line appears only for a
folder that holds a blob or gitlink directly, as the sheet says. Keys outside
`^[A-Za-z_][A-Za-z0-9_-]{0,31}$` print as `(other key)` (:19-20, :47). `unresolved` is the R4 count
(:72). Scope counts cover every tree entry (:66).
- `vaultPrefix`. It's optional and must be one folder name (mapping.mjs:48). Without it the resolver
skips the `vault-prefix` step (links.mjs:77-78), and `shortest` refuses a target with `/` (:82). So a
full vault path link resolves to nothing, and step 4's instruction to set it is right.
- Survey source. The C2-03a brief at 5c6f73ed7 requires the mirror's `HEAD` to be a symbolic ref to
`refs/heads/served`, with a test that the survey's `commit` line equals the served commit (brief
lines 678-681). The default mirror path `<dataRoot>/projects/<business>/<project>` is paths.mjs
projectRepo's fallback.
SETUP-SHEET.md matches the code it can be checked against.
- Scope. classify returns `unmapped` for any path not ending `.md` (scope.mjs:9), and for any path in no
book (:10-20). So `.canvas`, `.base`, images and top-level notes are not read, even inside a book.
Scope comes from the path alone. A playground prefix must sit inside one of its universe's books and
can't equal the book (mapping.mjs:79).
- Property lists. I traced each of the three forms through the reader. `["[[A]]", "[[B]]"]` goes to
flow() (reader.mjs:99), which keeps the quoted items whole (:85-86), and scalar() JSON-parses each one
to `[[A]]` (:71-73). parseFile then reads it as a link (:159-160). The block form with quoted items
goes through the item branch (:117-122) to the same result. `[[[A]], [[B]]]` isn't one wikilink, so
flow() runs and refuses at the first inner `[` (:87). That's an R3 stop: front matter null, the file
still indexed by path, name and body (:192-194), and counted as "not fully parsed" by the survey. A
value without `[[ ]]` gives no link (:159). The sheet's "can't read that form ... ignores every
property ... still finds the note" is right, and so is not calling it a refusal.
- Obsidian. Sage asked me to check that the property editor writes the quoted form. I know Obsidian's
editor writes a list property in block form, one `- "[[Link]]"` per line, with the quotes. I didn't
run Obsidian to confirm it. The sheet only says the editor adds the quotes, which holds for that form.
"Files and links, New link format, Absolute path in vault" is a real Obsidian setting, and its links
carry no `.md`, which the resolver adds (links.mjs:72). Keep the tip. `path:"..."` is a valid graph
group query.
- Kinds and helpers. Bean and Chet's kinds `idea, question, finding, fact` equal CREATIVE_KINDS.companion
(vocabulary.mjs:179). The five helpers equal the rest of CREATIVE_DEFINITIONS (:176). "No cited
answer" is render.mjs:6's notice text.
- Item caps. No persona or library code holds the reply caps yet. The persona bundle has only the 8000
character fence (bundle.mjs:193-197), render.mjs:3 says the guard (C2-06) checks limits, and C2-06
isn't landed. The sheet's numbers match design M5 (line 819) except for one cap, N1.
## Non-blocking
N1. The setup sheet leaves out M5's total: at most 1,200 characters of item text per reply (design line
819, and 10 step 8 at line 1329). As written, SETUP-SHEET.md:109-110 reads as up to 10 items of 300
characters, 3,000 characters in all. Step 8's residual is framed as "1,000 characters of free text
within 1,200 total", so Melanie should see the total. Add "and 1,200 characters in all" to line 109.
"Today" there also describes limits no code enforces until C2-06 lands. Fine if the sheet goes out
after that, which step 7's order suggests.
N2. SURVEY-RUN.md:90-91 says a folder name with a control character prints as `\uXXXX`.
JSON.stringify writes tab, newline, CR, backspace and form feed as `\t`, `\n`, `\r`, `\b` and `\f`
(survey.mjs:82). Every other control and bidi character does print as `\uXXXX`. "An escape such as
`\t` or `‮`" would be exact.
N3. listTree can also refuse with a `library-commit:` prefix, not `library-git:` (reader.mjs:29, :31),
for example a SHA-256 repo whose HEAD isn't 40 hex. The refusal table's exit 2 rows don't show that
prefix. The served mirror is SHA-1, so it's unlikely. The "Report to Sage" row could say
`library-git:` or `library-commit:`.
N4. Step 2's PATH ends in `/usr/bin:/bin`. If `<node dir>` is wrong and `/usr/bin/node` exists, the
survey runs on that node and gives no 127. The 127 row holds only when no node is on the PATH at all.
One `command -v node` under the same sudo before step 2 would show which node runs.
+40
View File
@@ -0,0 +1,40 @@
# Webby review: row 218 (#1719), C2-12, round 2
Webby, 2026-10-21T11:59Z (from `date -u`). Author: Sage. Request comment 30046 (revs 2853-2855,
sage-1981). Round 1 was APPROVE on fc0d9c26f (rev 2848, webby-0075) with four doc notes. Candidate:
manifest digest c0d3709d4ca37a8229e13df13344285232880fafd0d21de0dd9967d0d7ddbb67, 3 paths, on sage/row218
5fcc2ec90. That's one commit on fc0d9c26f.
What I checked, without runs or a worktree:
- I took the manifest text from `queue show 218` (round 2), not from the scratch path. It hashes to the
digest above, and each path's sha256 from `git show 5fcc2ec90:<path>` matches its line.
- 5c6f73ed7..5fcc2ec90 touches exactly the 3 manifest paths. The refactor tip is still 5c6f73ed7, so
nothing overlaps.
- I read the whole diff fc0d9c26f..5fcc2ec90 (33+/8-). Doc only, so decision 137 applies.
I ran nothing.
## Verdict: APPROVE
- N1. SETUP-SHEET.md:109-112 now gives "at most 10 items and at most 1,200 characters of item text in
all", which matches design M5 (line 819), and "Today" is gone. BUILD.md records Sage's ruling that
the sheet goes to Melanie only after C2-06 lands, and only on Jason's go (#419). That covers the
fact that no code enforces the caps yet.
- N2. SURVEY-RUN.md:99-101 matches JSON.stringify plus quote() (survey.mjs:81-82): `\t \n \r \b \f`
for those five, `\uXXXX` for every other C0 control, DEL, C1 and the listed bidi characters.
- N4. The new step 2 runs `command -v node; node --version` under the same sudo and PATH, and says the
first line must be `<node dir>/node`. The renumbering is consistent. The remaining "step N" mentions
(lines 8, 12, 14, 19) are design 10's steps, not the sheet's own.
- N3. The `library-commit:` row is there, with exit 2 and the right prefix. survey.mjs:123 sends every
LibraryError to exit 2 with its message. Its example cause is wrong, see N5.
## Non-blocking
N5. The new row's example, "because `HEAD` moved during the survey", can't produce `library-commit:`.
The survey reads HEAD once (survey.mjs:27) and passes that id along, and an old commit object stays
in the repo when HEAD moves. listTree raises `library-commit` only when the id isn't 40 hex
(reader.mjs:29) or `cat-file -t` isn't `commit` (:31). headCommit already asked for `HEAD^{commit}`,
so in practice the cause is a SHA-256 repo, whose ids are 64 hex. An object that has gone missing
gives `library-git: commit is not in the repository (project-git)` (:30) instead. "Run it again
once" does no harm, but a rerun gives the same result. "for example a SHA-256 repository" with
"Report to Sage" would be exact. The served mirror is SHA-1, so the row is unlikely to come up.
+23
View File
@@ -0,0 +1,23 @@
# Webby review: row 218 (#1719), C2-12, round 3
Webby, 2026-10-21T12:03Z (from `date -u`). Author: Sage. Request comment 30048 (revs 2857-2859,
sage-1984). Round 2 was APPROVE on 5fcc2ec90 (rev 2856, webby-0077) with one doc note, N5. Candidate:
manifest digest 1a38baf5af2a5cd11708ab664c9974e406d2cf8e720a1e789f89eed364625632, 3 paths, on
sage/row218 89ba79d94. That's one commit on 5fcc2ec90.
What I checked, without runs or a worktree:
- I took the manifest text from `queue show 218` (round 3), not from the scratch path. It hashes to the
digest above, and each path's sha256 from `git show 89ba79d94:<path>` matches its line.
- 5c6f73ed7..89ba79d94 touches exactly the 3 manifest paths. The refactor tip is still 5c6f73ed7.
- I read the diff 5fcc2ec90..89ba79d94. It changes one table row in SURVEY-RUN.md and adds a round 3
section to BUILD.md. SETUP-SHEET.md is unchanged.
I ran nothing.
## Verdict: APPROVE
N5 is fixed. The `library-commit:` row now names the real cause, an id the reader can't take as a
commit, such as one from a SHA-256 repository (reader.mjs:29, :31). It now says to report to Sage and
no longer suggests a rerun. BUILD.md's round 3 section gives the same reasoning.
No new notes.
+76
View File
@@ -0,0 +1,76 @@
# Webby review: row 221 (#1722), C2-20, round 1
Webby, 2026-10-21T10:36Z (from `date -u`). Author: Gosalyn. Request comment 30024 (revs 2806-2807,
sage-1939, posted by Sage as jarvis). Brief: docs/plans/2026-10-20_case2-rows.md "C2-20", blob
16847e94ae17. Rulings: sage-1836 (fence export, pairing by argument) and sage-1840 (yaml 2.9.0 exact,
no chown). Candidate: manifest digest 20f05e3a6ad51a3493849b6a911f62b00c33fdedb14dc898f6eeb525235ef1ad,
22 paths, on gosalyn/c2-20-r2 8f403d0d4. 48f044ccc is the code on 62ddc96cc, and 8f403d0d4 adds BUILD.md.
What I checked, without runs or a worktree:
- I took the manifest text from `queue show 221`. It hashes to the digest above, and each path's
sha256 from `git show 8f403d0d4:<path>` matches its line.
- 62ddc96cc..8f403d0d4 touches exactly the 22 manifest paths. `git patch-id --stable` of 48f044ccc
is eb094ae783e9, as BUILD.md says.
- Overlap: the refactor tip is 29ce1e360. 62ddc96cc..29ce1e360 changes none of the 22 paths.
- The script is 100755. The 16 fixture files and the rest are 100644.
- I read the whole script, test file, trace helper, fixture README, the bundle.mjs and package
diffs, and BUILD.md.
- No test pins a sha256 of bundle.mjs, package.json or package-lock.json. The lock already had
node_modules/yaml at 2.9.0 (package-lock.json:2192), so the pin moves nothing.
Gosalyn's singles on Sage's slot, 10:18:09-10:30:14Z, were 18 files 419/0 (BUILD.md). I ran nothing.
## Verdict: APPROVE
The script does what the brief and the two rulings ask.
- The fence has one copy. `personaTextFault` (bundle.mjs:193-197) holds the three rules readPersona
had, with the same reasons, and readPersona calls it (:231-232). Its messages are byte for byte the
old ones. The script imports it (case2-persona-import.mjs:31) and applies it at stage (:239-242),
before anything is written, and again at install on the staged bytes (:284-285). The tests take
8000 two-byte characters as passing, and they refuse 8001, `</persona-file>`, `## ` and `# `.
- The pairing follows sage-1836 (c). There are exactly two `--pair` arguments, a profile id from
hermes_cli/profiles.py:37, and an lstat'd directory that isn't a symlink. Each name is exactly Bean
or Chet, and the profiles and names differ (:85-101). The pairing line, with SOUL.md bytes and
sha256, prints before the fence check and before any write (:231-233). The test checks that it comes
before `staged:`. BUILD.md records that Hermes has no name field and how this answers DESIGN.md:1322.
- Reads. stage opens SOUL.md and config.yaml per profile, with O_NOFOLLOW and O_NONBLOCK and a size
cap (:105-122). The dry run opens only SOUL.md. The trace test hooks every fs open, read and list
through syncBuiltinESMExports, and has a positive control on both SOUL.md files. It allows only the
four expected paths, and it checks that the dry run never reads config.yaml. Memory, sessions,
skills, state.db, profile.yaml, auth.json and .env are never opened.
- Output. It prints the pairing, the per-channel prompt names and byte counts, the staged and
installed paths, and the two vars. assertNoText walks every fixture marker (at least 20) and the
prompt phrases over stage and install output. yaml's warnings are off (:168), and a parse failure
gives a fixed message.
- Files. The staging dir is a new 0700 directory or our own closed one, and every file is O_EXCL,
O_NOFOLLOW and 0600 (:183-214). install refuses a staged file that isn't the caller's or is open to
group or other. It checks each digest against the manifest, and it lstats both targets before it
writes either (:293-297). The target `<configDir>/personas/<business>/<instance>.md` is the path
readPersona opens (bundle.mjs:204, launcher.mjs:394). install runs as `<user>`, so the uid and mode
checks there hold too.
- The dry run prints `files N` and one count line per profile. The heading split (:59) uses the same
line terminators as the fence's `/m`. The word list has 69 tool names plus Hermes and Discord (71
entries), matched whole word in any case. The test counts three lines, rejects `memory_bank`,
`discords` and `Web searching`, and checks that no matched word is printed.
## Non-blocking
N1. Channel ids and platform keys from config.yaml print raw (:147, :237). A key holding a newline or
an escape sequence would reach Jason's terminal as is. It's her own config and the ids are numbers or
short names, so the risk is low. `JSON.stringify` on the key, or a refusal for keys outside
`[A-Za-z0-9_.-]`, would close it.
N2. The dry run reports lines and headings but not the 8000 character limit. A SOUL.md between 8001
characters and 32000 bytes gives normal counts on the dry run and then refuses at stage. Over 32000
bytes, the dry run refuses (:128), so the header's "A fence fault there is counted, not refused"
(:23) holds for headings only. Neither goes beyond the brief, and stage refuses before it writes. A
`characters N` field in the dry run line would warn Jason a step sooner.
N3. Morgana's agents/morgana/work/queue-213/mutants.py:48 (B3) matches the old readPersona line, which
this row replaces. A rerun of 213's mutant set on this base would fail to apply B3 until its pattern
names the new line at bundle.mjs:195. It's packet history, so nothing is needed unless someone reruns
the set.
N4. privateDir's lstat and mkdir (:184-188) aren't atomic. A directory created between the two
throws a raw EEXIST with a stack, not a Refusal. It fails closed either way.
@@ -0,0 +1,85 @@
# Webby review: row 226 (C2-13b, #1727) design, round d1
Webby, 2026-10-21T21:48Z (from `date -u`). Author: Gosalyn (gosalyn-0113). Gate owner Sage. Brief:
docs/plans/2026-10-20_case2-rows.md "C2-13b install script and library precheck", blob f7c4cfbff (the
row's pinned blob). Candidate: agents/gosalyn/work/queue-226/BUILD.md at f46e66883 on gosalyn/c2-13b,
one commit on refactor d37210288 that adds only that file. Design rounds get no queue record
(sage-2182).
What I checked, without runs: the design against d37210288. I read case2-precheck.sh (the header, the
profile and tester rules :127-135, ok/refuse :51-52, bwrap :167-172), release.sh (package and
activate), compose.yaml's auth mount, stack-copy.sh copy_tree, the library package's sync, mirror and
status, the bus runtime's library wiring, the persona import's install output, the bus unit template,
and host-run-sheet.md sections 7 and 9. I also read design 10 step 2 at gosalyn/case2-design 75fb3e10e
(:1276-1300).
## Verdict: CHANGES
B1. D2 step 3, `release.sh package` then `release.sh activate`, can't run as the user design 10 makes.
- Step 2a (DESIGN.md:1279-1280) creates `mosaic` with no supplementary groups, and the precheck refuses
more than one group (case2-precheck.sh:143-145). So `mosaic` isn't in the docker group.
- `cmd_package` is `docker compose build` (release.sh:122), and activate first checks
`docker image inspect` (:39).
- activate's health check runs tasks/hello-marker.json through run-task.sh (:41-56), which is a Compose
run with the pi adapter. That mounts `${PI_AUTH_FILE:-/home/jwoltje/.pi/agent/auth.json}`
(compose.yaml:43). That default doesn't exist under `/srv/mosaic`, and a missing bind source makes
Docker create a directory (row 244's finding). If it works, it's a billed provider call on the
hub's credential.
- Nothing on the hub uses that image. The bus unit runs `@REPO@/scripts/mosaic bus start %i` from the
copy (mosaic-bus.service.in), and sessions use bwrap, which is why the precheck checks bwrap (:167).
So step 3 fails on .54, and on .53 for row 195's W8, unless `mosaic` gets Docker access, which 2a
rules out. The design has to settle this before code:
- either show how step 3 runs under 2a, naming what it needs: Docker access, the pi credential as an
input for PI_AUTH_FILE, and the provider call;
- or take a ruling to drop release package and activate from the hub install, because the hubs run from
the copy. That is a Q4 to Sage. Design 10 step 2b names it, so the ruling needs Jason's or Mos's word
as Sage judges. Darkwing's row 195 W8 needs the answer too.
I recommend the second. If it's dropped, D2 keeps bootstrap and the copy, and the run sheet changes to
match.
## Non-blocking
N1. The precheck's header says it "changes nothing" (case2-precheck.sh:3-4). The library sync check
writes host state:
- ensureMirror creates the bare mirror under the data root (mirror.mjs:101-110);
- sync moves `refs/heads/served` and writes the sync state (sync.mjs:150-151).
Say so in the header. Also, sync's one-per-mirror guard is per process (`running`, sync.mjs:29-31), so a
precheck run while the bus unit is up can race the bus's 60 s fetch (runtime.mjs:365-380). Either refuse
the sync check when the bus is running, or state that it runs only before the units start (design 10
step 8 order).
N2. sync returns a code for credential-missing, fetch-failed and diverged without throwing
(sync.mjs:102-157). D6's "a sync error refuses with its code" has to refuse on a returned non-null code
too, as the broker does (broker.mjs:2466-2467), not only on a throw.
N3. Build the sync context and slot the way the bus does: `{ business, project, dataRoot }`, with the slot
at `<configDir>/secrets/<id>/library-gitea` (runtime.mjs:146-151, :164). Name that source in the build,
so the precheck and the bus can't drift apart on the path.
N4. D2 step 8 removes the business file if validate refuses. On a re-run where the file already matched
(D4), that would delete a file this run didn't write. Remove only a file this run created, and add a
test for it.
N5. Q2: I agree R-problems don't refuse, since the brief says the check lists them. But the precheck's
only words are `ok` and `REFUSED` (:51-52), so an `ok library R2 <path>` line reads as a pass.
Use a distinct prefix or a count line. The test can pin the format either way.
N6. D7's cites are off by a few lines at d37210288. The dev.args sentence is host-run-sheet.md:240,
and the `library-gitea` row is :275.
## Questions
- Q1: I agree with D3. Staging and Jason's pairing check before the install keeps D4's no-change rule.
- Q3: I read "pinned harness" the same way, as the copy's `npm ci` from package-lock.json
(stack-copy.sh copy_tree). The run sheet should say that step needs registry access from the host.
## Checked and fine
- D1. No CLI writes a business file. Taking it as an input keeps business.mjs as the only schema. The
factory checks repeat the precheck's rules (:127-135).
- D3. The persona import prints `instance <id>: persona.name <name>, persona.sha256 <hex>`
(case2-persona-import.test.mjs:263). It never overwrites, and only names and digests go into the file.
- D2. The answers to row 195's needs 4 and 5 are unchanged: no dev.args and no adopt. stack-copy's copy
is idempotent ("exists:").
- D8 covers each refusal the brief lists, the factory case, and a mutant per refusal.
@@ -0,0 +1,48 @@
# Webby review: row 226 (C2-13b, #1727) design, round d2
Webby, 2026-10-21T21:55Z (from `date -u`; the first push said 21:58Z, which was my estimate). Author: Gosalyn (gosalyn-0117). Candidate: agents/gosalyn/work/
queue-226/BUILD.md at d572b6102 on gosalyn/c2-13b. d1 was f46e66883, which got CHANGES in
REVIEW-design-d1.md. Sage's ruling, sage-2189, is as BUILD.md quotes it. I didn't read the message
itself. Design rounds get no queue record (sage-2182).
What I checked, without runs: the whole f46e66883..d572b6102 diff against d37210288. I checked the new
fact cites: sync.mjs's `running` guard, runtime.mjs:143-151 and :162-164, mirror.mjs ownedRoot,
readStateHeld's empty state (:205-210), the lastError code pattern, and validProblem (:196-201).
## Verdict: APPROVE
- B1. Release package and activate are out, with the CASE2_RELEASE seam and its test. The reason is
stated (2a has no Docker and no pi credential, and the hub runs no containers), and design 10 step 2b
changes to match (D8). Row 195's W8 note is there.
- N1 and webby-0086. D6a runs before the start and touches no mirror. D6b runs after the bus is up and
step 4. It reads libraryStatus only, which takes the flock and writes nothing, so it can't race the
bus timer. A missing mirror gets its own refusal rather than ownedRoot's library-git, and the
60 s wait goes in the run sheet.
- N2: a non-null lastError refuses with its fixed-pattern code.
- N3: the context is `{ business, project, dataRoot }`, as the bus builds it.
- N4: step 7 removes only a file this run created, and the D9 test checks a matching file survives
byte for byte.
- N5 and N6 are as asked. The registry note is in D7.
- The tests cover each D6a refusal, each D6b refusal against a real ensureMirror and sync on a local
bare repo, and the pass with a `problems 1` line. Each refusal gets a mutant.
## Non-blocking
N1. D8 puts agents/gosalyn/work/case2/DESIGN.md in this row's manifest, but that file isn't on
refactor. It exists only on gosalyn/case2-design (1346 lines at 75fb3e10e). So landing 226 would add the
whole design doc to refactor, not just the three-line change. If Sage meant that, my manifest review
covers only D8's lines in it, and the landing note should say the rest arrives unreviewed by this row.
If not, make the D8 change on gosalyn/case2-design and leave the file out of 226's manifest. That's
Sage's call. It's doc-only, so it doesn't block (decision 137).
N2. On the path in the `problem` line: keep it. Jason needs the file name. But validProblem accepts any
string up to 4096 without NUL (mirror.mjs:199), and JSON.stringify escapes only C0 controls, `"` and
`\`. C1 controls (U+0080-U+009F, including U+009B, a one-byte CSI) and bidi overrides
(U+202A-U+202E, U+2066-U+2069) pass through to the terminal. Escape those too, for example by
replacing `[\u007f-\u009f‎‏‪-‮⁦-⁩]` with `\uXXXX` after stringify,
and add a test path that holds one.
N3. When the bus's first sync ran before the token went in, the state has lastFetch null and lastError
library-credential-missing. Both D6b refusals apply. Check lastError first, or print both, so the
output names the cause rather than just "never synced". The D9 test for a fresh mirror should
leave lastError null so it hits the other branch.
+39
View File
@@ -0,0 +1,39 @@
# Webby review: row 228 (row A, #1729), round 1, ZAI_API_KEY in the session env allowlist
Webby, 2026-10-20T21:19Z (from `date -u`). Requested by Sage (sage-1495), request comment 29677.
Candidate: commit 4fa2ed1e3d5e20ad5a0c98a1578e35d4ba055c67 on researcher/row228, base 088087ac9 (an
ancestor of origin/refactor), one commit, three files. Brief: "Brief: row A" in
docs/plans/2026-10-20_case2-rows.md on origin/refactor 32b54ac3b. I approved that brief in webby-0034.
I ran `packages/seat/tests/session.test.mjs` alone on a `git archive` export of the candidate: 13 tests,
13 pass, 0 fail, skipped or cancelled. I took the mutant result from Researcher's
`out/mutants.log`. M1 removes `ZAI_API_KEY`, the new test reports failing, and the verdict is KILLED with
the restore sha256 matching. I didn't rerun the mutant.
## Verdict: APPROVE
Each item in the brief is in the candidate, and nothing beyond it:
- session.mjs:86: `"ZAI_API_KEY"` follows `OPENROUTER_API_KEY` on the provider line. Nothing else in
`ENV_ALLOW` changes, and `SESSION_CREDENTIALS` and `hasSessionCredential` don't change either.
- The new test checks membership and passthrough with `fixture-zai`, and checks that `ZAI_TOKEN` is
dropped. The existing allowlist test is unchanged.
- README :435-439 has the sentence the brief gives. At :714-716, "one line per variable" and the
every-provider-key sentence are in, and the "nothing in the repository creates or reads it" line is
left for C2-13a.
No other test reads either changed file. A grep of `packages/*/tests` and `scripts/test-*.sh` for
`ENV_ALLOW`, `session.mjs` and `cli/README`, outside session.test.mjs itself, finds nothing, and no 64-hex
pin names them. The fixture value is made up, so no credential value appears.
## Non-blocking
N1. Sage asked me to judge the repeat. The new sentence ends "a shell inside any of them can read it".
The next sentence, which was already there, says "A shell the harness opens, Claude Code's `Bash` or Pi's
`bash`, can read them." The two say the same thing. Worse, "them" now follows a sentence whose subject is
"it", so its antecedent, "the same variables" two sentences back, is hard to find. This is my miss in
webby-0034: the brief dictated the sentence, and the candidate copied it exactly. It's doc-only, and no
test reads the README, so it doesn't block (decision 137). A fix for a later README touch, or at landing
if that's cheap:
- End the new sentence at "Claude Code ones included."
- Change the existing sentence to "A shell the harness opens, Claude Code's `Bash` or Pi's `bash`, can
read all of these variables."
+37
View File
@@ -0,0 +1,37 @@
# Webby review: row 228 (#1729), round 2
Webby, 2026-10-21T05:10Z (from `date -u`). Author: Researcher. Request comment 29927 (sage-228-request-2,
revs 2671-2672, posted as jarvis). Candidate: manifest digest
ab40b92f68a03d4c528f9fe80333fe7897863ea35e27072f2a6c1de9fdf5d5e5, 3 paths, on researcher/row228-r2
5f2aad593. Base origin/refactor 9b5362018 is its ancestor. Round 1 was APPROVE on commit 4fa2ed1e3
(comment 29677). Scope (sage-1734): the README only.
What I checked, without runs or a worktree:
- I took the manifest text from `queue show 228` (round 2), not from the author's packet. It hashes to
the digest above. Each path's sha256 from `git show 5f2aad593:<path>` matches its line.
- session.mjs (sha256 40822543...) and session.test.mjs (193bacdc...) have the same git blobs at
5f2aad593 as at round 1's 4fa2ed1e3 (f8254d1c, 08b3d051). I didn't reread them.
- I read `git diff 9b5362018 5f2aad593 -- packages/cli/README.md` against round 1's README hunk.
- Readers: only packages/cli/tests/host.test.mjs:855 reads this README, through `join()`, and it checks
only that the unit's file name appears. Neither hunk touches that name. No test quotes a changed
sentence, and no 64-hex pin names the README.
Researcher's slot run on 5f2aad593 was 94/0 over four files with M1 killed (sage-1734). I didn't run
anything.
## Verdict: APPROVE
- The :714 hunk keeps both sides. Row 190's wording stays whole: "nothing in the repository creates it,
and only the Case 2 precheck reads it, printing variable names, never values. No session sees the file
itself, only the variable." Researcher's two changes sit inside it: "one line per variable" and "It
holds every session provider key, not only Claude's."
- The N1 trim at :435-439 now makes one reader point. The `ZAI_API_KEY` sentence says it reaches every
session the host starts, and the paragraph ends "A shell the harness opens ... can read all of these
variables." Round 1's repeated "a shell inside any of them can read it" is gone, and nothing it said
was lost.
## Non-blocking
N1. Doc-only. Two singulars at :711-718 are left from when the file held one token: "The session
credential comes from" and "only the variable". With one line per provider key, "credentials" and "the
variables" would match. No test reads either phrase.
+23
View File
@@ -0,0 +1,23 @@
# Webby review: row 228 (#1729), round 3
Webby, 2026-10-21T06:52Z (from `date -u`). Author: Researcher. Request comment 29949 (sage-1800, revs
2704-2706, posted as jarvis). Candidate: manifest digest
82cf98c600622a23a968a3856659ad5c28c3ab0f4561a1ec4a9ee1f072c71d7c, 3 paths, on researcher/row228-r3
cf18c42b9, one commit on c771a36f4 (row 234's landing). Round 2 was APPROVE on ab40b92f (comment 29927).
Scope (sage-1800): the README, my round 2 N1 pair, and row 234's hosts section.
What I checked, read-only, with no runs and no worktree:
- The queue's round 3 manifest text hashes to the digest above, and each path matches
`git show cf18c42b9:<path>`.
- session.mjs and session.test.mjs are git blobs f8254d1c and 08b3d051, the same as rounds 1 and 2
(sha256 40822543 and 193bacdc).
- `git diff c771a36f4 cf18c42b9 -- packages/cli/README.md` has only row 228's hunks: the allowlist
paragraph at :432-439 (round 2's trimmed text) and the credential-file paragraph at :708-718.
Researcher's slot run was 55/0 (host.test.mjs 42, session.test.mjs 13).
## Verdict: APPROVE
- N1 from round 2 is fixed: :711 "The session credentials come from" and :718 "only the variables".
- Row 234's "## hosts (row 234)" section is intact. The diff against the 234 landing doesn't touch it.
- Row 190's wording in the credential-file paragraph stays whole, as in round 2.
+122
View File
@@ -0,0 +1,122 @@
# Webby review: row 230 (C2-13a, #1732), round 1
Webby, 2026-10-20T23:50Z (from `date -u`). Author: Gosalyn (gosalyn-0071). Candidate: manifest digest
8e9d1ef0c9c84e798a568921825804c5f6ae1e77ea96777bd1ddd782a2be830a, 5 paths, on gosalyn/c2-13a-on-190
2fa7b0f4c. Base 00d4cc1f6 is its ancestor. Brief: docs/plans/2026-10-20_case2-rows.md "C2-13a", blob
f6b4933f0f47.
What I ran, on a detached worktree of 2fa7b0f4c in my own scratch, removed afterwards:
- The queue's manifest text hashes to the digest above, and `sha256sum -c` passes for all 5 paths.
- Each file alone with `node --test --test-reporter=tap`: case2-unit 28/28 with 0 skipped (the bind test
ran), and host.test.mjs 42/42. host.test.mjs reads the cli README (:855).
- A grep of `packages/*/tests/*` and `scripts/test-*.sh` for the two script names finds only
case2-unit.test.mjs. A grep for 64-hex pins near README or case2 finds none, and none of the tests that
read the README checks the changed sentence.
I ran no mutants. I read both scripts in full against the brief, and the test file case by case.
## Verdict: CHANGES (B1)
### B1. Seven precheck refusals and the "missing claude passes" case have no test
The brief's Tests section says "The precheck runs against a scratch data root. Each refusal has a case",
and "With no claude-code instance the claude check doesn't run, and a test shows a missing claude passes."
A grep of case2-unit.test.mjs for each refusal's message finds no case for these branches of
scripts/case2-precheck.sh:
- :77, the user isn't in the passwd database.
- :137, the business file doesn't load (bad JSON, or `loadBusiness` refuses it).
- :151, the run root is a symlink. This one matters most. `stat -c '%a'` follows the link, so without
this branch a symlink to any 0700 directory passes as "is 0700". Nothing would catch its removal.
- :179, the installed Console unit's ExecStart has no `--port`.
- :180, `ip_unprivileged_port_start` can't be read.
- :223, claude replies without the marker (`nomarker`).
- :224, claude gives no JSON reply.
The "no claude-code instance" case at case2-unit.test.mjs:515-518 doesn't show that a missing claude
passes. Its PATH is `bin:/usr/bin:/bin`, and this host has `/usr/bin/claude` (root-owned, 0755). The
`missing` case at :532-536 already builds a PATH without it. The no-route case needs the same PATH so
the claude really is missing.
The fix is test-only: one case per branch, each matching its REFUSED line, and the `usr-bin` PATH on the
no-route case. The two fake-claude cases need a third fake kind that prints JSON without the marker, and
a fourth that prints something other than JSON.
## Deviations (sage-1570 accepted them for the build)
1. `--business ID` on the precheck: accept. The brief names no other way to find the business file.
2. A missing run root passes: accept. `newRun` makes it with `mkdirSync(root, { recursive: true, mode:
0o700 })` (packages/seat/src/session.mjs:109), so before the first launch there is nothing to check.
The symlink branch still needs its test (B1).
3. The unmeasured 2000-token ceiling: accept. A wrong ceiling fails safe. Claude Code's own prompt is
far above 2000, so an appending claude can't pass, and a ceiling set too low only refuses a good
host. Row 195's install run measures it. Each precheck run with a claude-code instance also makes one
real model call. That's small, but the operator should know it.
4. The fake getent uid for the proof-owner case: accept. The check compares against `$USER_UID` from
getent (:219), so moving the uid exercises the same comparison a root-owned file would.
5. Refusing `%h` left in any unit: accept. A system unit reads `%h` as root's home.
6. 64 lower-case hex for the allowed user with X-Authentik-Uid: accept. It matches decision 101, and
:298-299 test both sides.
## What I checked and found right
- The render. Every token has one value, as the brief lists. `@PATH@` puts the node's directory first
every time (:228-230), so `/usr/bin/node` gives `/usr/bin:/usr/local/bin:/bin`. `@CONFIG_ENV@` is
deleted and `@SESSION_ENV@` is the `%h` path, as instance.sh:88-90 does for the default instance. `%h`
then becomes the literal home. Values going into sed and awk pass `plain` (:55), so none holds `|`, `&`,
`\` or `%`. `@FLAGS@` goes in after the token check (:255-259), so an allowed user holding `@` can't
trip it. The flag values also contain no `&`, so bash's pattern replacement can't expand one.
- The added lines. User, Group and the four hardening lines are added in order at the end of
`[Service]`, the caps only on the Console, and `WantedBy=multi-user.target` replaces
`default.target` in both units. No `After=` or `Wants=`.
- The checks (:302-359). Fixed lines are each checked exactly once in `[Service]`, with the Environment=
lines matched exactly. RestrictNamespaces, PrivateUsers, SystemCallFilter and PermissionsStartOnly are
refused. The Exec prefix check reads the whole leading run of `-@:+!`, so `-+` is caught. A continued
line refuses, so nothing hides on one.
- The re-install. Each installed unit passes the same checks against its own copy, must equal the
render with only the copy id swapped (:380), and both units must name one copy (:385). So the node,
flags, `--pm` and `--claude` can't drift.
- The inputs. `--node` and `--claude` must be absolute, plain and normalized, not a symlink, outside
`<home>`, and root-owned and not group- or other-writable all the way up to `/` (:114-124). The copy
id is 40 lower-case hex and must name a real directory (not a link) holding `scripts/mosaic`. The proof
file is checked by metadata only: no symlink anywhere in its path (`realpath -e` equality), under
`<home>/.config/mosaic-dev/secrets`, owned by the user's uid, 0600 or 0400. It is never opened.
- The serve flags match the brief for both profiles. The `--tester-port` equal to `--port` case is
unreachable through the ranges (1..1023 against 1024..65535). The test at :351-356 says so, and the
guard stays.
- The start order: daemon-reload, enable both, start the bus, then the Console. A failed bus start
stops before the Console starts (:403).
- The precheck never prints a session env value. Every precheck test asserts the fixture key is absent
from stdout and stderr. The claude subshell exports the file's variables only into claude's
environment.
- The bind test builds the argv with `binds()` and `sandboxArgv()`, checks the floor first and fails
rather than skipping when it's too low, and uses a free high port as its control.
## Non-blocking
N1. On a re-install the units are rewritten, but `systemctl start` (:403-404) does nothing to a running
unit. The bus and Console keep running the old copy, and the last line still says "enabled and started".
The units on disk then name the new copy while both processes run the old one. If the bus later restarts
on its own (`Restart=on-failure`, mosaic-bus.service.in:32), it comes back on the new copy while the
Console stays on the old one. That is the split pair :385 refuses, and the bus accepts only its own
human-cli.mjs path. The brief puts re-pins in row 195 (brief line 899) and says nothing here restarts
anything, so this isn't a defect in this row. Row 195 should restart both together, or this script
should say on a re-install that it changed nothing that is running.
N2. A drop-in overrides every line this script checks. Examples are
`/etc/systemd/system/mosaic-webui.service.d/*.conf`, `[email protected]/`,
`mosaic-bus@<business>.service.d/` and the global `service.d/`. A unit file named
`mosaic-bus@<business>.service` replaces the template altogether. Only root can write these
directories, so this is the same defence in depth as the installed-unit checks. The user-unit installer
already refuses ExecStart and WorkingDirectory drop-ins (scripts/bus-service.sh:122-145, globs from
instance.sh:88). A later row (C2-13b or 195) could do the same for the system paths, and also check
User, Group and the hardening lines.
N3. The precheck's claude, when the bus unit has no `MOSAIC_CLAUDE_BIN`, is `command -v claude` in the
user's shell (:194). The bus runs `claude` from the unit's `Environment=PATH=`, which is the node's
directory then `/usr/local/bin:/usr/bin:/bin`. A claude in `~/.local/bin` would be checked while the bus
runs a different one, or none. This matches the brief's words ("`claude` on PATH"). Resolving it against
the unit's PATH value, which the precheck can already read with `unit_value`, would check the binary
the bus runs.
N4. Doc-only. The changed README line (packages/cli/README.md:713) is about 150 characters, while the
paragraph around it wraps near 75. No test reads the sentence.
+72
View File
@@ -0,0 +1,72 @@
# Webby review: row 230 (C2-13a, #1732), round 2
Webby, 2026-10-20T23:58Z (from `date -u`). Author: Gosalyn (gosalyn-0076), op gosalyn-230-inreview-r2, rev 2559.
Request comment 29788 (sage-1595). Candidate: manifest digest
fbb0d7e42c67507519e03b5a2354fb82e760908fcfeecdf1f6c1da3e836a81da, 5 paths, on gosalyn/c2-13a-r2
96768766c. Base origin/refactor 722136772 is its ancestor. Round 1 was CHANGES B1 (REVIEW-r1.md), and
sage-1589 ruled N1 into this round.
What I ran, on a detached worktree of 96768766c in my own scratch, removed afterwards:
- I took the manifest text from `queue show 230` (round 2), not from the author's scratch. It hashes to
the digest above, and `sha256sum -c` passes for all 5 paths.
- `git diff 00d4cc1f6 722136772` on the 5 paths is empty. `git range-diff` shows the two r1 commits
unchanged (`=`) after the rebase, so the r1 to r2 change is 96768766c alone, and I read it in full.
- Each file alone with `node --test --test-reporter=tap`: case2-unit 30/30 with 0 skipped, and
host.test.mjs 42/42.
- The reader grep (`packages/*/tests/*`, `scripts/test-*.sh`) still finds only case2-unit.test.mjs for
the two scripts. No 64-hex pin names a case2 file or the cli README. host.test.mjs reads the README
only for the unit file name, so the rewrap can't break it.
I ran no mutants. Gosalyn's 10 mutants and the gate run in Sage's slot. Sage won't land the row until
both are green.
## Verdict: APPROVE
B1 is fixed. Each branch I named has a case that matches its REFUSED line:
- :77: getent exits 2.
- :137: the business file is unparsable, missing (ENOENT), or fails the schema. The schema case asserts
it isn't the "cannot read" branch, so it reaches `loadBusiness`.
- :151: the run root is a symlink to a directory chmod'ed 0700. If the branch were removed, `stat` would
read the target as 0700 and the test would fail.
- :179: the Console ExecStart has no `--port`.
- :180: the port-floor file is missing.
- :223 and :224: the fake claude has new `nomarker` and `text` kinds. `nomarker` keeps 40 tokens, so it
passes the ceiling and reaches the marker check.
The no-claude-code case now runs on `pathWithoutClaude()`, the same PATH as the missing-claude case. It
asserts both a pass and that no claude line printed, so it shows a host with no claude passing.
N1, as ruled, is in:
- The script asks `is-active` for both units after the re-install checks and before the write.
- A running unit gets "was already running, so it is still on copy <old>, not copy <new>", never
"started".
- A unit that wasn't running gets "started ... on copy".
- The "restart ... together" line follows.
- A fresh install still prints the round 1 line, and the clean-render test now asserts that.
- The script restarts nothing.
The fake systemctl answers `is-active` from `CASE2_TEST_ACTIVE`, and the two sequence asserts take
the two new log lines into account.
N4 is rewrapped. N2, N3 and the N1 restart are in BUILD.md as row 195 carry items.
## Non-blocking
N1. The "still on copy <old>" line takes `<old>` from the installed unit file (`ids[0]`, :384-386), not
from the running process, and those can differ. Here is the sequence:
1. Install A and start both.
2. Re-install B with both running. The files now name B, and both processes still run A.
3. Re-install C before any restart. The script prints "still on copy B", but both run A.
The new test models this. Its second step re-installs A with only the Console running, after an
install of B that didn't restart it, and asserts "still on copy B". In that story the Console runs A.
The restart line is still right, so the operator does the right thing. The copy claim is what's
wrong. Two fixes would work, and either fits row 195:
- Drop the copy and print "was already running, so it is not on copy <new>".
- Read the running copy from the unit's main process, for example `systemctl show -p MainPID`, then
`/proc/<pid>/cwd`.
N2. A test-file nit. The `both` case asserts `doesNotMatch(/started/)` over all output. Any later wording
that holds the substring "started" would fail it, including "not started". Matching
`/started mosaic-/` would pin the claim the ruling cares about.
+62
View File
@@ -0,0 +1,62 @@
# Webby review: row 231 (#1733), C2-01b, round 1
Webby, 2026-10-21T09:32Z (from `date -u`, corrected from an estimated 09:36Z). Author: Gosalyn. Request comment 30003 (sage-231-review-1b,
sage-1896). Brief: docs/plans/2026-10-20_case2-rows.md "C2-01b". These are my row 212 notes N1-N3
(webby-0041). Candidate: manifest digest
d4c4cf09c1d5abe5580e6d8804ef134719c0c0a4431c6231d814f6c38e9966fe, 9 paths, on gosalyn/c2-01b-r2
88daaac50. The code is 3647a5a93, and 88daaac50 adds BUILD.md. Both are on fefd54e13.
What I checked:
- I took the manifest text from `queue show 231`. It hashes to the digest above, and each path's sha256
at 88daaac50 matches its line. fefd54e13..88daaac50 touches no path outside the manifest.
- `git patch-id --stable` of 3647a5a93 is 45911915a9ee, matching BUILD.md.
- Overlap: the refactor tip is 38b83ea5c. fefd54e13..38b83ea5c changes no manifest path.
- I read the whole diff of src, README and tests. Gosalyn's singles were 60/0 (BUILD.md). I ran nothing.
## Verdict: CHANGES (two blockers)
N1 (escapes) and N2 (list item lines) are correct. N3 (attachments) has two problems.
### Blockers
B1. A link to a real note whose name ends in a dot and letters is silently dropped. readLibrary skips
`isAttachment(link)` before it resolves anything (reader.mjs:202). isAttachment (links.mjs:30-32) calls
any last segment ending in `.` plus 1-8 letters or digits, with at least one letter, an attachment. So
`Dr.Quinn`, `Mr.Robot`, `a.k.a` and `Book 1.ii` are attachments.
Failure: `Book One/Characters/Dr.Quinn.md` exists, and another note has `relationships:` `- "[[Dr.Quinn]]"`
or a body `[[Dr.Quinn]]`. The resolver would find `Dr.Quinn.md` on its `.md` try (links.mjs:69), and
Obsidian resolves it the same way. Instead the link never reaches the resolver, it isn't in the
file's links, and no problem is raised. The relationship disappears with nothing to show for it.
Fix: resolve first. Treat a link as an attachment only when it doesn't resolve and isAttachment is
true. The brief's cases still hold: `![[cover.png]]` gives no problem, `![[missing-note]]` gives R4,
`![[note.md]]` resolves. Add a test with an indexed `Dr.Quinn.md` linked as `[[Dr.Quinn]]` in the body
and in a block list.
B2. The change covers more than the brief. The brief's N3 is about embeds only: "An embed whose target
has an extension other than `.md` is an attachment ... An embed with no extension or with `.md`
resolves as a link" (case2-rows.md:131-133). Its tests name only embeds. The code makes every wikilink
an attachment by extension: plain `[[cover.PNG]]`, `[[Board.canvas]]` and front matter values
(reader.mjs:202, tests at reader.test.mjs:110-118). It deletes the approved line "A link to a `.canvas`
file or an attachment is R4" from README.md:87 (as of fefd54e13). That turns a reported R4 into silence for plain and
front matter links. Neither BUILD.md nor the brief records a ruling for it. Either narrow the skip to
`link.embed`, or have Sage amend the brief to cover links and front matter values, and then cite that
ruling in BUILD.md.
## Non-blocking
N1. quote() covers the brief's ranges plus DEL, and the survey test checks both ends of each range.
Some format characters are outside that list and JSON.stringify leaves them raw: U+061C (Arabic letter
mark, a bidi control), U+2028 and U+2029, and zero-width U+200B-U+200D and U+FEFF. None can reorder a
line the way the escaped ones can. Add U+061C if you're touching the range anyway.
N2. The survey's `attachment` count only looks at names, without resolving. Once B1 is fixed, a
`[[Dr.Quinn]]` that resolves in the reader will still count as an attachment in the survey. Either
check it against the survey's own path list or describe the count in README.md as "links whose target
looks like an attachment".
N3. The N2 work is sound. itemLines gets each item line pushed next to its value (reader.mjs:121-122)
and is created only for a block list key (:131). parseFile uses the item's line when the key has
one, and otherwise the key's line (:157-160), so flow and scalar values keep the key's line. The new reader test checks R4 at the
flow key line 2, R4 at item 6 and R5 at item 7.
+43
View File
@@ -0,0 +1,43 @@
# Webby review: row 231 (#1733), C2-01b, round 2
Webby, 2026-10-21T09:51Z (from `date -u`). Author: Gosalyn. Request comment 30005 (revs 2785-2786,
sage-1913). Round 1 was CHANGES (B1, B2) at 88daaac50 (rev 2774, comment 30003). Sage ruled in
sage-1900 (B2 narrows to embeds, README R4 line back; B1 resolves first) and sage-1902 (a front matter
value written as an embed resolves like a link). Candidate: manifest digest
312c16fc4f657b38058cdfeed69eb11db52cd41507e6ec51a46c93c326b96690, 9 paths, on gosalyn/c2-01b-r2
f37a65857, a fast-forward from 88daaac50 by one commit.
What I checked, without runs or a worktree:
- I took the manifest text from `queue show 231` (round 2). It hashes to the digest above, and each
path's sha256 at f37a65857 matches its line. Same 9 paths as round 1. fefd54e13..f37a65857 touches no
path outside the manifest.
- Overlap: the refactor tip is 4364dddb0. fefd54e13..4364dddb0 changes none of the 9 paths.
- I read the whole 88daaac50..f37a65857 diff (8 files, +110 -34).
Gosalyn's slot runs (sage-1903, sage-1907): run 1 was 60/1, the new Dr.Quinn test expecting the front
matter link first, while the reader lists body links first. That was fixed in the test only. Run 2 was
61/0. BUILD.md records both. I ran nothing.
## Verdict: APPROVE
B1 is fixed. readLibrary resolves every link first (reader.mjs:202-204). Only an unresolved link skips
R4, and only when isAttachment holds. The new reader test indexes `Dr.Quinn.md` and resolves
`[[Dr.Quinn]]` in a block list and in the body, plus `![[Dr.Quinn]]`, with no problem.
B2 is fixed as sage-1900 rules. isAttachment is now `link.embed === true && !link.field` plus the
extension test (links.mjs:33-35), so a plain link and a front matter value are never attachments. An
unresolved one is R4. That includes a front matter value written as an embed, per sage-1902. The
reader test checks R4 for `"![[portrait.JPG]]"` (line 3), `[[Board.canvas]]` (line 5) and
`![[missing-note]]` (line 6), with no problem for `![[cover.png]]` or `![[map.pdf|Map]]`. README.md:88-89
is the fefd54e13 text again, word for word.
The two round 1 notes are taken. RAW_CONTROLS adds U+061C, with a survey test. The survey's attachment
count still goes by name, and README.md says so with the `![[Dr.Quinn]]` example, which is the
wording option I offered.
## Non-blocking
N4. README.md:88 says "A link to a `.canvas` file or an attachment is R4", and the next paragraph says
an unresolved attachment embed is never R4. The text is accurate if you read "link" as "not an
embed", but a reader could take the two as contradicting each other. "A plain link to ..." on :88 would
settle it, but that changes the line sage-1900 restored, so leave it unless Sage wants it.
+34
View File
@@ -0,0 +1,34 @@
# Webby review: row 238 (#1744), round 1
Webby, 2026-10-21T01:31Z (from `date -u`). Author: Launchpad (launchpad-0017), revs 2590-2591. This row follows up my row
200 r3 N1. Candidate: manifest digest 7093c10e43be8d7b9b38a3d0d025c5e0f014befd77f17a9b70e6f2a06feacec4, 3
paths, on launchpad/row238 bec7e499e (one commit). Base refactor 011d41ba6 is its ancestor and is
origin/refactor today.
What I ran, on a detached worktree of bec7e499e in my own scratch, removed afterwards:
- I took the manifest text from `queue show 238`. It hashes to the digest above, and `sha256sum -c`
passes for all 3 paths.
- Each touched test file whole with `node --test --test-reporter=tap`: stack-release 193/193 and
stack-instances 23/23, with 0 failures and 0 skipped.
- A grep of `packages/*/tests/*` and `scripts/test-*.sh` for the hint text finds :1951 (default, unchanged)
and the new :2102 and :2104 only. No test reads the changed TOOLS.md paragraph. The only TOOLS.md mention
in a test is a string at stack-release.test.mjs:491. No 64-hex pin names the 3 paths.
I ran no mutants. Mutant 1 and the suites run in Sage's slot.
## Verdict: APPROVE
`inst="${INSTANCE_FLAGS[*]:+ ${INSTANCE_FLAGS[*]}}"` (stack-release.sh:841) is the idiom `plan()` already uses
at :1192. INSTANCE_FLAGS is `()` by default and `(--instance NAME)` after a named `instance_select`
(instance.sh:49, :101). `${arr[*]:+...}` is safe under `set -u` on an empty array, so the default text is
byte-identical, and :1951 still passes on it. On alpha, both commands in the hint now carry
`--instance alpha`. The new subtest checks the exact refusal text and the `--plan` line, and `noLeak`
covers the run. Mutant 1 (`inst=""`) would fail both asserts. The TOOLS.md sentence matches the code.
## Non-blocking
N1. This is older than this row: neither hint names the business. The usage line is `activate ID BUSINESS`
(:39), but the hint reads `activate $TO --instance alpha without --sso first`, so pasting it as printed
gives a usage error, not the first step. `$BUSINESS` is in scope at :845. Adding it to both commands, and
the `acme` it puts in the three test strings, would fit a later row. It fails safe, since the usage error
changes nothing.
+22
View File
@@ -0,0 +1,22 @@
# Webby review: row 238 (#1744), round 2
Webby, 2026-10-21T06:52Z (from `date -u`). Author: Launchpad (launchpad-0025), revs 2697-2699. Request
comment 29948 (sage-1800, request rev 2701, resolved to posted 2703). Candidate: manifest digest
36b0adfa2e7b4807db26837c081eb418c926feb61baf2b9200c22a62eea7e32a, 3 paths, on launchpad/row238-r3
a33597c33. Base c771a36f4 (row 234's landing) is its ancestor. Round 1 was APPROVE on bec7e499e at
011d41ba6 (comment 29823). Scope: the merge only, after rows 232, 233 and 234 landed on these paths.
What I checked, read-only, with no runs and no worktree:
- I took the manifest text from `queue show 238` (round 2), not from the author's packet. It hashes to
the digest above, and each path matches `git show a33597c33:<path>`.
- `git patch-id --stable` over the 3-path diff is b9d0bb0ee3c7 for round 1 (011d41ba6..bec7e499e) and for
round 2 (c771a36f4..a33597c33). The change is the same, and the landed lines around it are untouched.
- scripts/stack-release.sh stays 100755 on c771a36f4 and a33597c33.
- Outside the 3 paths the commit adds only agents/launchpad/work/queue-238 files.
Launchpad's slot run of stack-release.test.mjs on the code blob was 196/196. External 25/25 and upgrade
20/20 from round 2 stand, since row 234 changed neither file nor the fixture.
## Verdict: APPROVE
Round 1's N1 (the hint has no BUSINESS) stays on the #1744 carry list, out of this row's scope.
+20
View File
@@ -0,0 +1,20 @@
# Webby review: row 238 (#1744), round 3
Webby, 2026-10-21T07:02Z (from `date -u`). Author: Launchpad (launchpad-0028), revs 2712-2713. Request
comment 29956 (sage-1810, revs 2714-2715, posted as jarvis). Candidate: manifest digest
1bf831f33501682a6498ed9c4900b42383ee1e8ad09905096d1a0f7062cfc7be, 3 paths, on launchpad/row238-r4
4c90099ea, one commit on c515273db (row 240's landing). Round 2 was APPROVE on 36b0adfa (comment 29948).
Scope (sage-1804): docs/TOOLS.md after row 240's landing.
What I checked, read-only, with no runs and no worktree:
- The queue's round 3 manifest text hashes to the digest above, and each path matches
`git show 4c90099ea:<path>`.
- `git diff c515273db 4c90099ea -- docs/TOOLS.md` is the one :599 hunk only. Row 240's lines at :837 and
:854 are untouched.
- scripts/stack-release.sh (blob cbc99c13, 100755) and stack-release.test.mjs (ed86589c) are the blobs I
approved in round 2 on a33597c33.
- `git patch-id --stable` over the 3-path diff is b9d0bb0ee3c7, as in rounds 1 and 2.
No runs, per Sage's ruling for a doc-only merge. Sage's combined 228 and 238 gate covers the landing.
## Verdict: APPROVE
+52
View File
@@ -0,0 +1,52 @@
# Webby review: row 243 (#1754), round 1
Webby, 2026-10-21T19:10Z (from `date -u`). Author: Morgana (morgana-0045). Request comment 30111 (revs
2936-2937, posted by Sage as sage-2078a after Morgana's own attempt failed its credential check).
Reviewer by sage-2066 and mos-1166. Brief: docs/plans/2026-10-21_gate-env-rows.md "The startup token
refusal test sets its file mode". Candidate: manifest digest
2432c4a7dbb64899be5b371e65f301c3abca9f1c30a3f110f4c252f3a74d4fdb, 5 paths, on morgana/row243
6581f750e. That's two first-parent commits on a36523713: 137bda3af (the tests) and 6581f750e
(BUILD.md).
What I checked, without runs or a worktree:
- I took the manifest text from `queue show 243` (last round), not from the scratch path. It hashes
to the digest above, and each path's sha256 from `git show 6581f750e:<path>` matches its line. All
five paths are 100644.
- a36523713..6581f750e touches exactly the 5 manifest paths. None is under src or scripts.
- Overlap: refactor is now 9a8c8860d. a36523713..9a8c8860d changes none of the 5 paths.
- I read the whole diff and the BUILD.md sweep, plus the stack-instances helpers (`run`, `homeEnv`,
`mode`) and the T-I1 mode assertions (:86-92).
- My own sweep at 6581f750e: every writeFileSync, mkdirSync or openSync in packages/*/tests with a
group or other bit (66 hits). Leaving out executables at 0o755 and 0o700 leaves 9. Three are this
row's fixes. host-tools :81 and :205 are acceptance cases. Three are adapter scripts at 0o644,
which no mode check reads. One is the gate-lane umask probe. Separately, no test creates a
group-writable or world-writable file or directory. That agrees with Morgana's list.
Sage's slot results (sage-2062, 18:55Z, by BUILD.md): on the base under umask 077 the files fail
7/1, 2/1, 27/1 and 21/2, on the predicted assertions. The candidate passes 8/0, 3/0, 28/0 and 24/0
under both 022 and 077. I ran nothing.
## Verdict: APPROVE
- process.test.mjs:76-78. The write keeps its mode, and `chmodSync(file, 0o644)` follows it, so the
file is too open under any umask, and credentials.mjs's 0600 check refuses it. `chmodSync` is
imported (:4). This is the brief's change, word for word.
- proof-cli.test.mjs:30. `chmodSync(file, mode)` runs inside the case map with each case's own mode,
so the 0o644 `mode` case stays too open and the 0o600 cases are set explicitly. `chmodSync` was
already imported (:10).
- sso.test.mjs:361. The chmod comes after the write and before the `--sso` start that must refuse,
and the later chmod to 0600 still follows. `chmodSync` was already imported (:10).
- stack-instances.test.mjs:40-43. `underUmask` runs bootstrap.sh through
`/bin/bash -c 'umask <mask>; exec "$@"'`, and every `bootstrap()` call goes through it with 022.
So the 0644 assertions at :87 and :92 test the mode the source asks for, whatever the gate's
umask. The new subtest (:100-108) pins the other side: under 077 the config and instance.json are
0600, and the directories stay 0700. A later chmod-to-0644 "fix" in mosaic-config.mjs would fail
it. The `config()` helper has no mode assertion and stays as it is, which is correct.
- The brief's sweep is in BUILD.md: the affected tests, the unaffected ones with a reason for each,
and the mode checks that have no refusal test. The test step ran both umasks, as the gate asks.
- Hash pins: BUILD.md reports a basename grep with a positive control and no pins. I found no test
that names any of the four files with a 64-hex hash either.
## Non-blocking
None.
+76
View File
@@ -0,0 +1,76 @@
# Webby review: row 244 (#1757), round 1
Webby, 2026-10-21T21:40Z (from `date -u`). Author: Morgana (morgana-0053). Request comment 30154,
posted attempt sage-244-review-r1b. Request by sage-2185. Brief: docs/plans/2026-10-21_gate-env-rows.md
"Gate runs use a fixture pi auth file" (DEFERRED 059577886, my N4 on row 197). Candidate: manifest
digest 6e7e3e3c290fe5034cc8766b6dc7eeb3725a99f330837a357c66381d18be1b60, 8 paths, on morgana/row244
94ab4c2fe. That's three first-parent commits on a36523713: 47148991b, 81e037d20 (the code) and
94ab4c2fe (BUILD.md slot results only, +24/-1).
What I checked, without runs or a worktree:
- I took the manifest text from `queue show 244` (last round), not from the scratch path. It hashes
to the digest above. Each path's sha256 from `git show 94ab4c2fe:<path>` matches its line, and the
modes match: test-release.sh and test-task.sh are 100755, the other six 100644.
- a36523713..94ab4c2fe touches exactly the 8 manifest paths.
- Drift against origin/refactor d37210288. a36523713..d37210288 changes none of the 8 paths. It also
leaves release.sh, scripts/lib, compose.yaml, run-task.sh and mosaic-task.mjs alone. The one nearby
path it changes is scripts/stack-release.sh (7247d80c7, row 196). That isn't a manifest path, and
neither suite nor anything under scripts/testing names it. `git merge-tree` merges 94ab4c2fe onto
d37210288 clean.
- I read the whole diff, BUILD.md (rulings A and C, the Compose reach list, coverage dropped, the
DEFERRED wording, the mutant table and the M-F correction), compose.yaml's PI_AUTH_FILE default and
MOSAIC_ADAPTER pass-through, `within` in lane-state.mjs, and the requiredSkips filter.
Sage's slot results (sage-2147, on 81e037d20, by BUILD.md): gate-lane-auth 5/0, gate-lane-image 11/0,
gate-lane 13/0, gate-lane-state 14/0, gate-lane-fixtures 8/0, test-task.sh 91/0 with two live-skip
lines, test-release.sh 14/0. M-G, M-L, M-A, M-T and M-F were all killed on their named tests, and M-F
was also killed on the real test-release.sh (FAIL 3). I ran nothing.
## Verdict: APPROVE
- lane-docker.mjs:32-36, laneAuthFile. An unset or relative PI_AUTH_FILE, the host default, a path
whose realpath is the host default, a missing path, a directory, and a symlink escaping the temp
root all refuse. realpathSync on both sides plus `within` handles a symlinked temp root. An unset
value throws inside the try and reaches the same refusal. It runs only on `compose run` (:43),
after the option check, so `info` and `image inspect` are unchanged. The CLI entry passes
process.env (:66).
- gate-lane.mjs:112-114 and :184. cleanEnv sets PI_AUTH_FILE to `<tempRoot>/pi-auth.json`, and
runGate writes it `{}` with `wx` and then chmods to 0600, so the umask can't widen it. The kept list
doesn't include MOSAIC_LIVE_PROVIDER, and test 2 checks that it gets dropped.
- test-task.sh:19-23 and test-release.sh:16-20 write their own 0600 fixture under SANDBOX, which is
`mktemp -d` under TMPDIR. In the lane that's the temp root, so the guard accepts it. Outside the
lane, the legacy gate is covered too. Writing a real file matters, because a missing bind source
makes Docker create a host directory. BUILD.md says why.
- test-release.sh:69. `"adapter":"mock"` carries the ruling A comment. The three activations pass
MOSAIC_MOCK_RESPONSE=MOSAIC_HELLO_OK. Fault injection still refuses on its random expectation, and
M-F proves the mock's fixed reply can't pass that check.
- test-task.sh. The hello and wrong-marker run records move to mock-adapters.json, which the seam
block writes first (:159). Fork recall (M11) and user recall (M16) run only under
MOSAIC_LIVE_PROVIDER=1. The skip line "skip live provider cases (row 244: ...)" doesn't match the
requiredSkips pattern (gate-lane.mjs:56, docker|daemon|sandbox|systemd|credential), so the gate
doesn't count it as a missing required case. The new "skip run-record cases (docker unavailable)"
does match, so a Docker-less gate still fails. With live on, the suite leaves PI_AUTH_FILE alone,
and test 5 asserts the operator's file reaches every run.
- gate-lane-auth.test.mjs. Tests 3 and 4 read what each suite passes to Compose through a fake docker
that never opens the host file, which is the brief's test. They also assert the adapter set is
{mock}, so a suite drifting back to pi fails.
- gate-lane-image.test.mjs passes run.env to the three guardDocker compose calls. Its refusal loop
calls guardDocker without env, but none of those argv reach the auth check, so the default doesn't
matter there.
- The coverage loss is stated. test-release no longer proves the pi image reaches the provider, and
the gate no longer proves M11 or M16 against a real model. The DEFERRED wording for Sage is in
BUILD.md. I agree that it needs an entry. A real `release.sh activate` and an operator run with
MOSAIC_LIVE_PROVIDER=1 are now the only proof of those paths.
- Hash pins: no file under packages/*/tests or scripts names any of the changed files next to a
64-hex hash.
## Non-blocking
N1. HOST_PI_AUTH is a literal in lane-docker.mjs:6 and gate-lane-auth.test.mjs:12, copied from
compose.yaml:43. If someone changes the compose default, the guard's host-path comparison goes stale
without failing a test. The guard doesn't depend on that literal, though, because `within(root, real)`
refuses any path outside the temp root, wherever the host file lives. No change needed.
N2. At landing, the DEFERRED entry 059577886 moves to Done (brief), and Sage adds the
"Real-model suite cases run outside the gate" entry from BUILD.md. Both are landing steps, not
manifest paths.
+51
View File
@@ -0,0 +1,51 @@
# Webby review: row 247 (#1749), C2-20b, round 1
Webby, 2026-10-21T11:55Z (from `date -u`). Author: Gosalyn. Request comment 30042 (revs 2846-2847,
sage-1976, posted by Sage as jarvis). Brief: docs/plans/2026-10-20_case2-rows.md "C2-20b", blob
f7c4cfbff43a. It covers my row 221 notes N1, N2 and N4 (webby-0073). Candidate: manifest digest
6d3f279f692be2cbfaeca9ae36c3a8404d4cae110a2c005150b89803b247263e, 4 paths, on gosalyn/c2-20b 6d43618a3.
That's one commit on 335bb3e30.
What I checked, without runs or a worktree:
- I took the manifest text from `queue show 247` (round 1), not from the scratch path. It hashes to the
digest above, and each path's sha256 from `git show 6d43618a3:<path>` matches its line.
- 335bb3e30..6d43618a3 touches exactly the 4 manifest paths. The script stays 100755.
- Overlap: the refactor tip is 5c6f73ed7. 335bb3e30..5c6f73ed7 changes none of the 4 paths.
- Outside readers: `git grep case2-persona-import` over packages/*/tests/* and scripts/test-*.sh hits
only the test file and its own fixture tree. No hash pin.
- I read the script and test diffs, the new fixture, BUILD.md and the brief.
Gosalyn's slot run on grant sage-1971, 11:49:04-11:49:22Z: 14/0, and M1-M4 each killed (BUILD.md).
Run 1 was red from the dropped space after the comma. The fix put the space back, and the test expects
it (`"-1001", 29 bytes`). I ran nothing.
## Verdict: APPROVE
- N1. The per-channel line prints `c.where` and `c.channel` through JSON.stringify
(case2-persona-import.mjs:248). `where` is the fixed prefix plus the platform key (:149, :155-157), so
both config keys are quoted. That's the only line that prints config text. The profile name comes from
pairing's PROFILE_ID check. The new test writes a newline into both keys, expects one quoted line, and
checks that no line starts with the forged `staged:` or `pairing:` text. M1 and M2 each fail it.
- N2. The dry run calls personaTextFault on each persona after its count line (:235-236), prints the
fixed reason, and refuses after the loop (:238), so both profiles' faults show before exit 1. Nothing
is written. The new test takes 8001 two-byte characters (16002 bytes, under soul()'s 32000 cap at
:130), expects the "over 8000 characters" line and exit 1, and expects exit 0 at exactly 8000. The
heading test now expects the fence line and the refusal text. M3 fails both. The header (:21-25) now
says the dry run refuses and that over four times the fence in bytes refuses before any count, which
is what :130 does.
- N4. privateDir turns an EEXIST from mkdir into a named Refusal and rethrows anything else
(:189-194). mkdir-race.mjs patches fs.mkdirSync and calls syncBuiltinESMExports, so the script's
named import (:29) gets a real kernel EEXIST, the same way trace.mjs hooks reads. The test hits it at
stage (the staging dir, :255) and at install (`personas/<business>`, :303), checks the exact stderr,
and checks that each raced dir is empty. M4 fails it.
The JSON.stringify residual (DEL, C1, U+2028/9 and bidi left raw) is recorded in BUILD.md under
sage-1964. None of those ends a line on a terminal, so no forged line is possible. A bidi control can
only reorder the display of that one line, and the keys come from Jason's own Hermes config. I accept
the limit as ruled.
## Non-blocking
N1. BUILD.md's mutant table puts M1 and M2 at line 237. At the candidate, the per-channel line is 248.
237 is where that line sat before this row's dry run change. The mutants match exact strings, so the
runs aren't affected. Only the table's line numbers are off.
+47
View File
@@ -0,0 +1,47 @@
# Webby review: row 248 (#1750), round 1
Webby, 2026-10-21T13:14Z (from `date -u`). Author: Sage. Request comment 30061 (sage-2001-review248,
sage-2006). Reviewer by mos-1166. Brief: docs/plans/2026-10-20_host-credentials.md "Machine grant row b
follow-up: the held stubs end with their test", blob 84bb8577fb23. Candidate: commit 4d99c178b, one
commit on 540239b6b.
What I checked, without runs or a worktree:
- The candidate kind is commit, digest 4d99c178b3fc92f8fa48acd487f873fce8ffc9e3. I read it from the
canonical checkout. sage/row248 isn't on origin, so the commit exists only there for now.
- 540239b6b..4d99c178b changes one file, packages/bus/tests/grant-executor.test.mjs (+3/-2). Nothing
under src changes, as the brief requires.
- Overlap: refactor is bc579f7a8, and 540239b6b is its ancestor. 540239b6b..bc579f7a8 changes
docs/guides/host-run-sheet.md and the host-credentials plan, not the test file.
- I read the stub fixture (test:52-96) and the three tests that set a hold flag (:427/:435, :675/:683,
:832/:839), plus the X5 test around :931.
Sage's gate (row note, 13:05-13:07Z at 4d99c178b): three whole-file runs at 37/0 with 0 stubs left,
the bus suite at 596/0, and the hold-add mutant leaves 2 stubs. Before the fix, 4 runs left 7. I ran
nothing.
## Verdict: APPROVE
- The fix. Both hold loops (:67 add, :72 remove) now run only while `release` is missing and `$D`
is a directory. `D` is the mkdtemp dir (:63, :100), which `t.after` removes (:101). So once the
dir is gone, each loop ends within one 50 ms poll.
- After the loop. With the dir gone, add finds no `skip`, `fail-add` or `noop-add`, and its append to
`$D/granted` fails, so it exits nonzero. Remove's `grep -v ... > granted.tmp` fails in the same
way. Both stubs end, which is all the brief asks. Their exit codes don't matter, since the test that
held them has already finished.
- The test runs. While a test runs, its dir exists, so the new check can't end a hold early. The
three holding tests write `release` themselves (:435, :683, :839), so their behavior doesn't change.
- The comment at :54 says both stubs also stop when the dir is gone, and it cites #1750. The :432
comment goes to row 249, as Sage said.
- The gate matches the brief: three whole runs with no stub left, the bus suite green, and a mutant
that drops the hold-add check leaves a stub.
## Non-blocking
N1. There's a second leftover of the same kind, though it doesn't loop. The X5 test at :934 runs
`sleep 30` through ForceCommand and the sudo stub (`exec "$@"`, :71), then revokes. The broker
rejects the run, but under the brief's own sshd reasoning (no SIGHUP for a non-pty command), the
remote `sleep 30` can keep running after the test ends and the daemon is SIGKILLed (:88). It ends on
its own within 30 s. A pgrep on TMPDIR may not see it, since its argv is just `sleep 30`. I didn't
run anything to confirm it lingers. If a lane cleanup that scans by cwd or process tree catches it in
proof 3, a shorter sleep, or a stub that also exits once the dir is gone, would fix it. That could go
in row 249 or a later row, not here.
@@ -0,0 +1,75 @@
# Webby review: row 252 (#1753, #435 J2) design, revision 1
Webby, 2026-10-21T18:59Z (from `date -u`). Author: Gander (gander-0033). Gate owner Sage (sage-2053),
reviewer by mos-1193. Brief: 640aa03f2:docs/plans/2026-10-21_console-inbox-approve.md, items 1-6 of
"What the design must settle". Candidate: agents/gander/work/queue-252/DESIGN.md at d218e14bd on
gander/row196-r2, sha256 d85bd103bc0a2cc96fe47b9f8a6d4e0e16d90ee398ef8ffaa2fe1c55d926d23f (matches
Gander's). The branch tip is now ab1f0e576. d218e14bd..ab1f0e576 doesn't touch queue-252/.
What I checked, without runs: the design against the row 196 build it names (64539c96b on a36523713).
I read #approvedGrant, the decision.resolve case, #human, verifyPrincipal and verifyHuman, the runtime
binding, the Console's send wiring and CSRF checks, sso-config.mjs, the decision_events DDL, and every
reader of a resolution's `via`. Line numbers below are at 64539c96b.
## Verdict: APPROVE
1. Scope. Grant requests only, with the reason given: a wider Console write widens the security slice.
Eligibility matches what #approvedGrant (broker.mjs:686-693) already requires: credential.mint,
route_to human, and a grant binding on the original raise. It adds exactly two option keys, with
approvalChoice naming one. A raise allows 2 to 9 options (:2730), so the two-key rule actually
filters, and Deny is the real other key, not a guessed `no`.
2. Identity. (provider, iss, sub) from the live row 192 session. Session.name, headers and body fields
are refused. It extends row 196's verifyPrincipal the way row 196's `user` kind does (principal.mjs:
20-39): an exact same-tree `cli.mjs serve` parent, reread after the walk, with its own nonce
namespace. Agent, seat, user and tester callers are refused. The same-uid residual is stated, not
hidden, and it repeats row 196's provenance statement word for word.
3. Write path. A restricted Console cap and its own resolve op, sharing the decision.resolve
transaction. #human (:470-473) and bindHuman are left alone. decision_events.via has no CHECK
(migrations/3b.sql:62-69; the only `via` CHECK is :100, on another table), so `via='console'`
needs no migration. That backs the design's "no new schema column". The subject goes in the
human.input body in the same transaction. #approvedGrant gains a console branch tied to that
provenance, not a wider string check.
4. Guards. Origin and Sec-Fetch-Site follow the existing send boundary (serve.mjs:177-178). There is
a two-step confirmation bound to the session, decision, choice and digest, used once and capped,
with no eviction. The refusal table gives a status, code and message for CSRF, confirmation, not
open, stale key, double click, read-only and no-send. Its codes match webui bus.mjs STATUS where
both have them (human-required, peercred-required and read-only are 403, outcome-unknown is 503).
It never retries automatically after an uncertain transport.
5. Without --sso, the buttons are hidden, not disabled, and the `mosaic decide` lines stay. The design
says why: a disabled button would suggest sign-in alone grants write authority.
6. Tests use stand-ins only: row 192's fake provider, the test bus, row 196's peer fixtures and the
scratch TMPDIR. There are 17 mutants, each tied to one boundary.
## Non-blocking
N1. Section 2 point 5 says "allowed owner subject" but doesn't say what makes a subject an owner.
console.sso subjects are {sub, name} only (sso-config.mjs:66), and the key check refuses any other
field. Row 196's design (:124-125) says any subject in console.sso.subjects reaches owner bus calls.
I'm reading the design the same way: every configured subject is an owner, with no new config field.
The build should say so. That means every configured subject can approve a credential mint. That's
consistent with today's `--send` owner writes, but Jason should see it stated in the REQ-WEB-2
amendment.
N2. Order inside the shared transaction. Today decision.resolve calls #approvedGrant (broker.mjs:2884)
before it writes the human.input event (:2889). If the console branch requires the matching human.input,
it has to be written first, or the local `grant` is null and neither setImmediate executor start runs.
The approval would then record but never act. The design's own test ("approval consumable once by
existing grant path", host and host-enroll starts after commit) catches this, so the manifest review
will check that it asserts the executor start, not just the event.
N3. Other readers of `via` the design doesn't list. webui public/bus.js:194 sets `by` to 'broker' for
any via other than 'cli', so a Console approval would show as the broker in the trail. That breaks
"Trail output shows this provenance". The close notice's from_run is 'human-cli' for any human
(broker.mjs:2920). git-socket.mjs:274 RESOLVED_VIA falls back to `via console`, which is fine.
landing.mjs:107 and #protectedEnd (:1427) keep requiring 'cli'. That is correct, since neither a merge
decision nor a role.revoke decision is eligible. The build should name each of these.
N4. Today's `--sso --send` owner writes already reach the bus through human-cli and record via=cli
(webui cli.mjs:80, bus.mjs:32, runtime.mjs:226). So #approvedGrant's `via === 'cli'` check doesn't by
itself tell a terminal from the Console. That's acceptable, because the Console wires only a fixed verb
list. The build should keep decision.resolve off that list, so a Console grant answer can only take
the restricted path. A test or mutant that checks the list would hold that.
N5. readProcess keeps only an allowlist of environment keys (human.mjs:34). The Console nonce and the
provider, iss and sub keys need adding there. The J5 mutant (other nonce namespace) depends on that
list, so the build needs both.
+67 -2
View File
@@ -1361,5 +1361,70 @@ 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)
2026-10-20T18:57:45Z | Webby (Opus 5.5, reviewer seat) | Case 2 design r1 review for Gosalyn (#416, DESIGN.md blob fba567f71dc1 at e997f3b76 / 88222f3f3), static | changes: B1-B12, N1-N9 in agents/webby/work/case2-r1/REVIEW.md; section 6 no-schema claim holds
2026-10-20T19:14:58Z | Webby (Opus 5.5, reviewer seat) | row 194 design review for Honker (#1692, DESIGN.md sha256 eec9f011567d at 14a6f5f9d, r1 8f80c278b also read), static | changes: B1-B8, N1-N8 in agents/webby/work/queue-194/REVIEW.md; Q2 answered yes with two conditions
2026-10-20T19:20:19Z | Webby (Opus 5.5, reviewer seat) | row 197 design review for Herb (#1695, DESIGN.md sha256 64a429a3 at 85a6eb0a9), static | changes: B1 (temp root under /tmp hides sandboxed run dirs), N1-N5 in agents/webby/work/queue-197/REVIEW.md
2026-10-20T19:23:14Z | Webby (Opus 5.5, reviewer seat) | row 198 inventory and design review for Bushroot (#1696, INVENTORY 76b630ce at 004f133bd, DESIGN c2ea3df5 at c8fe23a37), static | inventory approved; design changes B1-B3 (copy lacks board code, re-pin carries --board, POST placement vs serve.mjs:558), N1-N5 in agents/webby/work/queue-198/REVIEW.md
2026-10-20T19:24:03Z | Webby (Opus 5.5, reviewer seat) | row 197 design r2 re-review for Herb (#1695, DESIGN.md sha256 732ee0e2 at 6ad25d745), static | approve: B1 fixed, N1-N5 in; one manifest note in agents/webby/work/queue-197/REVIEW-r2.md
2026-10-20T19:29:11Z | Webby (Opus 5.5, reviewer seat) | Case 2 design r2 review for Gosalyn (#416, DESIGN.md sha256 bda7a717 at c1913fb07), static | changes: B1 top-level playground has no universe, B2 persona.text storage and tester read, B3 THREAD_DEFINITION in launch-settings must stay assistant, B4 persona block position and fence; N1-N4; notes agents/webby/work/case2-r2/REVIEW.md
2026-10-20T19:31:52Z | Webby (Opus 5.5, reviewer seat) | row 194 design r2 re-review for Honker (#1692, DESIGN.md 313c206e and PRD-1.7-DRAFT.md 98f4f49e at c0a35f47a), static | changes: B1 hire floor is a deny list that misses git.merge.protected (merge-gate.json, no credentials), decision.resolve.technical, policy.change, prd.approve, instance.name; make it an allowlist. r1 B1, B3-B8, Q2, N1-N8 fixed; Q4 view: start empty; notes agents/webby/work/queue-194/REVIEW-r2.md
2026-10-20T19:33:02Z | Webby (Opus 5.5, reviewer seat) | row 194 design r3 review for Honker (#1692, DESIGN.md 885fce0d and PRD-1.7-DRAFT.md 04c32917 at d0afda024), static | changes: r2 B1 stands except instance.name (allowlist); B2 fire delist and claim revoke can't share a transaction, name order and recovery; N1 REQ-LAND-2 relies on Gitea refusal, N2 recheck by start time; notes agents/webby/work/queue-194/REVIEW-r3.md
2026-10-20T19:36:19Z | Webby (Opus 5.5, reviewer seat) | row 198 design rev 3 re-review for Bushroot (#1696, DESIGN.md 51343813 at 7e2ead7f3), static | changes: rev 1 B1-B3 and N1-N5 fixed, sage-1372 rulings read right; new B1 in-process board runs spawnSync tmux (5 s per seat) and agent-send (15 s) in the Console event loop, make createBoard async with a stall test and mutant; N1 decision 8 fallback sentences, N2 --board support check must not match a refusal; notes agents/webby/work/queue-198/REVIEW-r3.md
2026-10-20T19:37:29Z | Webby (Opus 5.5, reviewer seat) | row 197 manifest review for Herb (#1695, MANIFEST.md 16768433 at 84f21923c, TIMING.md 148a1416 at fa820fed6), static | changes: B1 drop dead Board :9 replacements (port below unprivileged floor 1024, not shared by lanes, 20 files, collides with row 198), add a :9 listener preflight refusal; N1 row 198 overlap for Sage; notes agents/webby/work/queue-197/REVIEW-manifest.md
2026-10-20T19:38:46Z | Webby (Opus 5.5, reviewer seat) | row 194 design r4 re-review for Honker (#1692, DESIGN.md 824ef884 and PRD-1.7-DRAFT.md 379cc318 at 7d0788236), static | approve: HIRE_ALLOWED allowlist checked against role files, fire order A-B-C repeatable; N1 launch should refuse a listed instance mid-fire, N2 hire-time check should require role version 2; notes agents/webby/work/queue-194/REVIEW-r4.md
2026-10-20T19:39:47Z | Webby (Opus 5.5, reviewer seat) | row 198 design rev 4 re-review for Bushroot (#1696, DESIGN.md 5ef6436f at 9af75dc72), static | changes: B1 and N1 fixed, --board parse check right; new B1 parses_flag swap at stack-release.sh:670 breaks the tester-port fixtures (stack-release.test.mjs:265, T37), keep it to --board; N1 index coalescing can return a scan from before markSeen's write; notes agents/webby/work/queue-198/REVIEW-r4.md
2026-10-20T19:40:29Z | Webby (Opus 5.5, reviewer seat) | row 197 manifest r2 re-review for Herb (#1695, MANIFEST.md 2a1a3ad0 at bdc9cd56a), static | approve: literal Board :9 URLs kept, leases only at the close-rebind probes (preview.test:208, serve.test:100), T1 :9 preflight; P3 row 198 order per sage-1384; notes agents/webby/work/queue-197/REVIEW-manifest-r2.md
2026-10-20T19:43:52Z | Webby (Opus 5.5, reviewer seat) | Case 2 design r3 for Gosalyn (#416, DESIGN.md sha256 f368c27d at 2958acb81), static | CHANGES B1 (routes.set lets the tester change a creative instance's route vars), N1-N3; notes agents/webby/work/case2-r3/REVIEW.md
2026-10-20T19:44:59Z | Webby (Opus 5.5, reviewer seat) | row 198 design rev 5 for Bushroot (#1696, DESIGN.md sha256 1503f764 at 3f71b359b), static | APPROVE, N1 (write, bump, scan order); notes agents/webby/work/queue-198/REVIEW-r5.md
2026-10-20T19:49:56Z | Webby (Opus 5.5, reviewer seat) | row 192 r1 candidate 83b32d8d8 for Launchpad (#1690, manifest 3ad2a73d...badab), static plus a node probe of returnPath | CHANGES: B1 open redirect after sign-in via dot segments (sso.mjs:200-206, :284); N1-N5; notes agents/webby/work/queue-192/REVIEW-r1.md
2026-10-20T19:52:33Z | Webby (Opus 5.5, reviewer seat) | Case 2 design r4 for Gosalyn (#416, 5fc296e70 blob 7c62c40cbbb5), static | CHANGES: B1 human reaches launch.models via limits.set (DESIGN.md:29-30, :545-548), test missing; r3 B1 fixed; N1-N2; row 192 r1 recorded in queue rev 2303 (comment 29553); notes agents/webby/work/case2-r4/REVIEW.md
2026-10-20T19:54:11Z | Webby (Opus 5.5, reviewer seat) | Case 2 design r5 review for Gosalyn (#416, e1635308b), static | APPROVE, r4 B1 N1 N2 fixed; notes agents/webby/work/case2-r5/REVIEW.md
2026-10-20T19:58:32Z | Webby (Opus 5.5, reviewer seat) | row 192 r2 review for Launchpad (#1690, 37582e820, manifest 6f8cee14), static plus a node probe | APPROVE, r1 B1 fixed, mutant 23 equivalent; notes agents/webby/work/queue-192/REVIEW-r2.md
2026-10-20T20:00:07Z | Webby (Opus 5.5, reviewer seat) | Case 2 design r6 step 1 review for Gosalyn (#416, 1508fd175), static | CHANGES B1 root steps on .54 have no owner after #421 stopped; notes agents/webby/work/case2-r6/REVIEW.md
2026-10-20T20:03:11Z | Webby (Opus 5.5, reviewer seat) | Case 2 design r7 sections 9-10 and C2-13 brief review for Gosalyn (#416, 8645cb456), static | CHANGES B1 root installs a unit file mosaic wrote; step 1 itself fine; notes agents/webby/work/case2-r7/REVIEW.md
2026-10-20T20:04:59Z | Webby (Opus 5.5, reviewer seat) | Case 2 design r8 step 2c and C2-13 brief review for Gosalyn (#416, eab6e1a3a), static | APPROVE, r7 B1 N1 fixed, N1 privileged Exec prefixes; notes agents/webby/work/case2-r8/REVIEW.md
2026-10-20T20:14:58Z | Webby (Opus 5.5, reviewer seat) | row 200 r1 review for Launchpad (#1698, 5dc0b5534), static | APPROVE, N1-N4; notes agents/webby/work/queue-200/REVIEW-r1.md
2026-10-20T20:24:21Z | Webby (Opus 5.5, reviewer seat) | row 197 implementation packet review for Herb (BUILD.patch 6a1cd3e5, worktree a4e3e857e), static plus two FD probes | changes B1 (manifest tests missing) B2 (fixture lease test crosses lanes); notes agents/webby/work/queue-197/REVIEW-impl.md
2026-10-20T20:31:25Z | Webby (Opus 5.5, reviewer seat) | row 200 r2 review for Launchpad (#1698, e3fd9f3a0, manifest a1f6fe91), static | approve, N2 deviation agreed (mutant 12 equivalent), queue rev 2355; notes agents/webby/work/queue-200/REVIEW-r2.md
2026-10-20T20:45:28Z | Webby (Opus 5.5, reviewer seat) | row 197 implementation review r2 for Herb (herb-0023, BUILD.patch 0b3871bd, SOURCE.json 91ba149f), static | approve, N1-N3 non-blocking; notes agents/webby/work/queue-197/REVIEW-impl-r2.md
2026-10-20T20:49Z | Webby (Opus 5.5, reviewer seat) | Case 2 design r9 review (#416) for Gosalyn (29e85196b, blob 60a2d6cb751e, incl 13a98bfd0 29404f996), static | approve, N1-N3; notes agents/webby/work/case2-r9/REVIEW.md
2026-10-20T20:50Z | Webby (Opus 5.5, reviewer seat) | row 197 implementation review r3 for Herb (herb-0026, N1-N3 delta, BUILD.patch 55fe390d), static | approve; notes agents/webby/work/queue-197/REVIEW-impl-r3.md
2026-10-20T20:55Z | Webby (Opus 5.5, reviewer seat) | row A brief review (ZAI_API_KEY allowlist) for Sage/Gosalyn (a2d94b2c2, rows blob 81140d017441), static | approve, N1-N2; notes agents/webby/work/case2-rowA/REVIEW-brief.md
2026-10-20T21:01Z | Webby (Opus 5.5, reviewer seat) | Case 2 design r10 review (#416) for Gosalyn (4281b0064, blob 4a1860e698e0, two system units), static | changes B1 Console boot start is Jason's call, B2 PATH/NODE/FLAGS render, B3 hardening lines unlisted, N1-N5; notes agents/webby/work/case2-r10/REVIEW.md
2026-10-20T21:15:35Z | Webby (Opus 5.5, reviewer seat) | Case 2 design r11 review (#416) for Gosalyn (fb20a61b4 + eac92d956, rows blob 3a86d5f048dc), static | APPROVE, N1-N4; notes agents/webby/work/case2-r11/REVIEW.md
2026-10-20T21:19:21Z | Webby (Opus 5.5, reviewer seat) | row 228 (row A, #1729) review r1 for Researcher/Sage (4fa2ed1e3, comment 29677), static plus session.test.mjs 13/13 | APPROVE, N1 doc-only; notes agents/webby/work/queue-228/REVIEW-r1.md
2026-10-20T21:47Z | Webby (Opus 5.5, reviewer seat) | row 213 (C2-08, #1714) review r1 for Morgana (bb5f46006e49 on e13ec4929, comment 29701), static plus single test files | CHANGES B1 broker-path M10 tests, B2 changes.jsonl mode; N1-N5; notes agents/webby/work/queue-213/REVIEW-r1.md
2026-10-20T21:48Z | Webby (Opus 5.5, reviewer seat) | row 212 (C2-01, #1713) review r1 for Gosalyn (bdf18272a, comment 29704), static plus single test files | APPROVE; N1-N5; notes agents/webby/work/queue-212/REVIEW-r1.md
2026-10-20T21:53Z | Webby (Opus 5.5, reviewer seat) | row 213 (C2-08, #1714) review r2 for Morgana (b5ff0ac0e754 on aee32d185), static plus single test files | APPROVE; B1 fixed, r1 B2 withdrawn as my error; notes agents/webby/work/queue-213/REVIEW-r2.md
2026-10-20T23:29Z | Webby (Opus 5.5, reviewer seat) | row 200 review r3 (activate --sso, rebase on row 190) | APPROVE on b3ead55c8b48 (9df40878f), stack-release 192/192, stack-instances 23/23; r1-r2 usage-regex miss owned; notes agents/webby/work/queue-200/REVIEW-r3.md
2026-10-20T23:50Z | Webby (Opus 5.5, reviewer seat) | row 230 review r1 (C2-13a, gosalyn/c2-13a-on-190 2fa7b0f4c, digest 8e9d1ef0c9c8) | changes B1 (7 precheck refusals and missing-claude case untested), N1-N4, six deviations accepted; notes agents/webby/work/queue-230/REVIEW-r1.md
2026-10-20T23:58Z | Webby (Opus 5.5, reviewer seat) | row 230 review r2 (C2-13a, gosalyn/c2-13a-r2 96768766c, digest fbb0d7e42c67) | approve; B1 fixed, sage-1589 N1 in; N1 (old copy id from the unit file, not the process), N2 test nit; notes agents/webby/work/queue-230/REVIEW-r2.md
2026-10-21T01:31Z | Webby (Opus 5.5, reviewer seat) | row 238 review r1 (#1744, launchpad/row238 bec7e499e, digest 7093c10e43be) | approve; N1 hint omits BUSINESS (pre-existing); notes agents/webby/work/queue-238/REVIEW-r1.md
2026-10-21T04:56Z | Webby (Opus 5.5, reviewer seat) | row 212 review r2 (merge scope, gosalyn/c2-01-r2 d3c7bbf83) | approve; notes agents/webby/work/queue-212/REVIEW-r2.md
2026-10-21T05:02Z | Webby (Opus 5.5, reviewer seat) | row 213 (C2-08) review r3, rebase scope, on manifest 887c32d6 (morgana/row213-r3 2d2ad2760) | approve, N1 creative credentials follow-up; notes agents/webby/work/queue-213/REVIEW-r3.md
2026-10-21T05:06Z | Webby (Opus 5.5, reviewer seat) | row 228 (#1729) review r2, README scope, on manifest ab40b92f (researcher/row228-r2 5f2aad593) | approve, N1 doc-only; notes agents/webby/work/queue-228/REVIEW-r2.md
2026-10-21T06:50Z | Webby (Opus 5.5, reviewer seat) | row 238 (#1744) review r2, merge scope, on manifest 36b0adfa (launchpad/row238-r3 a33597c33) and row 228 (#1729) review r3, README scope, on manifest 82cf98c6 (researcher/row228-r3 cf18c42b9) | approve both; notes agents/webby/work/queue-238/REVIEW-r2.md, agents/webby/work/queue-228/REVIEW-r3.md
2026-10-21T07:01Z | Webby (Opus 5.5, reviewer seat) | row 238 (#1744) review r3, TOOLS.md merge scope, on manifest 1bf831f3 (launchpad/row238-r4 4c90099ea) | approve; notes agents/webby/work/queue-238/REVIEW-r3.md
2026-10-21T07:19Z | Webby (Opus 5.5, reviewer seat) | row 197 queue round 2 merge review (304c67753, manifest 8119a608, comment 29961) | APPROVE; notes agents/webby/work/queue-197/REVIEW-merge-r2.md
2026-10-21T07:43Z | Webby (Opus 5.5, reviewer seat) | row 212 round 3 merge review (a2c72ac95 on c771a36f4, comment 29979) | APPROVE; notes agents/webby/work/queue-212/REVIEW-r3.md
2026-10-21T08:13Z | Webby (Opus 5.5, reviewer seat) | row 197 queue round 3 (r5) merge review (9aa6d52d9 on e7078e6a8, manifest 69fad4e7, comment 29981) | APPROVE; notes agents/webby/work/queue-197/REVIEW-merge-r3.md
2026-10-21T08:22Z | Webby (Opus 5.5, reviewer seat) | row 197 two-lane proof driver review (8ae9d8acd, run-proof.mjs b8bb3df6, sage-1850) | CHANGES B1 (gate temp roots left in scratch); notes agents/webby/work/queue-197/REVIEW-proof-driver.md
2026-10-21T08:31Z | Webby (Opus 5.5, reviewer seat) | row 197 proof driver r2 review, B1 and N3 only (b8461266c, run-proof.mjs ace376a9, sage-1864) | APPROVE; notes agents/webby/work/queue-197/REVIEW-proof-driver-r2.md
2026-10-21T09:29Z | Webby (Opus 5.5, reviewer seat) | row 197 queue round 4 (r6 df0c59608, manifest 1556cd29) merge and umask wrapper review | APPROVE; notes agents/webby/work/queue-197/REVIEW-merge-r4.md
2026-10-21T09:32Z | Webby (Opus 5.5, reviewer seat) | row 231 C2-01b round 1 (88daaac50, manifest d4c4cf09) review | CHANGES B1 B2; notes agents/webby/work/queue-231/REVIEW-r1.md
2026-10-21T09:37Z | Webby (Opus 5.5, reviewer seat) | row 213 C2-08 round 4 (2b3e67e36 on c771a36f4, manifest 26d4d0ec) rebase and 234 grant recheck | APPROVE; notes agents/webby/work/queue-213/REVIEW-r4.md
2026-10-21T09:52Z | Webby (Opus 5.5, reviewer seat) | row 231 C2-01b round 2 (f37a65857, manifest 312c16fc) review | APPROVE; notes agents/webby/work/queue-231/REVIEW-r2.md
2026-10-21T10:37Z | Webby (Opus 5.5, reviewer seat) | row 221 C2-20 round 1 (8f403d0d4, manifest 20f05e3a) review | APPROVE; notes agents/webby/work/queue-221/REVIEW-r1.md
2026-10-21T11:29Z | Webby (Opus 5.5, reviewer seat) | row 197 queue round 5 r7 (9c3166915, manifest 4e54024a) review | APPROVE; notes agents/webby/work/queue-197/REVIEW-merge-r5.md
2026-10-21T11:53Z | Webby (Opus 5.5, reviewer seat) | row 218 (#1719) C2-12 round 1 review, doc only, merge scope | APPROVE fc0d9c26f manifest fb1bbc5d, N1-N4 non-blocking; notes agents/webby/work/queue-218/REVIEW-r1.md
2026-10-21T11:55Z | Webby (Opus 5.5, reviewer seat) | row 247 (#1749) C2-20b round 1 review, merge scope | APPROVE 6d43618a3 manifest 6d3f279f, N1 non-blocking; notes agents/webby/work/queue-247/REVIEW-r1.md
2026-10-21T12:00Z | Webby (Opus 5.5, reviewer seat) | row 218 (#1719) C2-12 round 2 review, doc only | APPROVE 5fcc2ec90 manifest c0d3709d, N1-N4 fixed, N5 non-blocking; notes agents/webby/work/queue-218/REVIEW-r2.md
2026-10-21T12:04Z | Webby (Opus 5.5, reviewer seat) | row 218 (#1719) C2-12 round 3 review, doc only | APPROVE 89ba79d94 manifest 1a38baf5, N5 fixed, no new notes; notes agents/webby/work/queue-218/REVIEW-r3.md
2026-10-21T12:17Z | Webby (Opus 5.5, reviewer seat) | row 197 queue round 6 (Herb r8 c45ddeb6b, manifest 80dc1468), merge scope | APPROVE, N1 doc; notes agents/webby/work/queue-197/REVIEW-q6r8.md
2026-10-21T13:15Z | Webby (Opus 5.5, reviewer seat) | row 248 round 1 (Sage 4d99c178b, grant-executor held stubs) | APPROVE, N1 sleep 30 residual; notes agents/webby/work/queue-248/REVIEW-r1.md
2026-10-21T15:20Z | Webby (Opus 5.5, reviewer seat) | row 197 proof driver a5b636750 (Herb, proof 4 watcher change), herb-0090 | APPROVE, N1 probe cleanup retry, N2 pin is Sage's; notes agents/webby/work/queue-197/REVIEW-proof-driver-a5b636750.md
2026-10-21T19:00Z | Webby (Opus 5.5, reviewer seat) | row 252 design r1 (Gander d218e14bd, DESIGN.md d85bd103) | APPROVE, N1-N5; notes agents/webby/work/queue-252/REVIEW-design-r1.md
2026-10-21T19:10Z | Webby (Opus 5.5, reviewer seat) | row 243 round 1 (Morgana 6581f750e, manifest 2432c4a7) | APPROVE; notes agents/webby/work/queue-243/REVIEW-r1.md
2026-10-21T21:41Z | Webby (Opus 5.5, reviewer seat) | row 244 (#1757) round 1 review, manifest 6e7e3e3c at 94ab4c2fe | APPROVE, N1-N2 non-blocking; notes agents/webby/work/queue-244/REVIEW-r1.md
2026-10-21T21:49Z | Webby (Opus 5.5, reviewer seat) | row 226 (C2-13b, #1727) design d1 review, BUILD.md at f46e66883 | CHANGES B1 (release package/activate needs Docker and a pi credential the no-group hub user lacks), N1-N6; notes agents/webby/work/queue-226/REVIEW-design-d1.md
2026-10-21T21:55Z | Webby (Opus 5.5, reviewer seat) | row 226 (C2-13b, #1727) design d2 review, BUILD.md at d572b6102 | APPROVE, N1 (DESIGN.md not on refactor) N2 (escape C1 and bidi in problem paths) N3 non-blocking; notes agents/webby/work/queue-226/REVIEW-design-d2.md
+1 -15
View File
@@ -238,12 +238,6 @@ Exit codes: `0` ok · `2` invalid file or credential reference · `3` system
config problem · `4` usage or a required file missing. Details:
`packages/business/README.md`.
`mosaic credentials status|issue|rotate|revoke|delete` (row 199) goes to
`packages/cli` and the bus's human-only credential verbs: an agent's own
token in `<configDir>/secrets/agents/<business>/<agent>/<kind>`, read on
stdin, never printed. The tester may run `status` and `revoke`. Details:
`packages/cli/README.md`.
`instances` and `instance add|set` (row 165) go to `packages/cli` and the
bus's human-only `instances` and `instances.set` verbs, so they need a live
bus host and refuse inside an agent session. `instances` prints the revision,
@@ -374,7 +368,7 @@ reports checkout or copy identity and refactor drift, and writes nothing.
```
scripts/stack-release.sh adopt BUSINESS
scripts/stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-port N]
[--proof-header NAME --proof-file PATH | --no-proof] [--no-send] [--timeout S] [--plan]
[--proof-header NAME --proof-file PATH | --no-proof] [--timeout S] [--plan]
scripts/stack-release.sh pm-on BUSINESS [--timeout S]
scripts/stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
scripts/stack-release.sh status BUSINESS
@@ -486,14 +480,6 @@ copies. It checks the file with `stat` and `realpath` and never reads the value.
or `off`. On the live copy it also notes that the run re-pins the pointer's copy
and only the Console flags change.
`activate ... --no-send` (row 189, mos-0972) drops `--send` from the pointer's
Console flags and the unit, so the Console serves reads and no human sends. It
may name the live copy while the pointer holds `--send`; once `--send` is gone a
same-copy `--no-send` refuses `already-active`. Later activates, `pm-on` and
rollbacks carry the pointer, so send stays off. No flag puts `--send` back.
`--plan` prints one `send:` line: `dropped`, `off` with or without the
`--no-send` note, or `carried`.
#### A target past c216f47db: the PM after the harness kind
From c216f47db on, the bus launches the PM only with a `harness` kind in the
+5 -6
View File
@@ -1,8 +1,7 @@
# Brief: activate --no-send drops the Console's --send
Sage, 2026-10-20, for Mos's ruling mos-0972 (Q175 interim, Vikunja #412). Owner Sage, reviewer Dewey, gate owner
Sage. Base: refactor 9ae8c73ac (row 163 landed). Line numbers are at that commit. The candidate corrects them;
the first draft took them before row 163.
Sage. Base: refactor 9ae8c73ac (row 163 landed). Line numbers are at that commit.
This row builds the tool only. The live restart without `--send` is a separate step afterwards, a same-copy
activate under Q173 with a backup first and a STATE report after. No test here touches `~/.mosaic-dev`, the
@@ -15,11 +14,11 @@ Mos ruled that the Console runs without `--send` until Q175 phase 2, because wit
process can forge `X-authentik-username` on 127.0.0.1:7332 and send as Jason (design section 8). On the pinned
stack, the Console flags live in the pointer (`~/.mosaic-dev/state/stack-active.json`):
- `console_for_target` (scripts/stack-release.sh:664-693) carries the pointer's flags unchanged. Only
- `console_for_target` (scripts/stack-release.sh:658-687) carries the pointer's flags unchanged. Only
`--tester-port` and the row 187 proof pair can change.
- `check_target` (:249-256) lets an activate name the pointer's copy only for a proof-pair change.
- `adopt` refuses once a pointer exists (:828).
- A hand `webui-service.sh install` without `--send` makes `flags_drift` (:646-657) refuse
- `check_target` (:247-249) lets an activate name the pointer's copy only for a proof-pair change.
- `adopt` refuses once a pointer exists (:822).
- A hand `webui-service.sh install` without `--send` makes `flags_drift` (:640-651) refuse
`unit-flags-drift` on every later activate, pm-on and rollback.
So the tool can't drop `--send` today. This row adds that one path.
@@ -1,57 +0,0 @@
# Brief: a credential store per agent, own git identity, pushes to a remote
Sage, 2026-10-20, for mos-0974 (MVP T1 item 6) and the MVP plan
(`jason-master-restart/projects/mosaic-mvp/PLAN.md` item 6, GAP-MAP G11 and G13; TARGET.md: "credentials store
available for all agents; secrets protected with RLS equivalent" and "Agent uses own credentials for git and
other actions"). Gitea #1697. Owner Megavolt (Opus 5.5, a new builder seat), reviewer Duckworth, gate owner
Sage. Base: refactor at the brief commit. Design first. The first deliverable is
`agents/megavolt/work/queue-ROW/DESIGN.md`.
This row draws a security boundary. Sage reviews the design's authority model, and Mos rules on it before any
code is written.
## What exists today (GAP-MAP, measured at 9ae8c73ac)
- G11. Credential references exist per instance for Gitea and Vikunja only
(`packages/business/src/credentials.mjs`, `packages/bus/src/credentials.mjs`, `vocabulary.mjs:103`). One
Claude env file is shared by every seat.
- G13. Agents use their own Vikunja token (`packages/tasks/src/adapter.mjs`). Git goes through the host
socket (`packages/cli/src/git-socket.mjs`). Gitea tokens are loaded but never used.
- The development repository's helper (`scripts/git-credential-mosaic.mjs`, wired in `.git/config` for
git.mosaicstack.dev) pushes as one identity. Until this row lands, seats push only their own `<seat>/*`
branches through it (mos-0981).
## What the design must settle
1. The store. Where each agent's credentials live, per instance, under the row 190 config canon. File modes,
ownership and the read path. Which process may read which entry, and how the bus enforces that (the "RLS
equivalent"). A process asking for another agent's entry is refused and logged.
2. Kinds. Gitea (git over HTTPS, and the API), Vikunja, and the model provider. Say which kinds 10-27 needs
and which wait. The model provider entry must not copy jwoltje's `claude-session.env`. If it needs a new
provider account or subscription, flag it for Jason and don't pick one.
3. Git identity and push. Each agent commits with its own name and email and pushes with its own token to a
remote it is granted. Which refs an agent may push (its own branches, never the default branch or another
agent's), and where that's enforced: the Gitea side (branch protection, token scopes) and our side. How
this fits the host git socket, or replaces it.
4. Lifecycle. Issue, rotate (`rotateBy`, `expires`), revoke and delete, and what row 194 (agent lifecycle) calls
when an agent is hired or fired. A fired agent's credentials stop working at once.
5. Who does what. Creating a Gitea user or token, or any account outside the fleet, is Jason's (Q173
exclusions). The design lists each such step as a command or UI action for Jason. Agents never mint
accounts.
6. Migration. Today's seats (jarvis helper, seat tokens) keep working until each moves. The mos-0981 push
rule ends only when Mos says so.
7. What this row closes in G11 and G13, and what stays open.
## Boundaries
- No credential value is read, printed, logged or committed. Tests use fake tokens in scratch roots and a
fake Gitea.
- No change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units, slot files, `~/.mosaic` or the live
`.git/config` of the canonical checkout.
- No request to git.mosaicstack.dev, tasks.woltje.com or any provider from a test.
## Gate
Duckworth approves DESIGN.md, Mos rules on the authority model, then Duckworth approves the manifest. The bus,
business, cli and tasks suites and every `scripts/test-*.sh` are green on Sage's gate, and the design's
mutants are killed.
+3 -3
View File
@@ -3,10 +3,10 @@
Sage, 2026-10-20, for mos-0974 (MVP, DECISIONS Q176, Vikunja #414 "User and main agent can create and spawn
agents" and "User maintains ability to terminate, regenerate, or create agents via WebUI or CLI ad-hoc", #415
Case 1 "Chief of staff agent has hire/fire authority for agents as needed; unless user forbids per agent" and
"User maintains ability to restructure organization at will"). Gitea #1692. T1 item 5. Owner Honker, reviewer Webby (mos-0981), gate
"User maintains ability to restructure organization at will"). Gitea #1692. T1 item 5. Owner Honker, reviewer Dewey, gate
owner Sage. Base: refactor at the brief commit.
Design first. The first deliverable is `agents/honker/work/queue-194/DESIGN.md`. Webby reviews it before any
Design first. The first deliverable is `agents/honker/work/queue-194/DESIGN.md`. Dewey reviews it before any
code.
## What exists today (GAP-MAP.md G8, G9, C1.5, C1.6, measured at 9ae8c73ac)
@@ -43,5 +43,5 @@ code.
## Gate
Webby approves DESIGN.md, then the manifest. The bus, cli, business and webui suites and every
Dewey approves DESIGN.md, then the manifest. The bus, cli, business and webui suites and every
`scripts/test-*.sh` are green on Sage's gate, and the design's mutants are killed.
-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.
+1 -3
View File
@@ -23,9 +23,7 @@ any code.
1. The human model. How a business lists several humans, how each is identified, and how a Console SSO
session (#412, row 192: each session names a provider subject mapped to a name) and a CLI call each
become exactly one of them. Starting rule (Mos, mos-0985, from Duckworth's federation review B1 at
b402197ff on origin duckworth/reviews): the bus takes a human principal only from something it verifies,
such as the Console's OIDC session under #412. A process's own claim of who it is never counts.
become exactly one of them.
2. Human roles. A small fixed set to start (for example owner, member, viewer), what each may do on the
bus and in the Console, and who may add or remove humans and change roles. Restructuring stays with
the owner (Case 1).
+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,57 +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.
## Mos's call on the Console dependency (mos-1019)
The inventory (004f133bd) found that the Console proxies `/api/board`, `/api/seen`, `/api/reply` and the
conversation routes to `DEFAULT_BOARD` on 7331 (`packages/webui/src/serve.mjs:12`), so the board view returns
502 today. Mos took Sage's recommendation: retire the 7331 listener and its README start commands, and have
the Console call the board's read code in-process, behind the Console's own auth (Q175 SSO on 7332).
`/api/reply` stays only under `--send`. The DESIGN.md for that change comes before code, and Webby approves
it.
## Acceptance
Nothing in the stack can start a writer on 7331. A test proves it: the entry point refuses (or is gone), no
script or unit starts it, and a grep check finds no starter. Each change gets a mutant that brings the writer
back and the test kills it. Mos added three items (mos-1019):
- The board view answers from the Console with nothing listening on 7331.
- Every board route sits behind the Console's auth.
- A test proves `/api/reply` refuses without `--send`.
Test fixtures that bind port 0 may stay if they only test the code in-process.
## Boundaries
- 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 DESIGN.md, then the manifest. The control-board and webui package suites, the cli suite and
every `scripts/test-*.sh` are green on Sage's gate, and the mutants are killed.
-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.

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