Compare commits

..
Author SHA1 Message Date
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
jason.woltje 0599b119f4 plans: MVP T1 briefs, SSO phase 1 (#1690, Launchpad), human users (#1691, Morgana), agent lifecycle (#1692, Honker), mos-0974/0977 2026-10-20 13:49:15 -05:00
jason.woltje b416d2ede9 sage: Q175 SSO design, Mos C1 condition (mos-0977): straggler check before Jason step 4 in the phase 2 window 2026-10-20 13:49:15 -05:00
jason.woltje 5fc73a729c revs 2237-2247: row 189 added, briefed, started, in-review round 1 (Dewey, comment 29457); rows 190 (Darkwing) and 191 (Rocko) added and briefed for MVP T1 (mos-0974); darkwing-190-start (rev 2245, Darkwing's) and rocko-191-start (rev 2244, made for Rocko) 2026-10-20 13:47:51 -05:00
jason.woltje 99772421ab sage: row 189 candidate manifest for Dewey's review (d6116771f) 2026-10-20 13:46:35 -05:00
jason.woltje da230ca489 plans: MVP T1 briefs, several instances per host (#1688, Darkwing) and the kill switch (#1689, Rocko), mos-0974 2026-10-20 13:42:33 -05:00
jason.woltje 4f2d1be68a sessions: Filbert Q175 SSO design D1/D2 ack 2026-10-20 13:41:20 -05:00
jason.woltje d55d86bb04 sage: Q175 SSO design r3 fix, Filbert r3 D1 (loader checks slot shape only) and D2 (slot from config dir, scratch Console dataRoot and unreachable issuer) 2026-10-20 13:40:49 -05:00
jason.woltje c56ccf916e sessions: Filbert Q175 SSO design review r3, approve with D1 2026-10-20 13:40:20 -05:00
jason.woltje 817e8f51b0 sage: Q175 SSO design r3 for #412, Filbert r2 C1 (shared loader, config.json inside the phase 2 window, rollback order, test 25) and C2 (401 signed-out page, test 24), notes and Q-E 2026-10-20 13:39:21 -05:00
jason.woltje 5d019d8768 sessions: Filbert Q175 SSO design review r2, changes 2026-10-20 13:34:53 -05:00
jason.woltje 59475fc5a8 sage: row 163 landing record, P19 comments 372/373 on 337/412, and P19 #413 filed for the --no-send row 2026-10-20 13:32:55 -05:00
jason.woltje 1a268249e6 sage: Q175 SSO design r2 for #412, Filbert r1 B1-B6, rulings Q-B/Q-C/Q-D, config in config.json (mos-0973) 2026-10-20 13:32:26 -05:00
jason.woltje def2e79327 plans: brief for stack-release activate --no-send (Q175 interim, mos-0972/0973, #1687, P19 413) 2026-10-20 13:21:30 -05:00
jason.woltje c30b36fffb sessions: Filbert Q175 SSO design review r1, changes 2026-10-20 13:19:06 -05:00
55 changed files with 3621 additions and 770 deletions
-655
View File
@@ -1,655 +0,0 @@
# T3 federation design: factory and hub (Vikunja #414, G3, G4, C1.1)
Author Gizmoduck (Opus 5.5), 2026-10-20, r4.11. Design only, no product code. Reviewer Duckworth (Opus 5.5,
mos-0978), r1 verdict changes B1-B5 (duckworth-0001, REVIEW.md at b402197ff), r2 verdict changes B6 (duckworth-0002,
REVIEW-r2.md at 03a663cd7), r3 verdict changes B7 (duckworth-0003, REVIEW-r3.md at 45cbe5896), r4 verdict changes B8 (duckworth-0005, REVIEW-r4.md at
4966136d4), r4.1 verdict changes, B8's issuer host (duckworth-0007, REVIEW-r4.1.md at b14f11136), r4.4 verdict changes B9 (duckworth-0010, REVIEW-r4.4.md at 4cf3ce232), r4.5 approved (duckworth-0012, REVIEW-r4.5.md at 912b1d325). R1, R2 and R5 go to Sage's
queue once Duckworth approves, and Sage assigns owners. Code lines are at origin/refactor 9ae8c73ac. The
Q175 SSO design is cited at Sage's local commit 817e8f51b (agents/sage/work/q175-sso/DESIGN.md), which was
not on origin when this was written.
Binding inputs: TARGET.md addendum 2026-10-20, DECISIONS Q175 to Q178, projects/mosaic-mvp/GAP-MAP.md and
PLAN.md T3 (all in jason-master-restart).
## Changes from r1
- B1. A user principal now comes only from the Console's verified OIDC session. The user CLI takes the
human proof's walk, which refuses agent markers and binaries, and its direct parent must be the
Console from the bus's own tree. This needs the path-bound human proof (P19 333). Residual risk in 3.6.
- B2. 3.6 names the ownership changes: `#owner`, `#threadFor`, the `#human` gates and `thread.send`'s
sender fields. `message.send` is on no list, and `#threadFor` refuses users as a second wall.
- B3. One level for 10-27, `collaborator`, with an exact verb list and a filter per read (3.5). Viewer and
every board read wait.
- B4. Revocation has three triggers: the next user request, a 30 s broker timer, and a reload verb that
R5 calls. The launcher refuses to start a revoked thread, because the broker reports it on every
`threadsState`. It also SIGTERMs a running one (3.7). R4 now depends on R3 and R5.
- B5. A separate uid per instance is a hard entry gate of R8, with checks (mos-0984 point 2).
- N1 to N9 are handled where they apply: v1 count (2), `configDir` at business.mjs:25-28 (5), R1 takes
providers and peers as arguments (7), "human-only" (3.5), file checks (3.5), `instances` as the real limit
(3.5), provider removal needs a Console restart (3.7), Q1 is with Jason (9), client ids in R8 (7).
- r3 (duckworth-0002): B6, the reply path to a user (3.6 item 7, runner header, R3 test and mutant, R7
asserts a reply). C1, the walk doesn't stop an unsandboxed same-uid agent (3.6). N10, `names` gates
through `#agent` (3.6 item 4). mos-0991: R8's gate accepts a separate host or a separate uid, and 3.8
checks the federation path for one-host assumptions. Q1 holds Jason's answer. morgana-0002 settles Q2.
- r4 (duckworth-0003, mos-0996): B7, R8's gate now proves there is no privilege path from the factory to
the hub. Per mos-0996 (Vikunja #418) the hub runs on its own host, and for 10-27 the gate is the host form
only. The uid form (row 190b) is a later option. The checks run as the factory uid with a clean
environment and pass only when the call fails: `sudo -n`, `doas -n`, and `ssh -o BatchMode=yes` to the
hub host with and without the agent socket. Factory root on the PVE nodes defeats the host form
(duckworth-0004, mos-1004), so it is an open R8 gate item with options O1 to O3 for Jason (Vikunja
#419, section 8). N11, `#waiting` returns null for `to` `user` (3.6 item 7, R3).
N12, R3 assumes nothing about how shares end (section 9 Q2). Row 196 (#1694, Gander) owns the shared
principal verifier before R3 (section 5).
- r4.11 (mos-1063): the hub's host is settled, so the "own host, not dragon-lin or Melanie's server"
lines and the #418 dependency are gone. The hub runs on .54, VM 1012, as user `mosaic` (Q181), and R8
depends on the Case 2 install on .54 (#416). #418 is closed. #419 carries only the wording for
Melanie. Mos ruled the four functional checks stay blocking.
- r4.10 (mos-1055, DECISIONS Q182 and Q183): R8 is a recorded acceptance. It starts with Jason's
acceptance of the factory paths to .54 on record, quoted in section 8. Every privilege and host check
still runs, and its result goes into the R8 record with who ran it. They report and don't refuse.
Agents may run the .54 checks as localadmin, which replaces r4.9's line. The mos-1042 order is now in
the R8 row. The wording still reaches Melanie only through Jason (#419).
Two lines that said R8 proves the factory has no login on the hub (sections 3.5 and 3.6) now say
factory agents keep ssh to .54 under Q183, recorded as accepted.
- r4.9 (mos-1037): R8 names who runs the root checks on 10.1.1.54.
- r4.8 (duckworth-0020 N22): R8 checks the hub user's limits on 10.1.1.54 instead of taking #421's plan
as done.
- r4.7 (mos-1030, DECISIONS Q181): the hosts are settled. Case 1 is W-jarvis, 10.1.1.53 (VM 1008), the
same host as the Hermes agents, so section 8 lists them as one factory host. The hub, Case 2, is
W-Melanie-OC, 10.1.1.54 (VM 1012). Its OS user is `mosaic`, a system user with no sudo, no groups
and no keys (ops-01's tightening plan section 3, Vikunja #421).
- r4.6 (duckworth-0012 N21, morgana-0011): R6's notice is row 193's final user sentence, word for word
(row 193 design r5 section 5.7, morgana/row193 e2d161e2e). The start-order line now matches the
section 7 table: R2 waits on #412 phase 1, and R5 lands after R1.
- r4.5 (duckworth-0010): B9, a user-thread run can't message the owner, but its decisions do reach the
owner's inbox, as Sage ruled for row 193 (sage-1368, morgana-0008)
and Mos kept (mos-1022). 3.6 item 7 says so, R6 tells
the user, and R3 tests both sides. N20, the start-order line waits for row 193's viewer level. R8
can't start while any host variable is unresolved.
- r4.4 (mos-1020, mos-1019): N17, R4 is chained after row 193's viewer level and keeps the level-loss
test, which has to kill its mutant before R4 is done.
- r4.3 (duckworth-0007): B8, an issuer that isn't on the hub host must pass the host-access, hypervisor
and backups rows against its own host, and a hosted issuer's admin accounts each pass the failed-call
check (section 8, sign-in row). N16, the dependency note covers every secret on a PVE guest. N17, R4's
level-loss test moves to row 193's viewer level. N18, 3.6 names `verifyPrincipal`. N19, the TLS check
runs through the public route and from the LAN.
- r4.2 (mos-1013, morgana-0007): "factory" in R8 and section 8 means every principal outside the hub
instance, not only the dragon-lin uid: the dragon-lin dev uid, the Case 1 instance's VM and its agents,
and Jason's Hermes agents on W-jarvis. Every R8 check runs from each of their hosts. Hosts are variables
until Mos confirms them with Jason. The table rows don't change. Q1 is settled by Q180: P2P is
person-to-person. Row 193 calls `covers` with no level for share reads and with
`level: 'collaborator'` for create, open, send and share, so `covers` needs no other argument
(morgana-0007, row 193 DESIGN.md at 19c015741).
- r4.1 (mos-1006, gander-0007, morgana-0005): R8's gate adds the identity provider path. A factory
credential that can administer the issuer the hub trusts gets in without the host (the B8 bullet below
has the rule). Mos accepted the sudo deviation (root ssh
and the `authorized_keys` check). Row 196's interface is settled: one verify per one-request
connection, `s.user` for that request only, and the walk mutants move to row 196. morgana-0005: grants
name bus project ids, and one pure `covers` in R1 matches a grant to a thread (3.5). R4's loss
predicate counts only collaborator grants, so a downgrade to viewer ends the run.
- r4.1 (duckworth-0005): B8, the hub's issuer and TLS front end are in its trust base. The hub trusts
only an issuer no factory credential can administer, and TLS ends where the factory can't reach
(3.4, R8). A brand, tenant or application in the factory's Authentik doesn't count. N13, the key check
is a procedure. N14, R3 consumes row 196's verifier and depends on it. N15, DNS control. mos-1009:
section 8 has a table of every privilege path (hypervisor, backups, sign-in provider, TLS front end,
certificate authority, DNS, network), each closed by a check or accepted by Jason with set wording.
- r2.1 (mos-0988): the separate uid is row 190b. R8's gate points at 190b, R7 at row 190 (one uid,
scratch). P19 333 is on the 10-27 path (section 8).
- Mos's rulings in mos-0978 and mos-0984 replace the r1 questions (section 9). The push credential note is
in section 10.
## 1. Summary
By 10-27, federation means a person from one instance can sign in to the other instance's Console and use
exactly what its owner granted: one business, some projects and its thread instance, at the collaborator
level. The receiving instance verifies that person itself through OIDC (Q175) and its broker enforces the
grant on every request. Revoking a grant refuses the next request and stops that user's thread runs within
about 30 s, or at once when the grant CLI makes the change.
Instance-to-instance traffic, where an agent on the factory talks to a role on the hub, waits until after
10-27 unless Jason rules otherwise (mos-0984 point 3). Section 6 sketches it (Ed25519 instance keys and
signed HTTP requests) so the 10-27 slice doesn't block it.
## 2. What exists today (measured)
- F1. The bus is a local Unix socket per data root: `socketPath` at packages/cli/src/config.mjs:19, opened
at packages/bus/src/runtime.mjs:169. Nothing listens on a network port. No live federation code exists.
The PRD lists REQ-FED-1 as post-v1 (packages/tasks/requirements.json:61).
- F2. A business has exactly one human: `human` at packages/business/src/business.mjs:160.
`bindHuman` refuses any other name (packages/bus/src/broker.mjs:532-538).
- F3. The human proof is a cooperative same-uid process check, not a wall. `verifyHuman` compares the
caller's uid, start time, nonce and CLI path (packages/bus/src/human.mjs:52-72), then walks the parents
and refuses agent markers, launch records and agent binaries (:73-88). The tester proof refuses only
managed runs and allows agents by design (packages/bus/src/tester.mjs:5-9, :56-60). The server takes
exactly one of `cap`, `human` or `tester` per request (packages/bus/src/server.mjs:52-70).
- F4. Authority for agents is checked in the broker: `#checkAuthority` and `#authorityFor`
(broker.mjs:763-799), with classification at :596-609. Every one of these is about a role run. Humans
get no roles. `#human` (broker.mjs:375-378) is the only human check.
- F5. Threads have a single `owner` column (packages/bus/schema.sql:247). `#thread` refuses a thread whose
owner isn't the caller (broker.mjs:356-362). `#owner` returns `tester` or the human (broker.mjs:364-366).
`#threadFor` special-cases the human (:326) and checks reply chains for the tester only (`#testerOwns`,
:352-355, called at :337 and :342). `#threadHolders(business, instance, owner)` finds the held runs of
one owner's threads (broker.mjs:298-306).
- F6. The Console reaches the bus as the one human by spawning `human-cli.mjs` per call
(packages/webui/src/bus.mjs:1-9, 27-31). Its entry is `packages/webui/src/cli.mjs serve`
(scripts/webui-service.sh:3). Its sign-in today trusts a proxy header (Sage's design, section "What exists
today"). Q175 replaces that with an OIDC relying party that takes a `subjects` list from a `console.sso`
key in config.json (Sage's design sections 1 and 4).
- F7. config.json is read by one loader that refuses unknown top-level keys
(scripts/mosaic-config.mjs:112-116). The business file is writable by agents through
`organizationWriter` (packages/business/src/organization.mjs:83), which is why Sage put SSO config in
config.json and not the business file.
- F8. Bus, Discord and Console run as `systemctl --user` units (scripts/bus-service.sh:200), so every
instance on a host runs under Jason's uid today.
- F9. Board reads are per business, not per project or instance: `inbox`, `agents`, `tasks`, `trail` at
broker.mjs:2143-2146, `decisions` :2174, `credentials` :2195, `messages` :2217 (the Console's set is
views.mjs:3-13). `project.list` returns every project in the business (:2318-2322).
- F10. The launcher restarts a thread that has pending messages and no holder (packages/cli/src/launcher.mjs:533),
launched as the business human (:542), every 2 s (:133). Its `startedFor` guard is in memory. A claim end
stops a run only at its next bus call (broker.mjs:2273-2275). The idle stop is the one place that
SIGTERMs a thread runner (launcher.mjs:527).
### The archived v1 federation (v1/apps/gateway/src/federation)
What it does: mTLS between gateways with certificates from step-ca. The grant id and subject user id sit
in custom certificate OIDs (ca.service.ts:22-24). A peer enrolls with a single-use, 15-minute bearer
token. The guard reads the client certificate off the TLS socket (server/federation-auth.guard.ts:93-106)
and re-checks the grant row on every request. Scopes are zod-validated resource lists
(scope-schema.ts:15-99). Shared data is read-only list and get queries.
Verdict: none of the code is reusable here.
1. It is about 4,470 lines of NestJS, Drizzle and Postgres code without tests (9,198 with them), plus a
step-ca dependency. The new stack has none of those.
2. Inbound federation never worked. The guard depends on `requestCert: true`, but the server is built as
`new FastifyAdapter({ bodyLimit })` with no TLS options (v1/apps/gateway/src/main.ts:50-53). Every
inbound call would get 401.
3. mTLS is the wrong fit for the homelab path. Traefik terminates TLS in front of the Console (Sage's
design, public path). A client certificate would reach the app as a forwarded header, which is the
header trust Q175 point 1 rules out.
Ideas worth keeping: re-check the grant on every request, never cache it in the credential. Keep
enrollment short-lived and single-use. Tag every federated result with its source instance.
## 3. The 10-27 slice
### 3.1 What it must do
1. D1. Each instance names itself and its peers in config.json, so the Console can show "you are on the
hub" and link to the factory. Peer entries carry no trust in the 10-27 slice. They are labels and URLs.
2. D2. The owner of an instance grants a person access to one business, with a project list and an
instance list. Grants live in a human-authored access file on the receiving instance.
3. D3. That person signs in to the receiving instance's Console with OIDC. The Console verifies the token
itself (Q175) and matches `(provider, sub)` to an access-file user with a live grant.
4. D4. The broker, not the Console, enforces the grant on every request: which verbs, which projects,
which instances, and which threads (own threads only).
5. D5. Revoking a grant refuses that user's next request on the Console and on the bus, and ends the runs
of that user's threads in that business.
### 3.2 What waits
- W1. Agent-to-agent traffic between instances (section 6).
- W2. Shared threads between several users (C1.4), and a viewer level. Both are T1.3's. A granted user gets
private threads only.
- W3. Remote owners. A grant never gives owner verbs: organization, instances, routes, plugins, limits,
decision resolution, launch revoke and the kill switch stay with the local human.
- W4. A registry, `mosaic comms`, enrollment tokens and key rotation tooling. With two instances,
out-of-band setup by Jason is cheaper.
- W5. Federated reads that merge results from both instances into one view, and any board read for a
granted user (F9).
### 3.3 Instance identity in the slice
An instance is its public HTTPS origin plus its own OIDC client at the provider. The factory and the hub
are two clients, so their ID tokens carry different `aud` values. The Q175 audience check refuses a
factory token presented at the hub, which makes tokens instance-bound by signature, not by a header. This
holds only if the two client ids differ, which R8 checks. No instance key is needed until instances talk
to each other (W1).
### 3.4 Remote user identity
A user is `(provider, sub)` where `provider` is an id from that instance's `console.sso.providers`. Q175
already verifies signature, issuer, audience, expiry and nonce, and allows several providers. The hub
trusts only an issuer that no factory credential can administer (B8, duckworth-0005), for example a
Pocket ID on the hub's side. Jason then has his own account at that issuer, with a `sub` of its own. A
separate brand, tenant or application in the factory's Authentik doesn't count: factory agents hold an
Authentik API token, and whoever administers the issuer can impersonate any user, reset a login or
repoint a redirect. If Jason wants the hub on the factory's Authentik anyway, that is his decision, of
the same kind as #419 (section 8). Either way the receiving instance checks the token. It never takes the
home instance's word for who the user is.
The owner path stays as Sage designed it: `console.sso` subjects sign in as the business human. Everyone
else comes from the access file. The two lists are disjoint, and the loader refuses a subject in both.
### 3.5 The access file and the collaborator level
`<configDir>/access.json`, next to config.json, so it is per instance (`configDir` at business.mjs:25-28
reads `MOSAIC_CONFIG`). It is not in config.json for two reasons. Revocation has to take effect without a
restart, and a typo in config.json stops every tool on the instance (Sage's design, section 4). It is not
in the business file because agents can write that (F7).
```json
{
"accessVersion": 1,
"revision": 3,
"users": [
{ "id": "jason", "home": "factory", "subjects": [{ "provider": "authentik", "sub": "<sub>" }] }
],
"grants": [
{
"id": "g-hub-1",
"user": "jason",
"business": "creative-hub",
"projects": ["*"],
"instances": ["researcher"],
"level": "collaborator",
"expires": "2026-12-31T00:00:00Z"
}
]
}
```
Rules the loader enforces, refusing the whole file on any failure (fail closed). The loader takes the
provider ids, peer ids and declared businesses as arguments, so it stays pure:
- The file is a regular file, not a symlink, owned by the instance's uid, with no group or other write.
These are the checks Sage's loader runs on the secret slot.
- Unknown keys refuse. `accessVersion` is 1. `revision` is a positive integer.
- A user id matches the business id rule and is never `human`, `tester`, or any business's `human`.
- `subjects[].provider` names a given provider. A `(provider, sub)` pair appears once in the file and
never in `console.sso.subjects`.
- `home` names a given peer, or is `local` for a non-federated extra user.
- A grant names a declared business. `projects` is `["*"]` or bus project ids, the UUIDs
`project.create` makes (broker.mjs:2306). `instances` is
`["*"]` or declared instances. `level` is `collaborator`, the only level in 10-27. `expires` is optional
ISO time.
- The loader checks a project entry's shape only (a UUID). It can't see the bus, so an id with no bus
project covers nothing.
- A grant past `expires` is treated as absent.
- A file that fails any rule leaves every user with no grant, which revokes everything. That is the safe
direction.
Matching a grant to a thread (morgana-0005). A thread's `project` is a bus project id
(`threads.project REFERENCES projects(id)`, packages/bus/schema.sql:245), set by `thread.create`
(broker.mjs:2329-2345). A bus project may have no repo in the business file, so grants can't name
business-file projects. They name bus project ids, not names. A name could be taken later: the tester,
which runs from an agent, can call `project.create` (:2300), and nothing renames or deletes a project
today. R1 exports one pure function, `covers(grant, { business, project, instance }, now, { level })`.
It is true when the grant is unexpired, the business matches, `projects` is `["*"]` or holds the
project id, `instances` is `["*"]` or holds the instance, and the grant's level is the one asked for,
when one is asked for. The broker passes the thread row. `["*"]` also covers projects made after the
grant, and the R5 help text says so. R5's `grant --project <name>` resolves the name to the id through
`project.list` at grant time and writes the id. `mosaic access list` shows both. Row 193 uses the same
`covers`.
The collaborator level is an exact verb list. Every other verb fails `access-refused` before the verb's own
checks. That covers all owner verbs (W3), every board read (F9), `message.*`, `decision.*`, project files,
`spec.read`, `assistant.stop`, `launch.*`, `name.set` and `usage.*`.
| Verb | Today | For a user |
|---|---|---|
| `project.list` | every project in the business (:2318-2322) | granted projects only |
| `thread.create` | the business's one thread instance (:2331-2334) | project granted, and that instance in the grant's `instances`, else `access-refused` |
| `thread.list` | owner filter (:2351-2362) | owner `user:<id>`, and only threads whose project and instance are still granted |
| `thread.open` | `#thread` owner check (:2365-2371) | same, plus the thread's project and instance still granted |
| `thread.send` | `#thread`, sender `human`, writes `human.input` (:2374-2398) | same grant check. Sender `from_role` `user`, `from_run` `user:<id>`, no `human.input` |
| `thread.messages` | `#thread` (:2401-2420) | same grant check |
| `names` | business-wide (:2288-2295) | granted instances only |
`human.input` events are the owner's authority evidence (the `input` view, broker.mjs:1203-1207). A user's
message must never write one, the same rule the tester follows at :2398.
`instances` is the real limit of a grant. The thread instance runs with its full role authority and is
launched as the business human (F10), so a collaborator's message drives that instance with the owner's
role. A grant gives a user exactly what the granted instance can do. The grant docs and the R5 help text
say so.
Writes go through `mosaic access grant` and `revoke` (row R5), atomically with a revision bump and one
receipt line in `<dataRoot>/state/access-log.jsonl` (append-only, as `activation-log.jsonl` is). The
CLI checks its caller with the human proof, but a direct file write by the instance's uid skips both the
CLI and the receipt. The separate host keeps other instances' processes from making that write, but
factory agents keep ssh to .54 (Q183), so one with a login there can make it. R8 records that as
accepted (section 8). Jason may also edit the file by hand.
### 3.6 The user principal in the broker
The bar (B1, mos-0984 point 1): a bus user principal comes only from something the bus can verify. Here
that is the Console's verified OIDC session, reached through a proof the bus checks itself. The calling
process's claim alone isn't enough, and neither is a proof that admits agent ancestry.
The proof. Row 196 (#1694, Gander) builds this check as the `user` kind of its shared `verifyPrincipal`,
and R3 consumes it (section 5). The checks are the ones below. A new path-bound CLI, `packages/bus/src/user-cli.mjs`, with its own nonce variable
(`MOSAIC_BUS_USER_NONCE`), so neither of the other CLIs' nonces proves it. The bus checks it with
the `user` kind of row 196's `verifyPrincipal`, which:
1. Runs `verifyHuman`'s checks with `cliPath` set to the bus's own `user-cli.mjs`: same uid as the bus,
start time, nonce, CLI path, and the parent walk that refuses agent markers, launch records and agent
binaries (human.mjs:64-96).
2. Also requires the user CLI's direct parent to be the Console from the bus's own tree: `argv[1]` equals
the bus's resolved `packages/webui/src/cli.mjs`, and `argv[2]` is `serve`. This binds the user CLI to
the Console's tree, as #225 binds `human-cli` to its literal path.
3. Takes `user` from the proof and binds it only if the access file gives that user a live grant in the
proof's business. Otherwise `access-revoked`.
So the claim "I am user jason" comes from a process the bus has verified as its own Console, outside any
agent. The Console makes that claim only after Q175 verified the ID token's signature, issuer, audience,
expiry and nonce and mapped `(provider, sub)` to the user.
This needs the path-bound human proof, P19 333. Residual risk, stated as Sage's design section 8 states it
for the owner. The proof is a cooperative same-uid check. A non-agent process on the instance's uid, a
shell Jason opens for example, can start its own copy of the Console's tree from the bus's path and claim
any granted user. So can anything that can ptrace or write files as that uid. The walk refuses an agent
in its normal process tree, but an unsandboxed agent on the same uid can get past it by starting the
Console through `systemd-run --user` or by daemonizing so its parent chain ends at pid 1. That is the same
hole the owner proof has (P19 333), not a new one. Other instances can't: the 10-27 hub runs on its own host, so its bus
isn't on their host at all. Factory agents keep ssh to .54 and root on pve (Q183), so the host form doesn't stop them, and R8 records that as accepted (section 8). Federation adds no weaker path than the owner's.
The broker changes, each with an R3 test:
1. `bindUser({ business, user })` makes a session with `s.user` set and `s.human` and `s.tester` unset.
2. `#owner(s)` (:364-366) returns `user:<id>` for a user session. Today it would return undefined.
3. `#threadFor` (:318-345) refuses a user session with `thread-required-use-thread-send`, as it refuses
the human at :326. `message.send` is also off the verb list. Two walls, so a reply-chain send into
another user's thread fails even if one is removed (R3 mutant).
4. The `if (!s.tester) this.#human(s)` gates on the six project and thread verbs above become a check
that admits the human, the tester, or a user with a live grant. `names` gates differently, with
`if (!s.human && !s.tester) this.#agent(s)` (:2290), so there the user branch goes before the `#agent`
fall-through, or a user session fails `#agent`.
5. Every other `s.human` and `s.tester` branch (48 and 41 in broker.mjs) gets an audit line in R3's
packet: user refused, user treated as human, or not reachable for a user. Since every non-list verb
fails first, most are "not reachable", and the audit shows it.
6. `refuse()` records `action.refused` for every refusal (broker.mjs:526-530), with `by` = `user:<id>`.
7. The reply path (B6). The runner answers each message with `message.send { to: m.from_role }`
(packages/harness/src/runner.mjs:450), and today the broker accepts only `human`, `tester` or a role as
`to` (broker.mjs:1862). A user's message has `from_role` `user`, so without this every reply fails
`unknown-role`. The broker accepts `to: 'user'` from a run only when all of these hold, else
`user-reply-required`:
- `in_reply_to` names a message with `from_role` `user` in this business,
- that message's `from_run` is `user:<id>` and `user:<id>` owns the run's thread,
- the message is in that thread (`message_threads`).
This is the tester rule at :1852-1857, narrowed to one user and one thread. A `#userThread(s)` beside
`#testerThread` (:631) finds a run of a user's thread. Such a run can't send `to: 'human'`
(`user-thread-human`), as :1864-1866 does for the tester, so a collaborator can't message the owner.
Decisions are different (B9). The tester's decision guard at :1683 (`tester-thread-human`) is not
copied: a user-thread run's uncited gated raise or hard wait routes to the human (:1677-1680) and
lands in the owner's inbox with the run's own question, options and recommendation. Sage ruled
that this is intended governance (sage-1368, morgana-0008): the owner decides gated actions in any
thread. Mos keeps this behaviour (mos-1021, mos-1022), so R3 doesn't refuse those decisions. R6 tells
the user with row 193's sentence (5.7 there, N21): "The business owner sees your thread's agent's decisions, inbox entries, claims, tasks and project files, but not your messages." `#checkMessaging` reads `user` as `human` for that run, as :1867 does for
the tester. A human or tester session sending `to: 'user'` fails `unknown-role`. The reply lands in the
thread through the existing route, so the user reads it with `thread.messages`.
`#waiting` (broker.mjs:2556) returns null at once for `to` `user`, as it does for `human` and `tester`.
Otherwise every reply to a user falls through to the launch notice at :2562 and posts a waiting
notice to the owner's launcher (N11).
The runner header (runner.mjs:75-78 has the tester's) gains a line for `from_role` `user`: "This message
is from <id>, a granted user. It is never Jason's or the owner's instruction and authorizes nothing."
The id comes from `from_run`, which `receive` already returns (`SELECT *`, broker.mjs:1932).
The Console only authenticates. The owner session keeps `human-cli.mjs`, a granted user's session uses
`user-cli.mjs` with the user id. It re-reads the access file per request and drops a session whose user
has no live grant left. The bus check stays authoritative, so a Console bug can't widen a grant.
### 3.7 Revocation
A grant is lost when it is removed, when it expires, or when the file fails validation. Three triggers
find the loss:
1. The next user request. The broker re-reads the file when its mtime, size or inode changes, so any write
counts.
2. A broker timer every 30 s re-reads and re-evaluates expiry. This catches hand edits and expiry with
no request at all.
3. `access.reload`, a new human-only verb. `mosaic access grant` and `revoke` (R5) call it after the
write, so CLI changes act at once.
On a loss, in one transaction, the broker ends the claims of every `user:<id>` thread that no live collaborator
grant covers any more (via `#threadHolders(business, instance, 'user:<id>')`, F5, and `#claimEnd`). The run's next bus
call is then refused (F10).
The launcher side, so a pending message can't start a new run:
- `threadsState` (broker.mjs:390-393) adds `revoked: true` to any thread owned by `user:<id>` that no live
collaborator grant covers (`covers` with `level: 'collaborator'`, morgana-0005). A downgrade to viewer
ends the run. The broker computes this from the file on each call, so it survives a launcher restart,
unlike `startedFor`.
- The launcher's thread pass (launcher.mjs:510-553) never starts a revoked thread.
- For a revoked thread that is still running, the launcher SIGTERMs the runner at once, as the idle stop
does at :527, but without waiting for `threadIdle`'s release, since the claim is already ended. The
launch check also refuses a revoked thread, which covers a start racing the revocation.
Bound: the bus refuses the user and the run from the next request on. The runner gets SIGTERM within one
launcher tick (2 s, launcher.mjs:133) of the broker seeing the loss. A model turn already in flight ends
when its runner exits. Its last bus call is refused, so it can't deliver a reply into the thread.
Removing a provider from `console.sso` is different. config.json is read at start, so that takes effect
only after a Console restart. Removing the users from access.json acts at once, so the R5 help text says to
do that first.
### 3.8 One host or two (mos-0991)
For 10-27 the hub runs on .54, VM 1012, as user `mosaic` (Q181), a host of its own (mos-0996).
Jason (mos-1013): dragon-lin is his workstation and won't host the final instances, and both cases run
as VMs on the PVE node. Jason confirmed the hosts (DECISIONS Q181, mos-1030). The Case 1 instance is on W-jarvis,
10.1.1.53 (VM 1008), and the hub, Case 2, is W-Melanie-OC, 10.1.1.54 (VM 1012). The hub's OS user is
`mosaic`, a system user with no sudo, no groups and no keys (Vikunja #421).
It is a PVE VM, and Jason accepted the factory's paths to it (Q182, Q183, section 8). The federation path
assumes nothing about sharing a host:
- Nothing crosses between the instances' buses or filesystems. Each Console talks only to its own bus
through `user-cli` or `human-cli`, spawned on its own host. access.json lives in the receiving
instance's config dir. `federation.peers` holds URLs and labels, never a path or a socket. No row reads
a peer's file or socket.
- In the 10-27 slice the two Consoles never call each other. The user's browser reaches each origin, and a
peer link is a plain link. Section 6 does need each Console's origin reachable from the other host, so
R8 checks that now and the later rows add no infra.
- Each instance has its own OIDC client with its own redirect URI on its own origin (3.3). Each Console
must reach its provider's issuer and JWKS from its own host. The issuer is a privilege path too. Whoever administers it can mint a hub user, so
the hub's issuer is one the factory can't administer (3.4, R8). The factory's Console reaches its own
Authentik, and the hub's reaches its own issuer.
- Q175 checks token expiry, and section 6 has a 60 s window on signed requests, so both hosts need a synced
clock.
The only one-host piece is R7, which is scratch on one host by design (row 190). The uid form of R8's gate
(row 190b) is a later option, outside the 10-27 gate.
## 4. Fit with the current code
- The bus stays a local socket. Federation reaches it only through the receiving instance's Console, which
is the one network-facing piece. No row opens a bus port.
- The broker's agent authority path (broker.mjs:763-799) is untouched. User checks are a separate path,
next to `#human`, because humans have no role claim and `#agent` would refuse them (broker.mjs:367-374).
- New ownership code is small but real: `#owner`, `#threadFor`, the seven verb gates, `thread.send`'s
sender fields and `threadsState` (3.6, 3.7).
- The business file keeps one `human`. Granted users sit outside it, which keeps every existing
business-file test and the organization writer unchanged.
- The config loader gains one optional key, `federation`, in the same file Sage's phase 1 changes
(mosaic-config.mjs:112-116). Row R2 chains on that candidate and repeats its blast-radius rule: the key
goes in only after a copy that accepts it is pinned.
```json
"federation": {
"self": "hub",
"peers": [{ "id": "factory", "origin": "https://mosaic.woltje.com", "label": "Software factory" }]
}
```
## 5. Dependencies on T1
- T1.1, several instances per host (mos-0988). Row 190 (Darkwing) lands several instances on one uid,
for scratch tests. R7 runs on that. Row 190b (Darkwing, Opus, design first, chained on 190) puts the hub
under its own Linux user, with no factory process able to touch it (mos-0986). Rows R1 to R7 don't need
190b: they use separate `MOSAIC_CONFIG` files and data roots, which the code already honours
(business.mjs:25-28). For 10-27, R8's gate is the host form only (mos-0996), so 190b is a later
option, not an R8 dependency. With per-uid instances,
a factory-uid process can't read or write the hub's access.json or data root, and it can't pass the
hub's human or user proof, because `verifyHuman` requires the caller's uid to equal the bus's
(human.mjs:67). Inside one instance the proofs stay same-uid cooperative (3.6, P19 333).
The design doesn't plan a single-uid live run.
- A separate host (mos-0991, mos-0996). The hub runs on its own host, which R8 checks and records (section 7,
3.8). It is a PVE VM, with the factory paths accepted by Jason (Q182, Q183, section 8).
mos-0996 measured why the other hosts fail: on dragon-lin `sudo -n true` succeeds for jwoltje, and
Melanie's server takes `ssh -o BatchMode=yes` from here with passwordless sudo behind it.
- Row 196 (#1694, Gander) owns the bus human boundary before R3 (gander-0006): one shared verifier,
`verifyPrincipal(request, connection)` returning `{kind, business, id}`, with `SO_PEERCRED` binding
`proof.pid` to the connection. R3 consumes it for the `user` kind instead of writing its own
`verifyUser`, with the same checks as 3.6 (human walk, direct parent the bus tree's Console `serve`,
own nonce variable). It runs once for each one-request connection, and `s.user` holds for that
request only, with no role or grant cached (gander-0007). R3 owns every live grant check, the
`(provider, sub)` to user mapping and the user-name policy. Row 196 kills the agent-parent,
non-Console-parent and kind-confusion mutants. R3 keeps the reserved user-name mutant.
- T1.1 live also needs two Consoles, two public origins, two reverse-proxy routes and two provider
clients, which are Jason-gated infra.
- T1.2, #412 SSO. Hard dependency. Row R6 builds on Sage's phase 1 candidate (Q177 merge-chaining) and
goes live only after the phase 2 window.
- T1.3, human users and RBAC. One user and grant model for local and federated humans (mos-0978 Q2).
Row 193 (Morgana) confirmed access.json as that store (morgana-0002, section 9 Q2). T1.3 adds `local`
users, the viewer level and `thread_shares`, and federation adds `home`. R1 is the shared loader, so row
193 builds on R1's candidate.
## 6. After 10-27: instance-to-instance
Sketch only, so the slice doesn't block it. If Jason puts agent-to-agent inside 10-27 (mos-0984 point 3),
rows for it are added after r2.
- Each instance gets an Ed25519 key in a secrets slot (`~/.config/mosaic-dev/secrets/<instance>/`, 0600,
per the Q173 slot rule). Peers pin each other's public keys in `federation.peers[].keys`. Public keys are
not secrets.
- Requests between instances are signed with HTTP Message Signatures (RFC 9421) over method, target URI,
authority, content digest, created time and a nonce. The receiving Console verifies the signature
itself, so the proof survives Traefik. A created time outside 60 s or a reused nonce refuses. No shared
secret, no bearer token.
- A peer agent becomes a grant subject of kind `peer` (`factory/researcher`), in the same access file, so
grant and revoke work the same way. Removing a peer key refuses all of that peer's traffic at once.
- First use: a factory agent sends a message into a hub thread it was granted, owned by `peer:factory`.
## 7. Rows
Owner model per Q177 and Q178. Opus 5.5 for hard design and builds, Haiku 5.5 at high effort for mid work,
Codex gpt-6.1-sol for parallel builds. Every row has its own independent review (Filbert or Dewey) and
gate. Chains follow Q177: a dependent row builds on its parent's candidate.
| Row | Work | Owner model | Depends on | Test gate |
|---|---|---|---|---|
| R1 | `packages/business/src/access.mjs`: access.json loader and validator. Providers, peers and businesses come in as arguments. One read plus the file checks | Codex Sol | none | business tests: every rule in 3.5 refuses with the key path named, symlink, non-regular file, other owner, group or other write, expiry, `*`, disjoint from the given subjects, `human`/`tester` ids, a level other than `collaborator`, a project entry that isn't a UUID. `covers`: `*` and listed ids match, an unlisted project, instance or business doesn't, an expired grant doesn't, a level filter rejects another level |
| R2 | `federation` key in scripts/mosaic-config.mjs | Haiku 5.5 high | Sage #412 phase 1 candidate | test-config.sh: valid key loads, unknown sub-key and bad origin refuse, config without it loads |
| R5 | `mosaic access list, grant, revoke`: human proof, atomic write, revision bump, receipt line. Help text states the `instances` limit and the provider-removal order | Haiku 5.5 high | R1 | cli tests: tester and agent callers refused, concurrent write refuses on revision conflict, receipt appended, bad grant refused before write |
| R3 | Broker user principal: consumes row 196's `verifyPrincipal` (`user` kind, `user-cli.mjs`), `bindUser`, the 3.6 changes, the collaborator verb list and its filters, per-request grant read, audit lines for every `s.human`/`s.tester` branch | Opus 5.5 | R1, row 196 | bus tests: each listed verb allowed, every other verb refused, a leak test per listed read (other projects, other instances, other users' threads), a reply-chain `message.send` into another user's thread refused, `thread.send` writes no `human.input`, the run's reply to a user's message reaches that user's `thread.messages`, a reply `to: 'user'` without a matching `in_reply_to` refused, a user-thread run sending `to: 'human'` refused, a reply to a user returns `waiting` null and posts no notice message, a user-thread run's uncited gated raise reaches the owner's inbox and no other inbox or role, while its `message.send` `to: 'human'` is still refused (B9, mos-1022), revoked and expired grant refused on next request, refusals recorded. Mutants (the agent-parent, non-Console-parent and kind-confusion mutants are row 196's): the grant read skipped, a read filter dropped, `#threadFor`'s user refusal removed, the reply-to check dropped (a run answers another user's message or one outside its thread), a user named like the human accepted |
| R4 | Revocation: 30 s timer, `access.reload` verb, claim end on loss, `threadsState` `revoked`, launcher skip and SIGTERM, launch check refusal, R5 calls `access.reload` | Codex Sol | R3, R5, row 193's viewer level (mos-1019) | bus and launcher tests, each with a mutant: a hand edit removing the grant ends the run within one timer period, an expiry with no request ends it, a pending message followed by a launcher restart starts no run, a running revoked thread gets SIGTERM, a grant downgraded to viewer counts as a loss, another user's runs stay. R4 starts after row 193's viewer level lands, because before it R1 refuses any other level, the whole file fails, and the downgrade test can't kill its mutant (N17, mos-1020) |
| R6 | Console: granted-user sign-in, `user-cli` transport per session, per-request grant re-read, owner-only controls hidden, a notice on the user's thread page with row 193's sentence word for word (B9, N21): "The business owner sees your thread's agent's decisions, inbox entries, claims, tasks and project files, but not your messages.", peer links | Opus 5.5 | Sage #412 phase 1, R1, R2, R3 | webui tests on Sage's fake OP: granted user signs in, ungranted `sub` refused, revoked mid-session gets 401 on next call, a granted user's owner-verb call refused by the bus even with the UI check removed (mutant), the thread page's notice equals row 193's sentence exactly (N21) |
| R7 | Scratch two-instance acceptance: two configs, two data roots, two Consoles, one fake OP. The Consoles start outside agent ancestry (a transient user unit), as the user proof requires. They never use the live stack's units or ports | Haiku 5.5 high | R1-R6, row 190 (several instances on one uid) | script test: Jason granted collaborator on one hub project signs in to the hub, makes a thread, sends to the granted instance, gets the instance's reply in the thread, is refused on another project and on board reads, is revoked and gets refused with the run ended. The factory shows nothing of the hub |
| R8 | Live window, not a code row: hub provider client, hub proxy route, access.json written by Jason, re-pins | Jason, Mos | the Case 2 install on .54 (#416), Bean and Chet live and both Hermes gateways on .54 stopped (mos-1042), #412 phase 2, R7 | Host form only for 10-27 (mos-0996): the hub runs on .54, VM 1012, as user `mosaic` (Q181). R8 is a recorded acceptance (mos-1055). It starts with Jason's acceptance of the factory paths to .54 on record, DECISIONS Q182 and Q183, quoted in section 8. Every privilege and host check below still runs, and its result goes into the R8 record with the name of whoever ran it. They report and don't refuse. R8 runs after Bean and Chet are live and both Hermes gateways on .54 have stopped (mos-1042). Case 2 go-live doesn't wait on R8. The hub is W-Melanie-OC, 10.1.1.54 (VM 1012), and its OS user is `mosaic`, with no sudo, no groups and no keys (Q181, Q183). R8 checks that on 10.1.1.54 as root and records whether each holds: `sudo -l -U mosaic` grants nothing, `id mosaic` shows only its own group, `~mosaic/.ssh/authorized_keys` is absent or empty, and no file under `/etc/sudoers.d` names it (N22). An agent or Jason runs these as localadmin (mos-1055), and the output goes into the R8 record with who ran them. The privilege checks run on every factory host in section 8's list (the dragon-lin dev uid, and W-jarvis at 10.1.1.53, which is the Case 1 VM with its agents and the Hermes agents), as each factory uid there under `env -i`, and each is recorded as passing only when the call fails: `sudo -n true`, `doas -n true`, and `ssh -o BatchMode=yes 10.1.1.54 true` as the hub user `mosaic`, as Jason's user and as root, each with and without the session's `SSH_AUTH_SOCK`. If `sudo -n` succeeds, as it does for jwoltje on dragon-lin (mos-0996 point 1), the factory counts as root on its own host. Then, as root through `sudo -n`: fingerprint every private key under every home and `/root`, and every key loaded in any agent socket found on the host (`ssh-add -l` per socket). Compare them with `ssh-keygen -lf` over every `authorized_keys` on the hub host, root's included, and no fingerprint matches. Run the ssh checks as root, once with no agent socket and once with each socket found, and record each result (N13). Every other privilege path is a named item in section 8's table (mos-1009): hypervisor, backups, sign-in provider, TLS front end, certificate authority, DNS and network. The record marks each one closed, with its check passing, or accepted under Q182 and Q183 with the table's wording. That wording goes to Melanie through Jason (#419), never to her directly. R8 also records whether the hub host's bus socket, data root and config dir don't exist on any factory host, no factory host mounts anything of the hub's, and whether the hub host runs no other instance or agent. These must work, or R8 stops and Mos takes it to Jason, because federation fails without them: the two provider client ids differ, each origin answers over HTTPS from the other host, each Console reaches its issuer's discovery URL, and both clocks are synced. The record holds Jason's acceptance and the check results side by side, so a passing check is a measured fact and an accepted path is named as accepted. The uid form (row 190b) is a later option, outside this gate. Then Jason signs in to the hub from his account and sends one message, Melanie revokes, and his next request is refused |
R1 can start now. R5 can be briefed now against R1's exported interface and lands after R1. R2 starts
when Sage's #412 phase 1 candidate exists. R3 starts when R1 has a candidate, R4 when R3, R5 and row 193's viewer level do (mos-1019).
R6 waits on Sage's phase 1 candidate.
## 8. Risk to 10-27
The slice lands only if three things hold. #412 phase 1 has a reviewed candidate by about 10-23. P19 333,
the path-bound human proof R3 depends on, is settled in time for R3 (Mos told Sage, mos-0988). The Case 2
install on .54 (#416) is done in time for R8. Jason's answer on the factory paths is Q182 and Q183. R6 and R7 chain on #412 phase 1. R8 needs all three, plus
two Jason-gated windows (#412 phase 2 and the federation re-pin), plus infra work only Jason does: a
second public origin, a proxy route and a provider client. A failed privilege check
doesn't stop R8. It goes into the record, and Mos takes it to Jason.
The hypervisor path (Vikunja #419, mos-1004), accepted by Jason under Q182 and Q183. Factory agents hold root
ssh to the PVE nodes (Q172). Root on a node can read a running VM's memory and disk, open its console,
and change its config or boot. So it defeats the host form completely when the hub is a cluster VM
(duckworth-0004). LUKS inside the VM doesn't close it alone: the key sits in the running VM's memory,
which the node can read, and a node that can change the boot can capture the passphrase. Options:
- O1. The hub runs on hardware outside the PVE cluster, where the factory has no root.
- O2. A cluster VM, with factory root ssh removed on every node, no factory PVE token with console,
monitor, config, backup or snapshot rights on the VM, VM storage the factory can't read, and PBS
backups the factory can't read or that are encrypted with a key it doesn't hold.
- O3. Jason accepts it as a residual. Then this section and R8 say plainly that the host form doesn't
stop a factory agent using PVE root, and Melanie is told.
Jason's answer is O3, accepted (DECISIONS Q183 confirms Q4). His lines, verbatim:
Q182 (DECISIONS.md:1514-1519, jason-master-restart):
> "I am not pursuing a tightening of key and agent security on my workstation. I need my agents to be able to get onto the other workstations and servers."
> "We will eventually have my agents running on my VM on PVE, but currently I accept the risk of agents running on my workstation."
Q183 (DECISIONS.md:1535-1541):
> "Security tightening on .53 and .54 is fine. I will still want to allow infra agent(s) on my .53 instance to access other servers, etc. However, not all agents will need to have that access."
> "Q4 appears to properly reflect my statements."
Q4, as Mos asked it and Jason confirmed it:
> "The #421 plan stops. Your key stays, and agents keep root on pve and PBS and ssh to .53 and .54. Each VM still gets a mosaic user without sudo to run its instance. The federation gate records your acceptance instead of refusing to start."
So this section and R8 say it plainly: the host form doesn't stop a factory agent using PVE root, and
R8 records that as accepted. Melanie is told through Jason (#419).
### R8 privilege paths (mos-1009, duckworth-0005, Vikunja #419)
Here and in R8, "factory" means every principal outside the hub instance (mos-1013), and "factory
host" means each of their hosts:
- the dragon-lin dev uid, on Jason's workstation
- W-jarvis, 10.1.1.53 (VM 1008): the Case 1 instance's VM and its agents, and Jason's Hermes agents.
ops-01 reports the `jarvis@w-jarvis` key has root on `pve`.
Case 1 and the Hermes agents share W-jarvis, so they are one factory host (Q181, mos-1030). The hub is
W-Melanie-OC, 10.1.1.54 (VM 1012). Every check in the
table runs on each factory host, and a path is closed only when it passes on all of them.
ops-01 measured the estate read-only at 19:10Z (relayed in mos-1009). There is one PVE node, `pve`,
standalone, and its root `authorized_keys` holds the factory's key (dragon-lin's, and per mos-1013
the `jarvis@w-jarvis` key), so the factory has root on the
hypervisor without any API token. The only PBS is VM 1021 on that node, the factory has root on it, and
its backups are unencrypted. The LAN is flat (10.1.0.0/16) with dragon-lin on it and no internal VLAN.
The factory has used the jarvis Authentik token for admin changes. No hardware outside PVE without
factory root has been confirmed. So today the factory administers every path below.
Each path is a record item. Its check runs and the result is recorded. A path is closed when its check
passes. Otherwise it is accepted under Q182 and Q183, and its wording goes into the R8 record and to
Melanie through Jason (#419). Every check runs from the factory
host, as root through `sudo -n` where the factory has it, and passes only when the call fails.
| Path | Closed: the check | Accepted: the wording | Hub-local option |
|---|---|---|---|
| Host access | The R8 sudo, doas, ssh and key checks (section 7, N13) | "Factory agents can log in to the hub host." | none needed, the checks can pass today |
| Hypervisor | O1 or O2 above: no factory key in any PVE node's root `authorized_keys`, and a PVE API call on the hub VM fails with each token the factory holds | "Factory agents have root on the machine the hub runs on and can read its memory and disk." | O1, hardware outside PVE |
| Backups | The hub's backups are encrypted with a key only Jason or Melanie holds: the PBS datastore shows the hub's snapshots encrypted, and the key's fingerprint matches no file on the factory host | "Factory agents can read the hub's backups in full." | client-side encrypted backups, key held by Jason or Melanie |
| Sign-in provider (B8, mos-1006) | The hub's `console.sso` trusts only an issuer no factory credential administers, never a brand, tenant or application in the factory's Authentik. An admin API call on it fails with each identity provider token found on the factory host, and the issuer's list of admin tokens and service accounts (names only, never values) shows none held on the factory. An issuer that isn't on the hub host passes the host-access, hypervisor and backups rows itself, run against its own host, and a hosted issuer's admin accounts each pass the failed-call check (duckworth-0007) | "Anyone with admin on the factory's sign-in provider can sign in to the hub as any user, Melanie included." | an issuer on the hub host, such as Pocket ID (Q175 supports several providers) |
| TLS front end (B8) | TLS for the hub's origin ends on the hub host: the certificate the origin serves, fetched both through the public route and from the LAN (N19), matches the key on the hub host, and no factory proxy, tunnel or overlay route names the hub's origin | "The factory's proxy sees every hub sign-in, cookie and message in clear." | TLS ending on the hub host |
| Certificate authority (mos-1009) | The hub's certificate comes from a public CA through an ACME account whose key is only on the hub host, and the zone's CAA record names only that CA | "The factory can get a valid certificate for the hub's name." | ACME run on the hub host |
| DNS (N15) | A zone edit for the hub's name fails with each DNS credential found on the factory host, and the zone's owner account is one the factory can't sign in to | "The factory can point the hub's name at a host it runs." | a zone or registrar account only Jason or Melanie holds |
| Network | From the factory host, the hub host answers only on the ports R8 needs (HTTPS, and ssh where it is used), and nothing else is open to the LAN | "The hub host sits on the same flat LAN as the factory, and only its logins protect it." | a host firewall on the hub, a separate VLAN |
Some paths depend on others. While the hypervisor row is open, every secret on a PVE guest is exposed to
node root: the TLS key, the ACME account key, an issuer's database and signing key on a guest, and the
backup key whenever a restore loads it (N16). So closing those rows doesn't help until the hypervisor
row is closed too, or they sit on hardware outside PVE. The R8 record names every path that stays accepted.
## 9. Rulings (mos-0978, mos-0984, mos-0991, mos-0996, mos-1004, mos-1006, mos-1009, mos-1013)
1. Q1: human cross-access is the 10-27 slice (mos-0978). Duckworth's N8 put agent-to-agent's timing to
Jason through Mos. Jason answered "P2P federation is acceptable for 10-27" (mos-0991), and then "P2P is
person-to-person in this case" (DECISIONS Q180, mos-1013). The slice is human access only, with no
agent-to-agent rows.
2. Q2, settled by row 193 (morgana-0002, to Sage too): access.json is the one user and grant store for
humans. Row 193 adds `home: "local"` users, uses the grant level as the human role, and keeps viewer and
collaborator. The owner stays outside the file. Thread shares go in a new append-only bus table,
`thread_shares`, not in access.json, and a share counts only while the named user's live grant covers
the thread's project and instance. The 10-27 slice ships collaborator only (W2), so when row 193 adds
viewer, R1's level check widens with it. `thread_shares` is append-only, so an unshare is an end row in
row 193's design. R3 assumes nothing about shares (N12).
3. Q3 and B5: R8 never runs on a single uid (mos-0984 point 2). For 10-27 its isolation is the hub's own host,
.54 (Q181), the host form only (mos-0996). The uid form (row 190b) is a later option. Jason accepted
the factory paths, the hypervisor's included (Q182, Q183), so R8 records them (section 8).
## 10. Notes
- The r1 push used the repo-local credential helper `scripts/git-credential-mosaic.mjs` (set in the
canonical checkout's .git/config for git.mosaicstack.dev) with its default credential file,
`~/secrets/mosaic.gitea.json`, and no `MOSAIC_GITEA_CREDENTIAL_FILE` override. That file's user is
jason.woltje. The r2 push uses the same. Gizmoduck has no seat identity until T1 row 195 adds per-agent
git identity.
+321 -95
View File
@@ -1,8 +1,42 @@
# Q175 design: the Console verifies SSO itself (Vikunja #412)
Author Sage, 2026-10-20. Reviewer Filbert, and the review comes before any code. Line numbers are at refactor
4ca8d6e94. This replaces Part B (row 178 proxy proof, #385 comments 345 and 353). #385 stays open until the
Console stops trusting `X-authentik-username`.
Author Sage, 2026-10-20, r3. Reviewer Filbert, and the review comes before any code. Line numbers are at
refactor 4ca8d6e94 unless a line says otherwise. This replaces Part B (row 178 proxy proof, #385 comments 345
and 353). #385 stays open until the Console stops trusting `X-authentik-username`.
## Changes from r2 (Filbert's r2 review, CHANGES)
- C1: the `console` key goes into the shared loader, scripts/mosaic-config.mjs, which today refuses any
unknown top-level key (:113). Section 4 names the blast radius. Jason step 4 moves inside the phase 2
window, after the pin and before the Console starts. The phase 2 rollback removes `console` first. A
test-config.sh case covers it (test 25). Mos confirms the sequence before phase 1 (section 5).
- C2: under `--sso` the page treats a 401 `sign-in required` as signed out and links to
`/auth/login?return=<path>` (section 5 phase 1, test 24).
- Notes: tests 7 and 15 are built so that only the record rule refuses; test 9a is an alg test; Q-B's two
classes are defined, and test 23 adds a 200 HTML page; the phase 2 scratch Console uses a scratch config,
a dummy slot, no `--send` and no `--tester-port`.
- r3 fix (Filbert r3 approve): D1, the loader checks the slot reference's shape only, and the file checks
run in the Console's `--sso` start; D2, the slot resolves from the config dir, and the scratch Console has
a scratch dataRoot and an unreachable issuer (section 5).
- Q-E: settled as Filbert ruled. The `subject-unknown` line keeps iss and sub, JSON-escaped and capped
(section 1, tests 10 and 19).
## Changes from r1 (Filbert's r1 review, CHANGES)
- B1: the provider config moves to `~/.config/mosaic-dev/config.json` (section 4). Mos agreed (mos-0973). The
Console prints provider ids, issuers and subject counts at start. The rehearse-strip follow-up is dropped.
- B2: one return-path rule, `new URL(ret, publicOrigin).origin === publicOrigin` (section 1 step 7), with
tests for `/\evil.example` and `/%09/evil.example`.
- B3: refusals log a fixed code and the provider id, never the error or its cause (section 1).
- B4: login records are capped at 32 (section 1 step 1), with a test.
- B5: test 9 splits into a forged payload under a real kid and a kid missing from the JWKS, and a try/catch
mutant joins the list. Test 2 no longer claims the signature mutant (section 9).
- B6: the phase 2 forged-header check runs on a scratch Console, or Mos or Jason runs it on live (section 5).
- Rulings: Q-B start degraded (section 4), Q-C only the public Host on 7332 (section 3), Q-D keep 1 h idle
but polls don't count as activity (section 3).
- Section 6: `startServer` throws on `tester && sso`, and `human` joins `tester` as a refused subject label.
- The missing tests and nits N1-N5 are in sections 1, 3, 7 and 9.
- Section 8: the human-cli statement stays as written (mos-0972). The recommendation is now Mos's ruling.
## The ruling this implements
@@ -22,7 +56,9 @@ trust with verification" and confirmed five points (mos-0970):
## What exists today
The live Console runs from copies/490e2771d3d0 with `--public-host mosaic.woltje.com --auth-header
X-authentik-username --allow-user jason.woltje --send --tester-port 7333`.
X-authentik-username --allow-user jason.woltje --send --tester-port 7333`. Row 189 (#1687, P19 #413) adds
`stack-release.sh activate --no-send`, and Mos's ruling mos-0972 takes `--send` off the live Console until
phase 2.
- `previewOptions` (packages/webui/src/serve.mjs:205) builds `{ host, header, user }`.
- A request is `named` when its Host equals the public name (:493). A named request passes sign-in when it
@@ -45,41 +81,73 @@ New module `packages/webui/src/sso.mjs`, used only by the human listener (7332).
| Route | Method | What it does |
|---|---|---|
| `/auth/login` | GET | One provider configured: start its flow. Several: a plain page listing them, each a link to `/auth/login?provider=ID`. |
| `/auth/login` | GET | One provider configured: start its flow. Several: a plain page listing them, each a link to `/auth/login?provider=ID`. A provider whose discovery hasn't succeeded gets 503 (section 4). |
| `/auth/callback` | GET | Finish the flow, create the session, 303 to the saved return path. |
| `/auth/logout` | POST | Same-origin checks, delete the session, 303 to the provider's `end_session_endpoint`. |
| `/auth/signed-out` | GET | Static "signed out" page with a sign-in link. |
Every URL the Console hands a provider is built from the configured `publicOrigin`: `redirect_uri` is
`publicOrigin + '/auth/callback'` and `post_logout_redirect_uri` is `publicOrigin + '/auth/signed-out'`. No
code path reads the request's Host to build them (N2).
The flow per provider:
1. Start. Generate a state, a nonce and a PKCE verifier (`generateRandomState`, `generateRandomNonce`,
`generateRandomCodeVerifier`), and a challenge with `calculatePKCECodeChallenge` (S256 only). Put them, the
provider id and the return path in a server-side login record keyed by a random id. Set
`__Host-mosaic_login` to that id. 303 to `authorization_endpoint` with `response_type=code`,
`scope=openid profile email`, `code_challenge_method=S256`, `redirect_uri`, `state` and `nonce`.
provider id, the return path and an expiry 10 minutes out in a server-side login record keyed by a random
id. The store holds at most 32 login records; a new one evicts the oldest (B4). Set `__Host-mosaic_login` to
that id. 303 to `authorization_endpoint` with `response_type=code`, `scope=openid profile email`,
`code_challenge_method=S256`, `redirect_uri`, `state` and `nonce`.
2. Callback. Read exactly one `__Host-mosaic_login` cookie, then take and delete its record (single use, so a
replayed callback finds nothing). Expire the cookie in the response. The provider comes from the record,
never from the query, which stops a mix-up between providers.
never from the query, which stops a mix-up between providers. Two tabs signing in at once each set the
cookie, so the later one wins and the earlier tab's callback finds no record and gets the 403 page with a
sign-in link (N5). That costs one retry and keeps one cookie per browser.
3. `validateAuthResponse(as, client, url, record.state)` checks state, refuses an error response, and checks
the `iss` parameter when the provider advertises RFC 9207.
4. `authorizationCodeGrantRequest(as, client, clientAuth, params, redirectUri, record.verifier)`, then
`processAuthorizationCodeResponse(as, client, response, { expectedNonce: record.nonce, requireIdToken: true })`.
That checks iss, aud (and azp when aud has several values), exp, iat and nonce, and checks `alg` against
`client.id_token_signed_response_alg`.
4. `authorizationCodeGrantRequest(as, client, clientAuth, params, redirectUri, record.verifier)` returns a
`Response`. Keep that object. `processAuthorizationCodeResponse(as, client, response, { expectedNonce:
record.nonce, requireIdToken: true })` checks iss, aud (and azp when aud has several values), exp, iat and
nonce, and checks `alg` against `client.id_token_signed_response_alg`. That field gets the provider's whole
`algs` array, which the library's `checkSigningAlgorithm` accepts, so a provider listing RS256 and ES256
takes either and nothing else (N1). Test 16 pins this.
5. Signature. oauth4webapi does not verify the signature of an ID token that came straight from the token
endpoint over TLS (OIDC Core 3.1.3.7 allows that, and the library docs for
`validateApplicationLevelSignature` say so). Point 2 asks for the JWKS check, so the callback always calls
`validateApplicationLevelSignature(as, response)` after step 4. It fetches the JWKS from the discovered
`jwks_uri`, picks the key by `kid` and `alg`, and refuses HS* outright.
6. Our own checks on top of the library: `header.alg` is in the provider's configured `algs`, the token's
`iss` equals the configured issuer exactly, and `(iss, sub)` is in the provider's `subjects` list. Anything
else is a 403 that logs `refused sign-in iss=... sub=...` (never a token, code or cookie).
7. Create the session (section 3) and 303 to the return path. The return path must start with one `/` and
is otherwise replaced with `/`.
`validateApplicationLevelSignature(as, response)` with the same `Response` object from step 4. It fetches
the JWKS from the discovered `jwks_uri`, picks the key by `kty`, `kid`, `alg` and `use`, and refuses HS*
outright. On a `kid` it doesn't have, it refetches the JWKS once if the cache is at least 60 s old, which
covers key rotation (test 18). The call is not wrapped in a catch that continues; its throw ends the
callback.
6. Our own checks on top of the library: `header.alg` is in the provider's `algs`, the token's `iss` equals
the configured issuer as a raw string compare, with no URL normalising (N3; the library's discovery check
compares normalised hrefs, so this one is stricter), and `(iss, sub)` is in the provider's `subjects` list.
7. Create the session (section 3), then 303 to the return path. The rule (B2): resolve
`u = new URL(ret, publicOrigin)`, and use `u.pathname + u.search` only when
`u.origin === publicOrigin`; otherwise use `/`. A missing or unparsable `ret` gives `/`.
Every refusal in steps 2-6 ends at a 403 page saying sign-in failed, with a link back to `/auth/login`. No
step falls back to a header.
Logging (B3). A refusal logs one line: a fixed code from a closed list (`login-record-missing`,
`state-mismatch`, `auth-error-response`, `grant-failed`, `id-token-invalid`, `signature-invalid`,
`alg-refused`, `issuer-mismatch`, `subject-unknown`, `discovery-failed`, `jwks-failed`) and the provider id.
It never logs the exception, its message or its `cause`. oauth4webapi's errors carry token bodies and claims
in `cause`, so even a message can leak. The one extra field is on `subject-unknown`: it logs `iss` and `sub`,
because Jason step 3 reads his `sub` from that line (Q-E, Filbert's ruling). Authentik's default subject mode
is a hashed user id that its admin view doesn't show, so the line is the practical source. Neither value is a
credential. Three conditions hold:
- Only a token that passed steps 3-6 up to the subject check reaches this line. Every earlier refusal logs
its own code and the provider id only.
- Both values are written with `JSON.stringify` and each is cut to 255 characters first. Any account at the
provider can get a signed token, so a `sub` may hold a newline, and an unescaped one would let a sign-in
forge a log line.
- Test 10 uses a `sub` holding `\n` and a fake `refused sign-in` suffix and asserts one escaped line.
Test 19 asserts that no log line holds a token, code, state, nonce, verifier, client secret or session id.
It exempts only the `iss` and `sub` fields of the `subject-unknown` line.
### 2. Library: oauth4webapi 3.8.8, pinned exactly
Mos's steer (mos-0971) was to prefer oauth4webapi over hand-rolled verification on `node:crypto` unless I
@@ -103,34 +171,78 @@ entry. Root is right because `scripts/stack-copy.sh:23-31` copies only the root
webui's first runtime dependency. The root dependency rule (exact pins, never unversioned) applies to it.
Upgrades are reviewed commits.
### 3. Sessions, cookies and logout
### 3. Sessions, cookies, Host and logout
- Session id: 32 random bytes, base64url. The store keys on its sha256, so a heap dump doesn't hold usable ids.
- Cookie `__Host-mosaic_session=<id>; Secure; HttpOnly; SameSite=Lax; Path=/`, no Domain, no Max-Age (a
browser-session cookie). The `__Host-` prefix makes the browser refuse it unless it was set over https on
this exact host.
- Login cookie `__Host-mosaic_login`, same attributes plus `Max-Age=600`. The login record also expires at
10 minutes server-side.
- Session id: 32 random bytes, base64url. The store keys on its sha256 and holds no raw id, so a heap dump
doesn't hold usable ids. Test 20 checks that the store's keys and values never contain the cookie value.
- Cookie, exactly: `__Host-mosaic_session=<id>; Path=/; Secure; HttpOnly; SameSite=Lax`, no Domain, no
Max-Age (a browser-session cookie). The `__Host-` prefix makes the browser refuse it unless it was set over
https on this exact host. Test 21 asserts the whole Set-Cookie value, and a mutant drops `HttpOnly`.
- Login cookie `__Host-mosaic_login`, the same attributes plus `Max-Age=600`. The login record also expires
at 10 minutes server-side.
- Lax, not Strict, because the callback is a top-level navigation from the provider's site, and a Strict
cookie set there would be missing on the redirect to `/`. Lax still keeps the session off cross-site POSTs.
The write routes keep their exact `Origin` and `Sec-Fetch-Site: same-origin` checks (:171, :250, :297, :340,
:387, :425), so cross-site writes are refused twice.
- Lifetime: 8 h absolute, 1 h idle, both server-side. The store is in memory with a cap of 64 sessions
(oldest goes first), so a Console restart signs everyone out. That's acceptable for one human and keeps
sessions off disk.
- Lifetime: 8 h absolute, 1 h idle, both server-side (Q-D, Filbert's ruling). The store is in memory with a
cap of 64 sessions (oldest goes first), so a Console restart signs everyone out. That's acceptable for one
human and keeps sessions off disk.
- Activity (Q-D). Only a page load (a GET outside `/api/`) or a write (a POST) refreshes `seen`. The page's
background reads don't: `refresh` re-reads the board every 10 s (packages/webui/src/public/app.js:346) and
the 5 s timer at :301 redraws the age label. Without this rule an open tab would never go idle. Test 13
covers it.
- The session holds `{ provider, iss, sub, name, idToken, created, seen }`. `name` is the subject entry's
label. It goes where `preview.user` goes today (send logs, settings, seat routes), so the downstream writer
shape stays as it is. `idToken` is kept only as the logout hint and never leaves the server.
- Host (Q-C, Filbert's ruling). Under `--sso`, 7332 serves only requests whose Host is the public host.
Any other Host, loopback included, gets 403 `public Host required`, except `GET /healthz`. The loopback read
path at :509-512 is gone under `--sso`. Today's readers of 127.0.0.1:7332 and where they go:
- `scripts/webui-service.sh status` (:406), the stack-release gate (scripts/stack-release.sh:484) and the
rehearse gate (:1369) read only `/healthz`, which stays open on loopback.
- The webui README (:83) describes the loopback path. It changes in phase 1.
- Agent reads of the board go to the tester listener on 7333, which already serves reads. I found no other
code that reads 7332 on loopback. A person opening http://127.0.0.1:7332 in a local browser gets the 403
and uses the public URL instead.
Test 22 covers a loopback Host on `/`, `/api/board` and a write, and `/healthz` passing.
- Every request on 7332 except `/healthz` and `/auth/*` needs a valid session. Without one, `/api/*` gets
401 JSON and other GETs get a 303 to `/auth/login?return=<path>`.
- Logout: `POST /auth/logout` with the same-origin checks. It deletes the session, expires the cookie, and
303s to `end_session_endpoint?id_token_hint=...&post_logout_redirect_uri=https://mosaic.woltje.com/auth/signed-out&client_id=...`.
303s to `end_session_endpoint?id_token_hint=...&post_logout_redirect_uri=<publicOrigin>/auth/signed-out&client_id=...`.
If the provider has no `end_session_endpoint`, it goes straight to `/auth/signed-out`.
- Limit: signing out at the provider does not end a Console session until idle or absolute expiry. Back-channel
logout is a follow-up, not phase 1.
### 4. Provider config: shape and location
Location (B1, Filbert's ruling, Mos agreed in mos-0973): a `console.sso` key in
`~/.config/mosaic-dev/config.json`. The reason is who can write it. The business file is writable by agents
through `organizationWriter` (the tester verbs and the chief's `organization.set`), so a provider entry there
could be changed by the agents the Console is meant to keep out. config.json is user-authored and never
auto-written: `scripts/bootstrap.sh` creates it and nothing overwrites it. Adding the key is a Jason step
(section 10).
The loader (C1). Every tool reads config.json through one loader, scripts/mosaic-config.mjs. Its `validate`
refuses any top-level key other than `configVersion`, `environment`, `dataRoot` and `execution` with
`unsupported configuration key` (:113). The business, seat, cli, discord, bootstrap, land-work, stack-copy and
conductor-apply code all use it. So phase 1 changes the shared loader, not a Console-only reader:
- mosaic-config.mjs accepts an optional `console` key, and the strict `console.sso` shape validator (the
config checks below, except the slot file checks) runs inside the loader. The fail-closed canon holds for
every reader. The loader checks shape only and never touches a slot file: `clientSecret` must be
`{ slot: <name> }` with the name matching `[a-z0-9-]{1,64}`. The file checks and the read run only in the
Console's `--sso` start (D1), so a missing slot or a 0640 slot stops the Console, not the stack.
- The blast radius is the whole stack. A typo in `console.sso` makes every tool refuse the config, not just
the Console. That's the price of one loader, and the reason step 4 writes the key inside a window with a
backup first.
- The other direction breaks too. A copy older than phase 1 refuses a config that has the key. If the key
went in before the phase 2 pin, the live copy (490e2771d3d0 or later) would refuse its own config on the
next read, and the bus, seats and host would stop. After phase 2, a rollback to a pre-phase-1 copy fails
the same way until the key is removed. This is the same trap as the plug-in kind before a rollback.
- Files without the key still load, on old and new copies.
Test 25 in scripts/test-config.sh: a config with a valid `console.sso` loads, a malformed one fails with
the bad key path named, and a config without `console` loads.
No secret value goes in this file. The client secret is a slot reference.
```json
@@ -151,38 +263,54 @@ No secret value goes in this file. The client secret is a slot reference.
}
```
Location: a new optional `console` key in the business file (`~/.config/mosaic-dev/businesses/<id>.json`). The
Console already serves one business (`--business`), the identities map to that business's human, and Q173
lets agents edit the test business with a backup first. Adding the key means adding `console` to `TOP_KEYS`
(packages/business/src/business.mjs:21) with a strict validator. It's additive, and existing files without
it still load. The alternative is `~/.config/mosaic-dev/config.json`, which is user-authored and never
auto-written. That fits a deployment setting well, but it puts per-business identity in system config.
**Filbert, rule on this one.**
At start, `--sso` prints one line per provider: its id, its issuer and the number of subjects. No client id
value beyond that, no secret, no `sub`.
The slot is `~/.config/mosaic-dev/secrets/mosaic-stack/<slot>`, read at start. The Console refuses to start if
the file isn't a regular file, isn't owned by the Console's uid, has any group or other mode bits, or
resolves under the repo or a stack copy. The value is never logged or put in a refusal message.
The slot is `<configDir>/secrets/mosaic-stack/<slot>`, where `configDir` is the directory of the config file
the loader read (`MOSAIC_CONFIG`, default `~/.config/mosaic-dev`), so a scratch config gets a scratch slot
(D2). The Console's `--sso` start reads it. The Console refuses to start if
the file is a symlink, isn't a regular file, isn't owned by the Console's uid, has any group or other mode
bits (0640 refuses as well as 0644), or resolves under the repo or a stack copy. The value is never logged or
put in a refusal message.
`stack-release.sh rehearse` copies a backup onto a scratch root. A rehearsed Console must not use the live
redirect URI, so rehearse strips `console.sso` from the scratch business file and the rehearsed Console runs
without SSO (tester listener only). That's a row 163 follow-up, named in the build brief.
`stack-release.sh rehearse` starts its Console with `serve --port N --business B` and no `--sso`, so a
rehearsed Console never reads `console.sso` and never uses the live redirect URI. No rehearse change is
needed; r1's follow-up is dropped.
Startup checks for each provider, all fail-closed (the Console doesn't start, and the message names the
provider and the check):
Config checks, run at start without the network. Any failure refuses the start, and the message names the
provider and the check:
- `issuer` is https with no query or fragment. Discovery returns that issuer exactly.
- `id_token_signing_alg_values_supported` shares at least one alg with `algs`, and `algs` holds only
asymmetric algs from the library's list. mosaic-preview advertises HS256 only, so the Console refuses it at
start.
- `publicOrigin` is an https origin with no path, query or fragment.
- Provider ids are unique and `[a-z0-9-]{1,32}`.
- `issuer` is https with no query or fragment.
- `algs` is non-empty and holds only asymmetric algs from the library's list. `HS256`, any `HS*` and `none`
refuse.
- `subjects` is non-empty, each `sub` is 1..255 visible characters, and no `name` is `tester` or `human`.
- `clientSecret` is `{ slot: <name> }` with the name matching `[a-z0-9-]{1,64}` (shape only, in the loader).
- In the Console's `--sso` start only, the slot file passes the file checks above.
Discovery checks, run per provider when its discovery succeeds:
- Discovery returns `issuer` exactly.
- `id_token_signing_alg_values_supported` shares at least one alg with `algs`. mosaic-preview advertises HS256
only, so it fails here.
- `code_challenge_methods_supported` includes `S256`.
- `jwks_uri` returns at least one key usable for one of `algs`. The empty mosaic-preview JWKS fails here too.
- `subjects` is non-empty, and each `sub` is 1..255 visible characters.
- `client.id_token_signed_response_alg` is set to `algs[0]`, so the library enforces it per token.
- Provider ids are unique and `[a-z0-9-]{1,32}`.
Starting while the provider is unreachable refuses the start. A Console with no working sign-in serves
nothing, so refusing loudly costs nothing, and the unit restarts it. The tradeoff is that an IdP outage
keeps the Console down until the IdP is back. With every route behind a session, it would be unusable anyway.
Unreachable provider (Q-B, Filbert's ruling). Config errors still refuse the start. Each fetch of the
discovery document or the JWKS lands in one of two classes:
- Unreachable: a network error, a timeout, a status other than 200, or a body that doesn't parse as JSON. An
Authentik maintenance page served as 200 HTML is unreachable, not refused, so it can't block a restart.
- Refused: a JSON document that fails the issuer, alg, S256 or JWKS check.
An unreachable provider doesn't stop the Console. It starts, that provider's `/auth/login` gets 503
`sign-in unavailable`, and a retry runs with backoff (5 s, doubling, capped at 5 minutes). `/healthz` stays
200 and adds `sso: { <id>: "ready" | "unreachable" | "refused" }`, so the gates still pass and an operator
can see the state. A refused provider at start refuses the start, because it's a configuration problem. On a
later retry it stays disabled with the `discovery-failed` code until a restart. Sessions already made keep
working while a provider is unreachable, since a session check needs no network. Test 23 covers the degraded
start, a 200 HTML page, and the recovery.
Several providers: Pocket ID, WorkOS and other compliant OPs use the same entry shape. The login page lists
them. Each entry has its own issuer, client, algs and subjects, and a session records which provider made it.
@@ -190,13 +318,48 @@ them. Each entry has its own issuer, client, algs and subjects, and a session re
### 5. Retiring `--auth-header`, `--allow-user` and the proof header
1. **Phase 1 (code row).** Add `--sso` to `cli.mjs serve` and `webui-service.sh`. It reads `console.sso` from
the business file. `--sso` cannot be combined with `--auth-header`, `--allow-user` or `--proof-*`. Under
config.json. `--sso` cannot be combined with `--auth-header`, `--allow-user` or `--proof-*`. Under
`--sso`, `named` means "has a session", and `writer` comes from the session. `--send` needs `--sso` or the
old preview set. Nothing live changes until the re-pin.
old preview set. Phase 1 also carries the loader change and its test-config.sh case (section 4, test 25).
Nothing live changes until the re-pin.
The page (C2). Today app.js signs out only on `res.type === 'opaqueredirect'` (packages/webui/src/public/
app.js:111 and :305), which is the outpost's redirect. Under `--sso` the Console answers an `/api/*`
request without a session with 401 `sign-in required`, so the page would show an error on every 10 s
refresh and never offer a way back. Phase 1 makes both fetch paths treat a 401 whose body has
`error: 'sign-in required'` as signed out, and the signed-out notice links to
`/auth/login?return=<current path and query>`. Other 401s stay errors. The opaqueredirect branch stays
until phase 3, because the preview flags still run behind the outpost until then. Test 24 covers it.
Before phase 1 starts, Mos confirms the config.json sequence in phase 2 (C1).
2. **Phase 2 (Jason-gated window).** Re-pin to the phase 1 commit with `--sso --send --tester-port 7333`
instead of the preview flags, after the Jason steps below. Backup first, STATE after (Q173). Acceptance:
Jason signs in through the Console flow, sends one message, signs out, and a forged-header request with no
cookie gets 401.
instead of the preview flags, after Jason steps 1 to 3. Backup first, STATE after (Q173). The order
inside the window:
1. Back up the store, the pointer, the unit and config.json.
2. Pin the phase 1 copy with the Console stopped. The copy's loader accepts a config with or without
`console`, so the bus and seats keep reading the old config.
3. Straggler check (Mos's C1 condition, mos-0977): list every running process, unit and timer that
still runs from the old copy and reads config.json. Each one is restarted on the new copy or shown
not to re-read config. The old loader refuses the key, so a straggler would fail on its next read.
Step 4 waits until the list is clean.
4. Jason step 4: the `console.sso` key goes into config.json, written by Jason or by an agent on his go
for that one key. The new loader reads it back (`scripts/mosaic-config.mjs` validate) before anything
starts.
5. Start the Console with `--sso --send --tester-port 7333`.
Acceptance: Jason signs in through the Console flow, sends one message and signs out. The forged-header
check (a request with `X-authentik-username` and no cookie gets 401) runs either on a scratch Console or
on live by Mos or Jason (B6). No agent sends the auth header to the live 7332 (section 8). The scratch
Console runs from the same copy as `serve --sso --port <scratch port>`, with `MOSAIC_CONFIG` pointing at a
scratch config in a scratch dir. That config has a scratch `dataRoot`, and any business it serves comes
from the scratch config dir; neither points at live. Its dummy client secret sits in the scratch slot
(`<scratch configDir>/secrets/mosaic-stack/<slot>`). Its issuer is an unreachable loopback host, not
Authentik, so the scratch Console sends nothing outbound (D2, mos-0971). It comes up in the degraded
state (Q-B), and the 401 check needs no sign-in. It has no `--send` and no `--tester-port`.
Rollback for phase 2: remove the `console` key from config.json first (restore the step 1 backup), then
roll the pointer back. An older copy's loader refuses a config holding the key (section 4), so the other
order stops the bus and seats. The window plan lists that order, and the STATE check after a rollback
reads config.json for the key's absence.
3. **Phase 3 (code row).** Remove `previewOptions`, `proofOptions` and their tests. `cli.mjs` and
`webui-service.sh` refuse the old flags with `--auth-header was retired by Q175; use --sso`. Close #385
with the phase 2 STATE and the phase 3 commit.
@@ -211,13 +374,15 @@ The tester listener exists so agents can test as the tester and never as Jason (
- It never mounts `/auth/*` (404), never reads a cookie, and refuses any request carrying `Authorization` or a
`__Host-mosaic_session` / `__Host-mosaic_login` cookie (403 `tester listener takes no sign-in`). The same
goes for `X-authentik-jwt` and the old auth and proof header names.
- `startServer` throws when its options hold both `tester` and `sso`, so the SSO module can't be mounted on
the tester listener by a wiring mistake. A unit test calls it with both.
- Its writer stays the fixed `{ tester: true, user: 'tester' }`, and it allows only the row 175 writes, as
today. No session or header can change it.
- Its bus calls stay on `TESTER_CLI`. The bus checks the tester proof and refuses human verbs from it, and the
broker refuses a business whose human is `tester` and any role named `tester`. So nothing that reaches 7333
can become the human.
- 7332 keeps refusing `x-mosaic-tester` for every Host (:485).
- `subjects` validation refuses an entry named `tester`.
- `subjects` validation refuses a `name` of `tester` or `human`.
### 7. Point 5, the proxy-forwarded token
@@ -225,7 +390,9 @@ Not in phase 1. Point 5 makes it optional and the code flow covers the ruling. I
- Verify with jose (MIT, exact pin), against the JWKS from the configured issuer's own discovery. Never use
the `X-authentik-meta-jwks` header, because it arrives with the request and the forger controls it.
- Use the same alg allow list, iss, aud (the proxy provider's client id), exp and subject list.
- Use the same alg allow list, iss, exp and subject list. `aud` is the proxy provider's client id, and the
config check refuses it if it equals any code-flow `clientId`, so an ID token issued to the Console can't be
replayed as a proxy token or the other way round (N4).
- The mosaic-preview provider signs with HS256 and has an empty JWKS, so its `X-authentik-jwt` fails every
check until Jason gives it an RSA or EC key (Jason step 1).
@@ -237,9 +404,11 @@ Today, with `--send` live, any same-uid process on dragon-lin can connect to 127
strips client copies of the header, so a remote forger needs local code execution first. Users on other uids
can reach the loopback port too (P19 333).
Recommendation: Mos restarts the Console without `--send` until phase 2. Reads stay up, the header path stops
carrying writes, and only Jason's sends through the Console stop. That's a Mos call (Console start belongs to
Jason or Mos). Until then, no agent sends a request with the auth header to 7332. The tests use fixture servers.
Mos ruled (mos-0972) that the Console runs without `--send` until phase 2. Reads stay up, the header path
stops carrying writes, and Jason sends piece 1 with `mosaic talk pm` instead. Row 189 (`stack-release.sh
activate --no-send`, #1687) makes that change through the pointer so the unit and stack-active.json stay in
step; the restart follows its landing. Until phase 2, no agent sends a request with the auth header to 7332.
The tests use fixture servers.
To be clear about the limit: SSO protects Console ingress. It doesn't protect the bus's human boundary. The
human proof is path-bound (#225), so a same-uid process can already run the tree's `human-cli.mjs` and act
@@ -249,11 +418,11 @@ as the human without touching the Console. Q175 doesn't change that. P19 333 own
All tests run against a fake OP in `packages/webui/tests/helpers/fake-oidc.mjs`. It's a loopback https
server with a self-signed cert. Each test passes the library a `customFetch` that trusts only that cert, so
production code needs no insecure-request flag. The fake OP has discovery, a
JWKS, and authorization, token and end-session endpoints, with keys made by `node:crypto` (RSA 2048, P-256, and an
HMAC secret for the forgeries). No test reaches a real provider. Each refusal test asserts the status, that no
`__Host-mosaic_session` was set, and that the bus send stub was never called. Each test also passes once
unmutated, as the control.
production code needs no insecure-request flag. The fake OP has discovery, a JWKS, and authorization, token
and end-session endpoints, with keys made by `node:crypto` (RSA 2048, P-256, and an HMAC secret for the
forgeries). It can serve two issuers at once and rotate its keys. No test reaches a real provider. Each
refusal test asserts the status, that no `__Host-mosaic_session` was set, and that the bus send stub was
never called. Each test also passes once unmutated, as the control.
Refusals Mos named:
@@ -261,35 +430,87 @@ Refusals Mos named:
Fetch Metadata, and no cookie: 401 on `/api/bus/message`, `/api/settings` and `/api/config`, and a 303 to
login on `/`.
2. HS256 ID token. The fake OP signs the ID token with HS256, using the client secret as the key (the classic
confusion) and `kid` set to a real RSA kid: refused at the callback.
3. Wrong aud. A correctly signed RS256 token with `aud` set to another client id: refused.
4. Expired token. A correctly signed token with `exp` 10 minutes ago (beyond the library's clock tolerance):
refused.
confusion) and `kid` set to a real RSA kid: refused at the callback. The library's alg check refuses it
before any signature check, so this test proves the alg rule, not the signature check. Test 9 covers that.
3. Wrong aud. A correctly signed RS256 token with `aud` set to another client id: refused. Also an `aud`
array holding our client id and another, with no `azp`: refused.
4. Expired token. A correctly signed token with `exp` 10 minutes ago (beyond the library's 30 s clock
tolerance): refused.
More refusals:
5. Wrong `iss`, both a different issuer and a trailing-slash variant.
6. Nonce mismatch or missing nonce.
7. State mismatch, a missing login cookie, a replayed callback (record already taken), an expired login record,
and two login cookies.
and two login cookies. For the replay, the fake OP is set to accept the same code twice, so the second
callback reaches the record lookup and only the take-and-delete rule refuses it.
8. A provider that demands no PKCE. The token request must carry `code_verifier`, and the fake OP refuses a
wrong one.
9. `alg: none`, and an RS256 token signed by a key not in the JWKS.
10. A valid token for a `sub` not in `subjects`: 403 and one log line with iss and sub only.
11. Startup refusals: HS-only discovery (the mosaic-preview shape), an empty JWKS, no S256, an issuer mismatch,
a slot with mode 0644, and `--sso` together with `--auth-header`.
9. The signature check (B5), three cases: (a) `alg: none`; (b) an RS256 token whose header names a real kid in
the JWKS, with a changed payload and the original signature; (c) an RS256 token signed by a key whose kid
isn't in the JWKS, even after the refetch. (a) is refused by the alg check in step 4, so it's an alg test
like test 2. Only (b) and (c) reach step 5, and (b) is the case only the signature check catches.
10. A valid token for a `sub` not in `subjects`: 403 and one `subject-unknown` log line with the provider id,
iss and sub only.
11. Start refusals: `algs` holding `HS256` or `none`, HS-only discovery (the mosaic-preview shape), an empty
JWKS, no S256, an issuer mismatch, a subject named `tester` or `human`, `--sso` together with
`--auth-header`, and slot files that are a symlink, mode 0644, mode 0640, or under the repo. The owner
check has no test: making a file owned by another uid needs root, which the suite doesn't have. The
review reads that branch instead.
12. Tester port: a valid session cookie (taken from a 7332 sign-in), `Authorization: Bearer <valid id token>`,
and `X-authentik-jwt` each get 403. `/auth/login` on 7333 gets 404.
and `X-authentik-jwt` each get 403. `/auth/login` on 7333 gets 404. `startServer` with `tester` and `sso`
throws.
13. Session: idle expiry, absolute expiry, a logout followed by reuse of the old cookie, and logout without
same-origin (refused, session kept).
14. Open redirect: `return=//evil.example` and `return=https://evil.example` both land on `/`.
same-origin (refused, session kept). Background `/api/` GETs every 10 s for over an hour don't keep the
session alive; a page load does.
14. Open redirect: `return=//evil.example`, `return=https://evil.example`, `return=/\evil.example` and
`return=/%09/evil.example` all land on `/`, and `return=/board?x=1` lands there.
15. Provider mix-up. Two providers, A and B. A login started for A whose callback carries B's `iss` and a
code from B is refused, and a `provider=` query on the callback is ignored. B's code is issued against
A-record's PKCE challenge, so the verifier matches and only the record rule (the callback belongs to A)
refuses it.
16. Several algs: a provider with `algs: ["RS256", "ES256"]` takes a token signed with either, and refuses
PS256 even when the JWKS holds a usable PS256 key.
17. Login record cap: 33 started logins leave 32 records, and the first login's callback finds no record.
18. JWKS rotation: the fake OP rotates to a new key with a new kid. A sign-in after the rotation succeeds
through the one refetch, and a token under the removed kid is refused.
19. Logs: across every test in this file, captured log lines hold none of the tokens, codes, states, nonces,
verifiers, the client secret or a session id that the test produced.
20. The session store holds no raw session id (keys are sha256 values, and no value holds the cookie).
21. Set-Cookie: the exact session and login cookie strings, including the expiry of the login cookie at
callback.
22. Host: under `--sso`, a loopback Host on `/`, `/api/board` and a write each get 403; `/healthz` on
loopback gets 200.
23. Degraded start: the fake OP is down at start; the Console starts, `/auth/login` is 503, `/healthz` reports
`unreachable`; the OP comes up and the next retry makes sign-in work. A discovery URL that answers 200
with an HTML page also counts as `unreachable` and the Console starts. A provider that answers with
HS-only discovery at start refuses the start.
24. Signed-out page (C2). A page test in the board-held.test.mjs style stubs fetch to answer 401
`{ error: 'sign-in required' }`: the page shows the signed-out notice, its link is
`/auth/login?return=<current path and query>`, and the refresh stops. A 401 with another error stays an
error. The existing opaqueredirect case still signs out.
25. Loader (C1), in scripts/test-config.sh: a config with a valid `console.sso` loads; a malformed one (an HS
alg, a missing `slot` field, a slot name outside the pattern, an unknown key under `console`) fails and
names the bad key path, and no case reads a slot file (the file cases stay in test 11); a config without
`console` loads.
Positive paths: an RS256 and an ES256 provider each complete sign-in and one send, and two providers configured
together each work with their own subjects.
Mutants for the gate: drop the `validateApplicationLevelSignature` call (test 2 or 9 must fail), drop the
`subjects` check, accept HS* in `algs`, take the provider from the query, let the tester listener read
cookies, and skip the login record delete.
Mutants for the gate, each killed by the named test:
- drop the `validateApplicationLevelSignature` call (9b);
- wrap that call in a try/catch that continues (9b);
- drop the `subjects` check (10);
- accept HS* in `algs` (11);
- take the provider from the query (15);
- let the tester listener read cookies (12);
- skip the login record delete (7);
- drop `HttpOnly` from the session cookie (21);
- refresh `seen` on every request (13);
- build `redirect_uri` from the request Host (a test with a different Host on `/auth/login` checks the
`redirect_uri` sent to the fake OP);
- serve the loopback read path under `--sso` (22).
### 10. Jason-gated steps
@@ -300,16 +521,21 @@ cookies, and skip the login record delete.
post-logout URI `https://mosaic.woltje.com/auth/signed-out`, keep RS256, and give the client id. Put the
client secret into slot `console-sso-authentik` (or say an agent may create the slot file from a value he
puts in place, per the Q173 secrets rule). Agents never see the value.
3. His `sub` for that provider, or a go to read it from the Console's one refusal log line on his first
sign-in attempt.
4. Any Traefik or outpost change. None is needed while the outpost stays in front. Dropping the outpost
3. His `sub` for that provider, or a go to read it from the Console's one `subject-unknown` log line on his
first sign-in attempt.
4. The `console.sso` entry in `~/.config/mosaic-dev/config.json`, inside the phase 2 window, after the pin
and before the Console starts (section 5). He writes it, or he gives an agent the go to write that one
key from the values in steps 2 and 3, with a backup of the file first. config.json is user-authored, so
this isn't covered by Q173. Writing it earlier stops every tool on the live copy, whose loader refuses
the key.
5. Any Traefik or outpost change. None is needed while the outpost stays in front. Dropping the outpost
later, so the Console is the only gate, changes who can reach the Console from outside and is his call.
5. The phase 2 re-pin window go.
6. The phase 2 re-pin window go.
## Questions for review
- Q-A: the config location, business file `console` key or config.json (section 4).
- Q-B: refuse to start when the IdP is unreachable, or start and refuse sign-ins (section 4).
- Q-C: should the loopback-Host read path on 7332 also go behind the session, as proposed? Public traffic
arrives from 127.0.0.1 too, and only Host keeps it off that path today.
- Q-D: 8 h absolute and 1 h idle, or shorter.
Q-A to Q-E are settled (Filbert r1 and r2, Mos mos-0973). One open point, for Mos:
- The config.json sequence (C1): the key goes in only inside the phase 2 window, after the pin and before
the Console starts, with a backup first, and a rollback removes it before the pointer moves (section 5).
Mos confirms this before phase 1 starts.
@@ -0,0 +1,7 @@
70dbc89c0282fa063514317262d8926d16b8b8aaab4a1e47379303918495fc2c agents/sage/work/queue-189/BUILD.md
4bd216cce2b40b39998725daa8989f6e3acb3ccd81a7017f6b09026af3965584 agents/sage/work/queue-189/mutants-run.log
ddbdddf03abf4aa91aa62f52e468be7af3c6b2f5d7bec2ee99b11e315471f18c agents/sage/work/queue-189/mutants.sh
5d7b2da84d994f4f93d1212e349b4e4c19c5c00780e6cfcde91edb3ca08c014e docs/plans/2026-10-20_activate-no-send.md
38e410c4cb731390f96747646eb7f6caa7a587fca9024ccbe901aab5ac19c7cf docs/TOOLS.md
fe430969cc669515e348fced1a22cbe839a12023f7ac67cfba905b7afd29872f packages/cli/tests/stack-release.test.mjs
16f77305e558f4fb953accc6d09f1f7b408aba363851d8ca79216ffce41bed9f scripts/stack-release.sh
@@ -0,0 +1,56 @@
// Sage, 2026-10-20: row 163 (D2, #1660) round 2 approved. One comment on P19 337 (D2), per mos-0882 (every
// landing or decision gets a Vikunja comment). Sage's own key (bot user 13); the sync key reads back. Comments only:
// no task edits, labels, assignees or bucket moves. Checks the task's title before posting. Prints no secret.
// Usage: node comment-337-r163-approved.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON
import { readFileSync, writeFileSync, existsSync } from "node:fs";
const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2);
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
const PROJECT = 19;
const PSIG = "Posted by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13).";
const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const ps = (xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(PSIG)}</p>`;
const POSTS = [
{ id: 337, title: /^D2: stack rehearsal on a scratch root/, body: [
"Row 163 (Gitea #1660) landed and pushed. Landing commit 8ecc7a239 (Sage; Filbert approve 29447), queue revs 2235-2236 (landing note and done) in 9ae8c73ac, range 4ca8d6e94..9ae8c73ac on refactor. Patch-id b8d5f8a37c24 matches candidate c6f4f4c12596. #1660 is closed with comment 29450.",
"Sage gate passed at 18:16Z on eaa0f2666: cli 472/472 and every package and scripts/test-*.sh green. The pushed commit is a cherry-pick of eaa0f2666 onto 7f57419b3, which adds only the Q175 design doc, and no test reads that file. Mos dropped the Part B wait when Q175 replaced Part B (mos-0971).",
"Not live. The live stack still runs copies/490e2771d3d0. Rehearse and bus start --no-launch go live at the next re-pin, where the Q173 backup and STATE report apply.",
] },
{ id: 412, title: /^Console SSO: zero-trust OIDC verification/, body: [
"Design r1 is committed: agents/sage/work/q175-sso/DESIGN.md in 7f57419b3 (blob 91e5b1b5e737), pushed in 9ae8c73ac. The Console becomes an OIDC relying party on oauth4webapi 3.8.8, pinned exactly. It always calls validateApplicationLevelSignature, because the library skips the signature check on a token-endpoint ID token. Identity is an (iss, sub) pair, and only asymmetric algs are accepted.",
"Filbert's r1 review asks for changes in the doc before any code (B1-B6). B1 moves the provider config to config.json, because agents have write paths into the business file. B2 closes an open-redirect hole. B3 keeps the library's error causes out of logs. B4 caps login records. B5 fixes the signature mutant test. B6 removes a live forged-header probe from the phase 2 acceptance. Their rulings: Q-B start degraded when the IdP is unreachable, Q-C yes (7332 accepts only the public Host under --sso), Q-D keep idle expiry but don't refresh it on poll GETs. r2 follows.",
"Interim (mos-0972): the Console runs without --send until phase 2. stack-release.sh has no way to drop --send that keeps stack-active.json and the unit in step, so a small tool row comes first, through review.",
] },
];
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
async function api(method, path, body, auth) {
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
return { status: r.status, json };
}
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
const rec = { at: new Date().toISOString(), comments: [], problems: [] };
const save = () => {
const out = JSON.stringify(rec, null, 1) + "\n";
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
writeFileSync(OUT, out);
};
for (const P of POSTS) {
const t = await api("GET", `/api/v2/tasks/${P.id}`, undefined, sync);
if (t.status !== 200 || t.json?.project_id !== PROJECT || !P.title.test(t.json?.title ?? "")) throw new Error(`task ${P.id}: ${t.status}, title check failed; nothing posted`);
}
writeFileSync(OUT, "", { flag: "wx" });
for (const P of POSTS) {
const c = await api("PUT", `/api/v1/tasks/${P.id}/comments`, { comment: ps(P.body) }, key);
const e = { task: P.id, status: c.status, id: c.json?.id ?? null };
rec.comments.push(e);
if (c.status >= 300 || !Number.isSafeInteger(c.json?.id)) { rec.problems.push(`comment ${P.id}: ${c.status} ${c.json?.message ?? ""}`); save(); continue; }
const back = await api("GET", `/api/v2/tasks/${P.id}/comments?per_page=50`, undefined, sync);
const found = list(back.json).find((x) => x.id === c.json.id);
Object.assign(e, { readBack: back.status, found: Boolean(found) });
if (back.status !== 200 || !found) rec.problems.push(`comment ${P.id} read-back: ${back.status}`);
save();
}
save();
process.stdout.write(JSON.stringify(rec, null, 1) + "\n");
if (rec.problems.length) process.exit(2);
@@ -0,0 +1,56 @@
// Sage, 2026-10-20: files the activate --no-send row (mos-0972, mos-0973) as one P19 task, owner Sage, no due date.
// Sage's own key (bot user 13); the sync key reads back. No labels, assignees, due dates or bucket moves. Refuses if
// the title exists. Prints no secret.
// Usage: node file-no-send-row.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON
import { readFileSync, writeFileSync, existsSync } from "node:fs";
const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2);
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
const PROJECT = 19;
const SIG = "Created by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13).";
const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const ps = (sig, xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(sig)}</p>`;
const T = {
title: "stack-release.sh activate --no-send: drop the Console's --send and keep the pointer in step (Q175 interim, mos-0972)",
description: ps(SIG, [
"Mos ruled (mos-0972) that the Console runs without --send until Q175 phase 2 (P19 #412), because with --send live any same-uid process can forge X-authentik-username on 127.0.0.1:7332 and send as Jason.",
"stack-release.sh has no path that drops --send and keeps ~/.mosaic-dev/state/stack-active.json and the unit in step: console_for_target (:658-687) carries the pointer's flags, check_target (:247-249) allows a same-copy activate only for the proof pair, and adopt refuses once a pointer exists. A hand reinstall makes flags_drift refuse every later activate, pm-on and rollback.",
"The row adds activate --no-send, modeled on row 187's --no-proof. It drops --send from the pointer and the unit. A same-copy activate is allowed when the pointer holds --send. Rollback and pm-on carry the pointer as they do now. There is no re-enable flag. Brief: docs/plans/2026-10-20_activate-no-send.md. Owner Sage, reviewer Dewey, gate the cli suite plus every scripts/test-*.sh (mos-0973 go).",
"After landing: a same-copy activate on 490e2771d3d0 with --no-send under Q173, with the plan output sent to Mos first, a backup first and a STATE report after. Then row 66 lands.",
]),
};
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
async function api(method, path, body, auth) {
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
return { status: r.status, json };
}
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
async function existing() {
const out = [];
for (let page = 1; page < 20; page++) {
const r = await api("GET", `/api/v2/projects/${PROJECT}/tasks?per_page=50&page=${page}`, undefined, sync);
if (r.status !== 200) throw new Error(`list tasks: ${r.status}`);
out.push(...list(r.json));
if (list(r.json).length < 50) return out;
}
throw new Error("more than 19 pages");
}
const rec = { at: new Date().toISOString(), project: PROJECT, created: null, problems: [] };
const save = () => {
const out = JSON.stringify(rec, null, 1) + "\n";
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
writeFileSync(OUT, out);
};
if ((await existing()).some((t) => t.title === T.title)) throw new Error("title exists; refusing");
writeFileSync(OUT, "", { flag: "wx" });
const r = await api("PUT", `/api/v1/projects/${PROJECT}/tasks`, { title: T.title, description: T.description }, key);
rec.created = { status: r.status, id: r.json?.id ?? null };
if (r.status >= 300 || !Number.isSafeInteger(r.json?.id)) rec.problems.push(`create: ${r.status} ${r.json?.message ?? ""}`);
else {
const back = await api("GET", `/api/v2/tasks/${r.json.id}`, undefined, sync);
Object.assign(rec.created, { readBack: back.status, done: back.json?.done, inverse: `mark done: POST /api/v1/tasks/${r.json.id} with the whole task, done true (sage key); delete is Jason's or ops-01's` });
if (back.status !== 200 || back.json?.title !== T.title || back.json?.done) rec.problems.push("create read-back differs");
}
save();
console.log(JSON.stringify(rec));
@@ -0,0 +1,12 @@
{
"at": "2026-10-20T18:21:15.323Z",
"project": 19,
"created": {
"status": 201,
"id": 413,
"readBack": 200,
"done": false,
"inverse": "mark done: POST /api/v1/tasks/413 with the whole task, done true (sage key); delete is Jason's or ops-01's"
},
"problems": []
}
@@ -0,0 +1,20 @@
{
"at": "2026-10-20T18:19:36.544Z",
"comments": [
{
"task": 337,
"status": 201,
"id": 372,
"readBack": 200,
"found": true
},
{
"task": 412,
"status": 201,
"id": 373,
"readBack": 200,
"found": true
}
],
"problems": []
}
+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.
+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.
+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.
+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."
+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.
+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.
+43 -18
View File
@@ -1357,21 +1357,46 @@ are never rewritten or removed; corrections are new entries.
2026-10-20T15:26:33Z | Sage (Opus 5.5, 1ef1e4f8) | row 163 (#1660) r2 for Filbert's r1 changes (F1-F3, N1) | in-review r2, Filbert reviewer: candidate 2c090eefd on code 2a5f3a3a0, base 4f02486d5, manifest c6f4f4c12596; tests R14 R15 + R4/R9/R11 cases, read-only Registry #write() returns; gate passed, K34-K41 killed; Mos checked manifest (mos-0967); revs 2230 (Filbert) and 2231-2233 committed 92cc6011c c539995cf; request comment 29446; P19 337 comment 367; packet 7af166574; lands after B7
2026-10-20T15:40:36Z | Filbert (Opus 5.5, 048f3c63) | row 163 r2 review (D2 rehearse, candidate 2c090eefd, manifest c6f4f4c12596) | approve, comment 29447 rev 2234: 249/249 singles pass; my M1-M6 and N1 mutants all killed (R14, R9, R11, R4, R15, session test)
2026-10-20T15:42:00Z | Sage (Opus 5.5, 1ef1e4f8) | row 163 (#1660) r2 verdict intake | approved by Filbert (comment 29447, rev 2234 committed cd2b377bb); P19 337 comment 367 -> 368; candidate 2c090eefd manifest c6f4f4c12596 waits to land after B7, fresh gate if the base moves
2026-10-20T18:44:50Z | Gizmoduck (Opus 5.5) | T3 federation design r1 for Vikunja #414 (G3, G4, C1.1), agents/gizmoduck/work/federation/DESIGN.md, on branch gizmoduck/federation-design off origin/refactor 9ae8c73ac | design committed and pushed; rows R1-R8 wait for Filbert or Dewey review before Sage's queue; Q1-Q3 to Mos
2026-10-20T18:56:54Z | Gizmoduck (Opus 5.5) | T3 federation design r2 for Vikunja #414, answering Duckworth's B1-B5 and N1-N9 (duckworth-0001) and Mos's mos-0978 and mos-0984 rulings, agents/gizmoduck/work/federation/DESIGN.md | r2 committed and pushed on gizmoduck/federation-design; sent to Duckworth and Mos for re-review; R1, R2, R5 wait for Duckworth's approval before Sage's queue
2026-10-20T18:58:23Z | Gizmoduck (Opus 5.5) | T3 federation design r2.1 for Vikunja #414, mos-0988: R8 uid gate on row 190b, R7 on row 190, P19 333 in the risk | committed and pushed on gizmoduck/federation-design; Duckworth's r2 verdict pending
2026-10-20T19:00:18Z | Gizmoduck (Opus 5.5) | T3 federation design r3 for Vikunja #414, answering duckworth-0002 B6 (user reply path), C1 and N10 | committed and pushed on gizmoduck/federation-design; sent to Duckworth for approval on the diff
2026-10-20T19:01:43Z | Gizmoduck (Opus 5.5) | T3 federation design r3.1 for Vikunja #414, mos-0991 (R8 gate accepts a separate host or uid, one-host audit 3.8, Q1 answer) and morgana-0002 (Q2 settled, access.json is the human store) | committed and pushed on gizmoduck/federation-design; sent to Duckworth with r3
2026-10-20T19:04:18Z | Gizmoduck (Opus 5.5) | T3 federation design r4 for Vikunja #414, answering duckworth-0003 B7 (privilege paths in R8's gate), N11 (#waiting for user replies), N12 | committed and pushed on gizmoduck/federation-design; sent to Duckworth
2026-10-20T19:08:20Z | Gizmoduck (Opus 5.5) | Correction to the 19:04:18Z line: commit 04a6112be carried only that SESSIONS line, because the DESIGN.md edit script failed on an anchor and the chain still committed and pushed, and nothing went to Duckworth then. r4 proper: B7 per mos-0996 and mos-1004 (host form only, sudo, doas and ssh checks, hypervisor path an open R8 item with O1 to O3 on Vikunja #419), N11, N12, row 196 verifier noted per gander-0006 | committed and pushed on gizmoduck/federation-design; sent to Duckworth and Mos, reply to Gander
2026-10-20T19:12:55Z | Gizmoduck (Opus 5.5) | T3 federation design r4.1 for Vikunja #414: B8 (duckworth-0005, issuer and TLS front end), N13 key procedure, N14 R3 on row 196, N15 DNS; mos-1006 (identity provider path, sudo deviation accepted); mos-1009 (privilege path table, each closed or accepted per #419); gander-0007 (verifier interface); morgana-0005 (grants name bus project ids, one covers in R1, R4 counts collaborator grants only) | committed and pushed on gizmoduck/federation-design; sent to Duckworth, copies to Mos, Morgana and Gander
| 2026-10-20T19:16:08Z | Gizmoduck | T3 federation design r4.2 for #414 (mos-1013, morgana-0007) | factory means every principal outside the hub instance, hosts as variables, Q180 settles Q1 |
| 2026-10-20T19:16:56Z | Gizmoduck | T3 federation design r4.3 for #414 (duckworth-0007) | B8 issuer host passes host-access, hypervisor and backups rows, N16-N19 |
| 2026-10-20T19:17:11Z | Gizmoduck | T3 federation design r4.4 for #414 (mos-1020) | R4 chained after row 193's viewer level, keeps the level-loss test (N17) |
| 2026-10-20T19:20:55Z | Gizmoduck | T3 federation design r4.5 for #414 (duckworth-0010, mos-1022) | B9 user-thread decisions reach the owner's inbox by ruling, R3 tests it, N20 start order, R8 waits on host variables |
| 2026-10-20T19:28:44Z | Gizmoduck | T3 federation design r4.6 for #414 (duckworth-0012 N21, morgana-0011) | R6 notice is row 193's sentence word for word, start-order line matches the table |
| 2026-10-20T19:29:44Z | Gizmoduck | T3 federation design r4.7 for #414 (mos-1030, Q181) | hosts settled: Case 1 and Hermes on W-jarvis 10.1.1.53, hub W-Melanie-OC 10.1.1.54 user mosaic |
| 2026-10-20T19:31:06Z | Gizmoduck | T3 federation design r4.8 for #414 (duckworth-0020 N22) | R8 checks mosaic's sudo, groups, keys and sudoers.d on 10.1.1.54 |
| 2026-10-20T19:35:32Z | Gizmoduck | T3 federation design r4.9 for #414 (mos-1037) | R8 names Jason as localadmin running the root checks on 10.1.1.54 |
2026-10-20T19:59:12Z | Gizmoduck (Opus 5.5, 9312a896) | federation design r4.10 (#414, mos-1055): R8 is a recorded acceptance citing DECISIONS Q182 and Q183 verbatim, checks report not refuse, agents may run the .54 checks, mos-1042 order in the R8 row | to Duckworth for ack, cc Mos and Sage
2026-10-20T20:01:20Z | Gizmoduck (Opus 5.5, 9312a896) | federation design r4.11 (#414, mos-1063): hub on .54 VM 1012 as mosaic (Q181), R8 depends on #416 not #418, functional checks stay blocking | to Duckworth with r4.10, cc Mos and Sage
2026-10-20T18:19:06Z | Filbert (Opus 5.5, 048f3c63) | Q175 SSO design review r1 (#412, DESIGN.md blob 91e5b1b5e737 at 7f57419b3), static read incl. oauth4webapi 3.8.8 | changes: B1 config in config.json not business file, B2 return-path backslash, B3 log redaction of library error causes, B4 login-record cap, B5 test 9 not 2 kills signature mutant, B6 phase 2 live forged-header probe; rulings Q-B start degraded, Q-C yes, Q-D 8h abs + idle ignores polls; step 5 reading confirmed; verdict sent to Sage, no Gitea/queue record
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: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
+72
View File
@@ -0,0 +1,72 @@
# 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.
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
live units or a slot file. Every test uses the stand-in `systemctl`, units, stores and data root that
`packages/cli/tests/stack-release.test.mjs` already builds.
## Why
Mos ruled that the Console runs without `--send` until Q175 phase 2, because with `--send` live any same-uid
process can forge `X-authentik-username` on 127.0.0.1:7332 and send as Jason (design section 8). On the pinned
stack, the Console flags live in the pointer (`~/.mosaic-dev/state/stack-active.json`):
- `console_for_target` (scripts/stack-release.sh:658-687) carries the pointer's flags unchanged. Only
`--tester-port` and the row 187 proof pair can change.
- `check_target` (:247-249) lets an activate name the pointer's copy only for a proof-pair change.
- `adopt` refuses once a pointer exists (:822).
- A hand `webui-service.sh install` without `--send` makes `flags_drift` (:640-651) refuse
`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.
## The change
1. `activate ID BUSINESS --no-send` drops `--send` from `RUN_CONSOLE`, so `install_stack` leaves it out of the
Console unit and `write_pointer` leaves it out of the pointer. Only `activate` takes it. It combines with
`--tester-port`, the proof pair and `--no-proof`.
2. An activate with `--no-send` may name the pointer's copy when the pointer's Console flags hold `--send`. It
re-pins the live copy with only the Console flags changed, as row 187 does for the pair, including the
live-copy plan note and the backup of the stopped store. Without `--send` in the pointer, a same-copy
`--no-send` still refuses `already-active`.
3. The steps log records `drop --send; --no-send was given`, or `--no-send was given and the pointer holds no
--send, so there is nothing to drop`.
4. `--plan` prints one `send:` line for an activate or rollback: `send: dropped; --no-send was given`,
`send: off; --no-send was given and the pointer holds no --send`, `send: carried` when the pointer's
`--send` goes on unchanged, or `send: off`.
5. `rollback` and `pm-on` don't change: they carry the pointer's flags. So once `--no-send` has run, a rollback
to an earlier copy keeps send off. There is no flag to turn send back on. Q175 phase 2 changes the Console
flags in its own row.
6. `docs/TOOLS.md` "Pinned stack update" gets `--no-send` in the usage block and one sentence.
## Tests (packages/cli/tests/stack-release.test.mjs)
1. Seeded preview pointer with `--send` and `--tester-port 7333`: a same-copy `activate B acme --no-send`
passes. The pointer and the unit ExecStart lack `--send` and keep the preview flags and
`--tester-port 7333` in order. Status says `unit flags: match the pointer`. The steps log has the drop
line. The log line has from = to = B, and the stopped store was backed up.
2. A plain same-copy activate still refuses `already-active`. So does a same-copy `--no-send` once `--send` is
gone.
3. `--plan` prints the four `send:` lines in their cases, with the live-copy note on the same-copy run.
4. After `--no-send`, a rollback to the earlier copy and `pm-on` both leave `--send` out of the pointer and
the unit.
5. `--no-send` on `rollback`, `pm-on`, `status` or `adopt` is a usage error (exit 4).
6. `--no-send` with the proof pair, and with `--no-proof`, on a pointer holding both: each drops what it
names and keeps the rest.
## Mutants (each must fail at least one test)
- M1: the same-copy exception ignores `--no-send`.
- M2: the same-copy exception allows `--no-send` without `--send` in the pointer.
- M3: `--no-send` drops `--send` from the pointer but not from the unit (it writes the pointer from
`P_CONSOLE`).
- M4: rollback re-adds `--send`.
- M5: the arg parser accepts `--no-send` for rollback.
## Gate
Dewey approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate, and the
mutants are killed.
+47
View File
@@ -0,0 +1,47 @@
# Brief: agent lifecycle from the Console and the CLI, with a per-agent hire/fire veto
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 Dewey, gate
owner Sage. Base: refactor at the brief commit.
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)
- The chief launches only listed instances (`roles/chief-of-staff.json`, harness `tools.mjs` launch,
`broker.mjs:1968`). No user verb launches an arbitrary agent.
- Terminate exists (`packages/cli/src/cli.mjs:447`, `instances.mjs:9-11`, webui `serve.mjs:288, 535`). There
is no regenerate, no delete and no Console launch.
- Instance changes need a bus restart (`instance-settings.mjs:35,75`, `organization.mjs:83`). Add and change
only, no delete.
- Human-only membership acts as a veto today, but there is no per-agent veto list.
## What the design must settle
1. The verbs: create (from a role and a name), launch, terminate, regenerate (a fresh session with the same
identity and role), and delete (with what happens to its sessions, workspace and run evidence, which
stays write-once). Each one is a human verb on the CLI and a Console action.
2. Chief hire/fire. What the chief may do on its own, and the per-agent veto: the human marks an agent so
the chief can't fire or replace it, and marks roles the chief can't hire. Where the veto lives and how
the broker enforces it.
3. Live changes. Which changes take effect without a bus restart, and how a running bus picks them up
safely.
4. RBAC. Human roles come from row 193 (Morgana). Until it lands, these verbs are owner-only. The design
states the hook that row 193 fills.
5. Per instance. Row 190 runs one instance per OS user. Lifecycle verbs act on their own instance only.
6. The kill switch (row 191, Rocko) refuses every launch path. These new launch paths check it too.
## Boundaries
- No test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or a slot file.
- Role authority is declared in `roles/` and changes only by reviewed commits (AGENTS.md invariant 7). This
row adds verbs, not new role authority. Any role file change is called out for review.
- The build may chain on rows 190, 191 and 193 candidates (Q177).
## Gate
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.
+54
View File
@@ -0,0 +1,54 @@
# Brief: many humans per business, human roles, private and shared threads
Sage, 2026-10-20, for mos-0974 and mos-0977 (MVP, DECISIONS Q176, Vikunja #414 "RBAC enforcement" and #415
Case 1 "Additional users can be configured for access" and "Shared and private agent sessions available per
user; RBAC"). Gitea #1691. T1 item 3. Owner Morgana, reviewer Duckworth, gate owner Sage. Base: refactor at the brief
commit.
Design first. The first deliverable is `agents/morgana/work/queue-193/DESIGN.md`. Duckworth reviews it before
any code.
## What exists today (GAP-MAP.md, measured at 9ae8c73ac)
- One human per business: `packages/business/src/business.mjs:160`, and the broker's human check
(`packages/bus/src/broker.mjs:532-538`).
- Threads have an owner field only (`packages/bus/schema.sql:247`, `broker.mjs:353-360`). Nothing is private
or shared.
- RBAC covers agents only (`role.mjs`, `vocabulary.mjs`, `broker.mjs:769-799`, harness `gate.mjs`). Humans
have no roles.
- The human proof is path-bound (#225, P19 333): any same-uid process that runs the tree's `human-cli.mjs` acts
as the human. That limit is real and the design must say how several humans change it.
## What the design must settle
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.
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).
3. Threads. Private threads (visible to their owner and the agents in them) and shared threads (visible to
named humans or all members). Schema change, migration of existing rows (all become the current human's),
and every read path that must filter.
4. One user and grant store. Gizmoduck's federation design (thread 9312a896, design 6925f78f0 on
gizmoduck/federation-design) proposes `access.json` for grants. Mos's reading (mos-0977): one model for
local and federated humans, because two stores drift. Lead decision (Sage, 2026-10-20, to gizmoduck-0002):
row 193 takes Gizmoduck's human-authored `<configDir>/access.json` (federation DESIGN.md section 3.5) as
the one user and grant store, and extends it with local humans, roles and shared threads. Federation adds
only a `home` field. The design confirms this or brings a concrete reason against it to Sage and
Gizmoduck before the build. Federation's R1 (the access.json loader) then comes before this row's build.
5. The bus human boundary. With several humans, the human verbs must know which human calls. State what a
same-uid process can still do (P19 333) and whether row 190's one-instance-per-OS-user direction narrows
it.
6. Migration and rollback. Existing businesses keep working with their one human as owner.
## Boundaries
- Changing who can reach the Console from outside is Jason's. This row changes who is allowed once inside.
- No test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or a slot file.
- The build may chain on rows 190 and 192 candidates (Q177). Each row keeps its own review and gate.
## Gate
Duckworth approves DESIGN.md, then the manifest. The bus, business, cli and webui suites and every
`scripts/test-*.sh` are green on Sage's gate, and the design's mutants are killed.
+65
View File
@@ -0,0 +1,65 @@
# Brief: one kill switch for the bus, every run, Discord and new launches
Sage, 2026-10-20, for Mos's order mos-0974 (MVP, DECISIONS Q176, Vikunja #414 "Kill switch is available to
stop all processing immediately"). Gitea #1689. T1 item 4. Owner Rocko, reviewer Dewey, gate owner Sage. Base: refactor
at the brief commit.
Design first. The first deliverable is `agents/rocko/work/queue-191/DESIGN.md`, which Dewey reviews before
any code. Then the build follows the design, with tests and mutants.
## The goal
One human command and one Console button stop all processing at once and keep it stopped until the human
clears it:
- The bus takes no new work. Agent verbs that start or continue work refuse with a fixed code while the
switch is on. Reads and the human's own verbs, including the clear, still work.
- Every running managed session and task run is stopped (SIGTERM, then SIGKILL after a bounded wait), and
each stop is recorded as an event.
- Discord stops (its existing STOP file and `stop` verb) and can't restart while the switch is on.
- New launches refuse: the launcher, chief hire, `run-task.sh`, and any other path that starts an agent.
- The switch survives a bus restart and a host reboot (it lives in the data root, not in memory).
- Turning it on and clearing it are human-only verbs, through the bus human proof, and each leaves one
event with who and when.
## What exists today (GAP-MAP.md G10, measured at 9ae8c73ac)
These are pieces, with no single switch over them. The design inventories every path that starts or
continues work and says how each one checks the switch:
- `launch.revoke` and `launch.restore` in `packages/bus/src/broker.mjs` (around :2087), human-only.
- The `mosaic launches off|on` verbs in `packages/cli/src/cli.mjs` (around :456-533) and
`packages/cli/src/launcher.mjs` (around :769).
- The Discord STOP file (`packages/discord/src/cli.mjs:18-27`).
## What the design must settle
1. Where the switch state lives, its format, and how each process reads it (the broker checks on every
work verb; the launcher and Discord check before start). The race: a launch already past its check when
the switch goes on must still be stopped.
2. The CLI verb names (for example `mosaic halt` and `mosaic resume`) and the Console button. The button
is a human write, so it goes through the same writer and same-origin checks as other Console writes, and
it works under the Q175 SSO session (#412) once phase 2 lands. Until then, test it with fixture servers
only.
3. Per instance. Row 190 adds several instances per host. The switch is per instance, so it stops one
instance and never another. The design names how it finds the instance's data root. This row may build
on the row 190 candidate (Q177 merge-chaining).
4. What a stopped run's record says, so run evidence stays write-once and honest.
## Tests
Each part of the goal gets a test: work verbs refuse, a running stand-in session and run are stopped,
Discord refuses to start, every launch path refuses, the switch survives a restart, an agent can't set or
clear it, and the clear lets work resume. Mutants: drop the check from one launch path, let an agent clear
the switch, keep the switch in memory only, skip the SIGKILL after the wait. Each one must fail a test.
## Boundaries
- No test touches `~/.mosaic-dev`, the live units, a real Discord bot or a slot file. Use the stand-in
units, scratch data roots and fixture servers the suites already build.
- Rocko has no git credentials. Sage commits the candidate and runs the queue moves Rocko can't.
## Gate
Dewey approves DESIGN.md, then the manifest. The cli suite, the bus, discord and webui package suites and
every `scripts/test-*.sh` are green on Sage's gate, and the mutants are killed.
+68
View File
@@ -0,0 +1,68 @@
# Brief: several Mosaic Stack instances on one host
Sage, 2026-10-20, for Mos's order mos-0974 (MVP, DECISIONS Q176-Q178, Vikunja #414). Gitea #1688. T1 item 1. Owner
Darkwing, reviewer Filbert, gate owner Sage. Base: refactor at the brief commit. This row blocks both MVP
cases: Case 1 (Jason's software factory) and Case 2 (Melanie's creative writing hub) run as two instances
on one homelab host, beside the current mosaic-stack test instance.
Design first. The first deliverable is `agents/darkwing/work/queue-190/DESIGN.md`. Filbert reviews it
before any code, as Q175 did. Then the build follows the design, with tests and mutants.
## The goal
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. 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)
The design starts with a full inventory. Grep every script and package, not only these:
- `packages/cli/systemd/mosaic-bus.service.in`: `mosaic-bus@<business>` sets no `MOSAIC_CONFIG` and reads the
fixed `EnvironmentFile=-%h/.config/mosaic-dev/secrets/claude-session.env`.
- `packages/webui/systemd/mosaic-webui.service.in` and `scripts/webui-service.sh`: one `mosaic-webui.service`.
- `packages/discord/systemd/[email protected]` and `scripts/discord-service.sh`.
- `scripts/stack-copy.sh:8` hardcodes `BUSINESS=mosaic-stack`, and `:95` reads `mosaic-webui.service`.
- `scripts/stack-release.sh`: unit names, the Console port and the tester port default, `COPIES` (:28), the
pointer under `$DATA/state` (:1474), and `rehearse`.
- `scripts/mosaic-config.mjs`, `scripts/bootstrap.sh`, `scripts/reset.sh`, `scripts/land-work.sh`,
`packages/webui/src/cli.mjs`, `packages/business/src/{business,vars}.mjs`, and
`packages/conversation/src/guard.mjs` each name the default config path or data root.
## What the design must settle
1. Instance identity. How a name maps to its config file and how every tool picks the instance (an
`--instance NAME` flag, `MOSAIC_CONFIG`, or both), and what happens with neither (the current default,
unchanged). Name rules, and refusal of a name that collides.
2. Config canon. AGENTS.md invariant 2 says `~/.config/mosaic-dev/config.json` is the sole system config,
created only by `scripts/bootstrap.sh`. Several instances mean several config files. The design proposes
the exact wording change and how bootstrap creates an instance config without overwriting one. Mos rules
on the canon change before the build.
3. Unit names and templates. The bus, Console and Discord units per instance, how `MOSAIC_CONFIG` and the
Claude env file reach each unit, and how the current unit names stay valid for the default instance.
4. Ports. Each instance's Console and tester ports, with a refusal when two instances claim the same port.
5. Stack copies and the pointer. Whether copies are shared between instances (read-only trees, so sharing
may be fine) and how the pointer, activation log, backups and `flags_drift` stay per instance.
`stack-release.sh` and `stack-copy.sh` take the instance.
6. Secrets. The per-instance Claude session env file and the secrets slot root, resolved from the instance's
config dir (the Q175 design resolves its slot the same way: `<configDir>/secrets/mosaic-stack/<slot>`).
7. Isolation proof. What the tests show: two instances started side by side on stand-in units and scratch
roots, each reads only its own config and data root, a command aimed at one never touches the other, and
the default instance's paths and unit names are byte-identical to today.
## Boundaries
- No test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or a slot file. Use the stand-in
systemctl, units and scratch roots that `packages/cli/tests/stack-release.test.mjs` already builds.
- Standing up a real second instance on dragon-lin is a later window under Q173, with a backup first and a
STATE report after. Exposing a new Console through the tunnel or Traefik changes who can reach it from
outside, so it waits for Jason. SSO (Q175, #412) comes first for any instance a second user can reach.
- Merge-chaining (Q177): rows that need instances (human users, kill switch, lifecycle) may build on this
row's candidate. This row keeps its own review and gate.
## Gate
Filbert approves DESIGN.md, then the manifest. The cli suite and every `scripts/test-*.sh` are green on
Sage's gate, and the design's mutants are killed.
+47
View File
@@ -0,0 +1,47 @@
# Brief: Q175 phase 1, the Console verifies SSO itself (--sso)
Sage, 2026-10-20, for Q175 (Jason's ruling), mos-0971/0973/0977 and Vikunja #412. Gitea #1690. MVP T1 item 2. Owner
Launchpad, reviewer Filbert, gate owner Sage. Base: refactor at the brief commit.
The design is approved: `agents/sage/work/q175-sso/DESIGN.md` at d55d86bb0 (blob eba861f5d2bb), Filbert r3
approve plus the D1/D2 ack, Mos C1 confirmation in mos-0977, whose straggler-check condition is in the phase 2 window at b416d2ede. Build phase 1 exactly as the design says. If
the code shows the design is wrong somewhere, stop and tell Sage and Filbert before building around it.
## Scope (phase 1 only, design section 5 item 1)
- oauth4webapi 3.8.8, pinned exactly (design section 2). No other new dependency.
- The OIDC relying party in the Console: `/auth/login`, `/auth/callback`, `/auth/logout`, `/auth/signed-out`,
the callback steps 1-7, our own checks on top of the library, and sessions, cookies and Host rules
(design sections 1 and 3).
- The shared loader, `scripts/mosaic-config.mjs`, accepts an optional `console` key and checks only its
shape. The slot file checks and the read run in the Console's `--sso` start, and the slot resolves from
the config dir (section 4, D1, D2).
- The degraded start for an unreachable provider, and the unreachable/refused classes (Q-B, section 4).
- `--sso` for `cli.mjs serve` and `webui-service.sh`, with its refusals against the old flags.
- The tester listener rules (section 6).
- The page: both fetch paths in `packages/webui/src/public/app.js` (:111 and :305) treat a 401
`sign-in required` as signed out, and the notice links to `/auth/login?return=<path>` (C2).
- Logging as the design says, including the Q-E `subject-unknown` line.
Out of scope: the phase 2 window, the live config.json key, phase 3 removals, the proxy-forwarded token
(section 7), and any change to the live Console.
## Tests and mutants
Design section 9: tests 1-25, the positive paths, and every listed mutant, each killed by its named test.
All tests run against the fake OP in `packages/webui/tests/helpers/fake-oidc.mjs`. No test reaches a real
provider or Authentik, and no test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or a
slot file. Test 25 goes in `scripts/test-config.sh`.
## Boundaries
- No request of any kind to auth.diversecanvas.com, mosaic.woltje.com or the live 127.0.0.1:7332 (mos-0971,
design section 8).
- No credential value is read, printed or logged.
- `npm install` runs only inside the worktree that has its own package.json, never in a directory that would
walk up to home.
## Gate
Filbert approves the manifest. The webui and cli suites, `scripts/test-config.sh` and every
`scripts/test-*.sh` are green on Sage's gate, and the design's mutants are killed.
+4 -1
View File
@@ -33,7 +33,7 @@ Gaps found while working go to `docs/plans/DEFERRED.md`, not here.
<!-- mosaic-queue:begin -->
Generated from `docs/plans/queue.json` revision 2236 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
Generated from `docs/plans/queue.json` revision 2247 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|---|---|---|---|---|---|---|---|---|
@@ -225,6 +225,9 @@ Generated from `docs/plans/queue.json` revision 2236 by `queue render`. Do not e
| 186 | stack-release --plan: the backup line says what the run will do (P19 404, row 185 N1/N2) | darkwing; reviewers filbert | #1684 | done | — | Filbert approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the three brief mutants killed (sage) | `docs/plans/2026-10-20_plan-backup-line.md` § Brief: the activate plan's backup line says what the run will do | Landed 269134d8d (author Darkwing) on 3c5da3a91. Patch-id c97b9f6e25cd matches the r1 candidate (manifest 449e9121, Filbert approve 29419). Sage gate on b367f5730+patch 09:00:20Z-09:08:59Z: node 2681/0, scripts 422/0, queue 156, network removed; 3c5da3a91 adds only a SESSIONS line. Not live: the stack runs copy 490e2771d3d0. Follow-ups on P19 404: Filbert M6 gap, store-unclean reading. |
| 187 | stack-release activate carries the row 178 proof pair (mos-0928 Part B A0) | darkwing; reviewers filbert | #1685 | done | 186 done | Filbert approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_activate-proof-pair.md` § Brief: activate carries the row 178 proof pair | Landed 9a71e4e80 (author Darkwing) on d17328a55; patch-id 6b57c47bc661 matches candidate 52a5ecc1d143 and Filbert's r1 reading. Sage gate passed 10:21:55Z: cli 446/446 and every package and scripts/test-*.sh green. Filbert approve 29424, rev 2204; follow-ups N1-N4 filed on P19. |
| 188 | stack-release tests: a carried proof pair and a proof file inside the repository (P19 409, 410; gates Part B) | darkwing; reviewers filbert | #1686 | done | 187 settled | Filbert approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_proof-test-gaps.md` § Brief: tests for the two proof-file paths row 187 left open | Landed 8fc0ee216 (author Darkwing) on 23a609c5c; patch-id c9a56b167c07 matches candidate 86aa549bad75 and Filbert's r1 reading; stack-release.sh blob 2586a2a37cd2 unchanged. Sage gate passed 10:58Z: cli 453/453 and every package and scripts/test-*.sh green. Filbert approve 29433, rev 2212; N1 (REPO logical path) to P19 after Part B (mos-0959). |
| 189 | stack-release activate --no-send drops the Console's --send and keeps the pointer in step (Q175 interim, mos-0972, P19 413) | sage; reviewers dewey | #1687 | in-review, round 1, request posted | 163 done | Dewey approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_activate-no-send.md` § Brief: activate --no-send drops the Console's --send | — |
| 190 | several Mosaic Stack instances on one host: per-instance config, data root, units and ports (MVP T1.1, mos-0974, Vikunja #414) | darkwing; reviewers filbert | #1688 | in-progress | — | Filbert approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the design mutants killed (sage) | `docs/plans/2026-10-20_multi-instance.md` § Brief: several Mosaic Stack instances on one host | — |
| 191 | one kill switch for the bus, every run, Discord and new launches, from the CLI and the Console (MVP T1.4, mos-0974, Vikunja #414) | rocko; reviewers dewey | #1689 | in-progress | — | Dewey approves DESIGN.md then the manifest; cli, bus, discord and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_kill-switch.md` § Brief: one kill switch for the bus, every run, Discord and new launches | — |
<!-- mosaic-queue:end -->
+471 -1
View File
@@ -1,7 +1,7 @@
{
"version": 1,
"canonicalRoot": "/mnt/storage/src/mosaic-stack",
"revision": 2236,
"revision": 2247,
"rows": [
{
"id": 1,
@@ -19820,6 +19820,153 @@
"createdAt": "2026-10-20T10:31:30.403Z",
"updatedAt": "2026-10-20T11:11:25.098Z",
"updatedBy": "sage"
},
{
"id": 189,
"piece": "stack-release activate --no-send drops the Console's --send and keeps the pointer in step (Q175 interim, mos-0972, P19 413)",
"owner": "sage",
"issues": [
1687
],
"closes": [
1687
],
"state": "in-review",
"previousState": null,
"required": false,
"requiredSince": null,
"gate": "Dewey approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed",
"gateOwner": "sage",
"brief": {
"path": "docs/plans/2026-10-20_activate-no-send.md",
"anchor": "Brief: activate --no-send drops the Console's --send",
"blob": "0444b774faf09084311357fdd59b0ef3a3d347eb"
},
"after": [
{
"id": 163,
"when": "done"
}
],
"reviewers": [
"dewey"
],
"review": {
"rounds": [
{
"n": 1,
"op": "sage-189-inreview-1",
"by": "sage",
"at": "2026-10-20T18:46:06.124Z",
"issue": 1687,
"candidate": {
"kind": "manifest",
"digest": "67af69bd579561fd8c20be2a47695240a9f09f119dbfff88a8e72e2a7416e668",
"text": "70dbc89c0282fa063514317262d8926d16b8b8aaab4a1e47379303918495fc2c agents/sage/work/queue-189/BUILD.md\n4bd216cce2b40b39998725daa8989f6e3acb3ccd81a7017f6b09026af3965584 agents/sage/work/queue-189/mutants-run.log\nddbdddf03abf4aa91aa62f52e468be7af3c6b2f5d7bec2ee99b11e315471f18c agents/sage/work/queue-189/mutants.sh\n5d7b2da84d994f4f93d1212e349b4e4c19c5c00780e6cfcde91edb3ca08c014e docs/plans/2026-10-20_activate-no-send.md\n38e410c4cb731390f96747646eb7f6caa7a587fca9024ccbe901aab5ac19c7cf docs/TOOLS.md\nfe430969cc669515e348fced1a22cbe839a12023f7ac67cfba905b7afd29872f packages/cli/tests/stack-release.test.mjs\n16f77305e558f4fb953accc6d09f1f7b408aba363851d8ca79216ffce41bed9f scripts/stack-release.sh\n"
},
"request": "comment",
"attempts": [
{
"op": "sage-189-inreview-1",
"by": "sage",
"at": "2026-10-20T18:46:06.124Z",
"state": "posted",
"comment": 29457,
"transport": {
"outcome": "posted",
"status": 201,
"comment": 29457,
"detail": "created"
},
"resolutions": []
}
],
"duplicateRisk": false,
"receipts": []
}
]
},
"claim": {
"seat": "sage",
"op": "sage-189-start"
},
"note": null,
"blockedReason": null,
"createdAt": "2026-10-20T18:21:38.646Z",
"updatedAt": "2026-10-20T18:46:24.267Z",
"updatedBy": "sage"
},
{
"id": 190,
"piece": "several Mosaic Stack instances on one host: per-instance config, data root, units and ports (MVP T1.1, mos-0974, Vikunja #414)",
"owner": "darkwing",
"issues": [
1688
],
"closes": [
1688
],
"state": "in-progress",
"previousState": null,
"required": false,
"requiredSince": null,
"gate": "Filbert approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the design mutants killed",
"gateOwner": "sage",
"brief": {
"path": "docs/plans/2026-10-20_multi-instance.md",
"anchor": "Brief: several Mosaic Stack instances on one host",
"blob": "79fa60fbf17a352edc5431d6aabc77c72ec5c48f"
},
"after": [],
"reviewers": [
"filbert"
],
"review": null,
"claim": {
"seat": "darkwing",
"op": "darkwing-190-start"
},
"note": null,
"blockedReason": null,
"createdAt": "2026-10-20T18:42:48.052Z",
"updatedAt": "2026-10-20T18:45:23.430Z",
"updatedBy": "darkwing"
},
{
"id": 191,
"piece": "one kill switch for the bus, every run, Discord and new launches, from the CLI and the Console (MVP T1.4, mos-0974, Vikunja #414)",
"owner": "rocko",
"issues": [
1689
],
"closes": [
1689
],
"state": "in-progress",
"previousState": null,
"required": false,
"requiredSince": null,
"gate": "Dewey approves DESIGN.md then the manifest; cli, bus, discord and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed",
"gateOwner": "sage",
"brief": {
"path": "docs/plans/2026-10-20_kill-switch.md",
"anchor": "Brief: one kill switch for the bus, every run, Discord and new launches",
"blob": "aed33125fa67ae69af73c18ffc9e2a83481421f3"
},
"after": [],
"reviewers": [
"dewey"
],
"review": null,
"claim": {
"seat": "rocko",
"op": "rocko-191-start"
},
"note": null,
"blockedReason": null,
"createdAt": "2026-10-20T18:43:05.222Z",
"updatedAt": "2026-10-20T18:45:05.621Z",
"updatedBy": "rocko"
}
],
"log": [
@@ -78756,6 +78903,329 @@
"receipt": "ok sage-163-done rev 2236 row 163 in-review→done round 2"
},
"viewSha": "e44815d32d46b91ee37d4c3c4701716956ba35d29c16c24c89020cce345bd61b"
},
{
"rev": 2237,
"op": "sage-189-add",
"verb": "add",
"args": {
"piece": "stack-release activate --no-send drops the Console's --send and keeps the pointer in step (Q175 interim, mos-0972, P19 413)",
"gate": "Dewey approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed",
"brief": "docs/plans/2026-10-20_activate-no-send.md#Brief: activate --no-send drops the Console's --send",
"issues": [
1687
],
"note": null,
"owner": "sage",
"gateOwner": "sage",
"after": [
{
"id": 163,
"when": "done"
}
],
"reviewers": [
"dewey"
],
"required": null
},
"by": "sage",
"at": "2026-10-20T18:21:38.646Z",
"semantics": 2,
"result": {
"row": 189,
"from": null,
"to": "queued",
"brief": {
"path": "docs/plans/2026-10-20_activate-no-send.md",
"anchor": "Brief: activate --no-send drops the Console's --send",
"blob": "0444b774faf09084311357fdd59b0ef3a3d347eb"
},
"receipt": "ok sage-189-add rev 2237 row 189 none→queued"
},
"viewSha": "70bf9967156881afecaba9ccf4edf8cbc63a344eb7c6407b3453c950781f34c5"
},
{
"rev": 2238,
"op": "sage-189-briefed",
"verb": "move",
"args": {
"id": 189,
"to": "briefed",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "sage",
"at": "2026-10-20T18:22:01.437Z",
"semantics": 2,
"result": {
"row": 189,
"from": "queued",
"to": "briefed",
"round": null,
"issue": null,
"candidate": null,
"receipt": "ok sage-189-briefed rev 2238 row 189 queued→briefed"
},
"viewSha": "27ee8a26722d3eaf4c4b346e06ffdb853037aad2d1a7fe14f440cbe1bb9a2a27"
},
{
"rev": 2239,
"op": "sage-189-start",
"verb": "move",
"args": {
"id": 189,
"to": "in-progress",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "sage",
"at": "2026-10-20T18:22:18.476Z",
"semantics": 2,
"result": {
"row": 189,
"from": "briefed",
"to": "in-progress",
"round": null,
"issue": null,
"candidate": null,
"receipt": "ok sage-189-start rev 2239 row 189 briefed→in-progress"
},
"viewSha": "230578585341fcfc9429364332ca8b78988ffd669d3c1bb5e2e94a74d9916649"
},
{
"rev": 2240,
"op": "sage-190-add",
"verb": "add",
"args": {
"piece": "several Mosaic Stack instances on one host: per-instance config, data root, units and ports (MVP T1.1, mos-0974, Vikunja #414)",
"gate": "Filbert approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the design mutants killed",
"brief": "docs/plans/2026-10-20_multi-instance.md#Brief: several Mosaic Stack instances on one host",
"issues": [
1688
],
"note": null,
"owner": "darkwing",
"gateOwner": "sage",
"after": null,
"reviewers": [
"filbert"
],
"required": null
},
"by": "sage",
"at": "2026-10-20T18:42:48.052Z",
"semantics": 2,
"result": {
"row": 190,
"from": null,
"to": "queued",
"brief": {
"path": "docs/plans/2026-10-20_multi-instance.md",
"anchor": "Brief: several Mosaic Stack instances on one host",
"blob": "79fa60fbf17a352edc5431d6aabc77c72ec5c48f"
},
"receipt": "ok sage-190-add rev 2240 row 190 none→queued"
},
"viewSha": "b4319dbd993254b58e4db8be18769b04322028903e325c0c8cadcfdec527929f"
},
{
"rev": 2241,
"op": "sage-191-add",
"verb": "add",
"args": {
"piece": "one kill switch for the bus, every run, Discord and new launches, from the CLI and the Console (MVP T1.4, mos-0974, Vikunja #414)",
"gate": "Dewey approves DESIGN.md then the manifest; cli, bus, discord and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed",
"brief": "docs/plans/2026-10-20_kill-switch.md#Brief: one kill switch for the bus, every run, Discord and new launches",
"issues": [
1689
],
"note": null,
"owner": "rocko",
"gateOwner": "sage",
"after": null,
"reviewers": [
"dewey"
],
"required": null
},
"by": "sage",
"at": "2026-10-20T18:43:05.222Z",
"semantics": 2,
"result": {
"row": 191,
"from": null,
"to": "queued",
"brief": {
"path": "docs/plans/2026-10-20_kill-switch.md",
"anchor": "Brief: one kill switch for the bus, every run, Discord and new launches",
"blob": "aed33125fa67ae69af73c18ffc9e2a83481421f3"
},
"receipt": "ok sage-191-add rev 2241 row 191 none→queued"
},
"viewSha": "2cdc7c37430226f1ea01ed9f3d60a2c12be16eddac38d18dc16f889c7b54b14e"
},
{
"rev": 2242,
"op": "sage-190-brief",
"verb": "move",
"args": {
"id": 190,
"to": "briefed",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "sage",
"at": "2026-10-20T18:43:25.630Z",
"semantics": 2,
"result": {
"row": 190,
"from": "queued",
"to": "briefed",
"round": null,
"issue": null,
"candidate": null,
"receipt": "ok sage-190-brief rev 2242 row 190 queued→briefed"
},
"viewSha": "6203315b484302369ce5ea7d854082a6bca4a95f9793e14ec123f57381234c95"
},
{
"rev": 2243,
"op": "sage-191-brief",
"verb": "move",
"args": {
"id": 191,
"to": "briefed",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "sage",
"at": "2026-10-20T18:43:43.042Z",
"semantics": 2,
"result": {
"row": 191,
"from": "queued",
"to": "briefed",
"round": null,
"issue": null,
"candidate": null,
"receipt": "ok sage-191-brief rev 2243 row 191 queued→briefed"
},
"viewSha": "9cc7dda5fa676258e864dedfdf2f4e3904c8723ba9c36474cff76757f52e5c07"
},
{
"rev": 2244,
"op": "rocko-191-start",
"verb": "move",
"args": {
"id": 191,
"to": "in-progress",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "rocko",
"at": "2026-10-20T18:45:05.621Z",
"semantics": 2,
"result": {
"row": 191,
"from": "briefed",
"to": "in-progress",
"round": null,
"issue": null,
"candidate": null,
"receipt": "ok rocko-191-start rev 2244 row 191 briefed→in-progress"
},
"viewSha": "cd2423e53ac82a667c737852bef6adfdafba82987f37cd090f5484b15b3a0e0c"
},
{
"rev": 2245,
"op": "darkwing-190-start",
"verb": "move",
"args": {
"id": 190,
"to": "in-progress",
"reason": null,
"candidate": null,
"evidence": null,
"issue": null
},
"by": "darkwing",
"at": "2026-10-20T18:45:23.430Z",
"semantics": 2,
"result": {
"row": 190,
"from": "briefed",
"to": "in-progress",
"round": null,
"issue": null,
"candidate": null,
"receipt": "ok darkwing-190-start rev 2245 row 190 briefed→in-progress"
},
"viewSha": "014db845fb2e380678ab1250f2cd3c0d7b192dc39909416515482c810440149b"
},
{
"rev": 2246,
"op": "sage-189-inreview-1",
"verb": "move",
"args": {
"id": 189,
"to": "in-review",
"reason": null,
"candidate": "agents/sage/work/queue-189/candidate-manifest.sha256",
"evidence": null,
"issue": null
},
"by": "sage",
"at": "2026-10-20T18:46:06.124Z",
"semantics": 2,
"result": {
"row": 189,
"from": "in-progress",
"to": "in-review",
"round": 1,
"issue": 1687,
"candidate": {
"kind": "manifest",
"digest": "67af69bd579561fd8c20be2a47695240a9f09f119dbfff88a8e72e2a7416e668",
"text": "70dbc89c0282fa063514317262d8926d16b8b8aaab4a1e47379303918495fc2c agents/sage/work/queue-189/BUILD.md\n4bd216cce2b40b39998725daa8989f6e3acb3ccd81a7017f6b09026af3965584 agents/sage/work/queue-189/mutants-run.log\nddbdddf03abf4aa91aa62f52e468be7af3c6b2f5d7bec2ee99b11e315471f18c agents/sage/work/queue-189/mutants.sh\n5d7b2da84d994f4f93d1212e349b4e4c19c5c00780e6cfcde91edb3ca08c014e docs/plans/2026-10-20_activate-no-send.md\n38e410c4cb731390f96747646eb7f6caa7a587fca9024ccbe901aab5ac19c7cf docs/TOOLS.md\nfe430969cc669515e348fced1a22cbe839a12023f7ac67cfba905b7afd29872f packages/cli/tests/stack-release.test.mjs\n16f77305e558f4fb953accc6d09f1f7b408aba363851d8ca79216ffce41bed9f scripts/stack-release.sh\n"
},
"receipt": "ok sage-189-inreview-1 rev 2246 row 189 in-progress→in-review round 1 on #1687; request sage-189-inreview-1 requesting"
},
"viewSha": "d8a3aa936a9a819329e86679d0bfcf658d773d0f7cfd29c10587c6447f78e968"
},
{
"rev": 2247,
"op": "sage-189-inreview-1.outcome",
"verb": "review-outcome",
"args": {
"id": 189,
"attempt": "sage-189-inreview-1",
"outcome": "posted",
"status": 201,
"comment": 29457,
"detail": "created"
},
"by": "sage",
"at": "2026-10-20T18:46:24.267Z",
"semantics": 2,
"result": {
"row": 189,
"round": 1,
"attempt": "sage-189-inreview-1",
"state": "posted",
"receipt": "ok sage-189-inreview-1.outcome rev 2247 row 189 round 1 request sage-189-inreview-1 posted comment 29457"
},
"viewSha": "6d7e91ae3cab0b76e5c5d791fb821b97c9da7db16389ff2d531d7b77d2a069f1"
}
]
}