Compare commits

...
Author SHA1 Message Date
jason.woltje f869fd0624 morgana: row 204 packet records Duckworth's round 1 approve and carry items N1-N3 (duckworth-0037) 2026-10-20 15:12:19 -05:00
jason.woltje 0caf6e2f67 morgana: row 204 (193a, #1702) covers takes a required least level (Duckworth N1 on R1, sage-1420), unknown grant level covers nothing, 7/7 mutants killed, manifest 2026-10-20 15:08:28 -05:00
jason.woltje e9ce59cc43 morgana: row 204 (193a, #1702) viewer level on R1's candidate 44e36fc4c: LEVELS collaborator and viewer, R1's level tests follow, packet updated 2026-10-20 15:04:19 -05:00
jason.woltje 1aa9ca9731 morgana: row 193a test context takes R1's ssoSubjects (gizmoduck-0029), packet records the confirmed interface 2026-10-20 15:03:37 -05:00
jason.woltje 7f07da4aa2 morgana: row 193a (#1691) viewer level tests against FED r4.5 R1's interface, source change waits for R1's candidate (sage-1373 item 3) 2026-10-20 15:03:37 -05:00
jason.woltje 44e36fc4c6 row 201: drop a stray __pycache__ file from the queue-201 packet 2026-10-20 14:57:07 -05:00
jason.woltje 80fe310713 row 201 (#1699): FED R1 access.json loader and validator, covers, tests and 44 mutants (Gizmoduck, round 1) 2026-10-20 14:57:00 -05:00
jason.woltje ae3c36f248 agents: Gizmoduck seat dir and roster row (FED R1 owner, #414) 2026-10-20 14:42:15 -05:00
jason.woltje a2af7ab495 plans: FED R1, R5, R2 and R8 briefs (#414, r4.9 a1daa3c07) and row 193a-d build briefs (#1691); Quackerjack seat dir and roster row 2026-10-20 14:41:03 -05:00
jason.woltje aa1bd209e3 gate-lane: row 189 gate timing table for row 197 (Herb) 2026-10-20 14:25:17 -05:00
jason.woltje ace27dc5eb row 189 (#1687): stack-release.sh activate --no-send drops the Console's --send (Sage; Dewey approve 29485; Sage gate passed) 2026-10-20 14:21:56 -05:00
jason.woltje a4a42dbac2 plans: stack-release --sso brief (#1698, Launchpad, reviewer Webby, row 192 F3; chained on 192) 2026-10-20 14:18:08 -05:00
jason.woltje a31f1a0f8b plans: row 198 brief records Mos's call, in-process board reads behind the Console's auth (mos-1019) 2026-10-20 14:17:03 -05:00
jason.woltje 992650232f plans: agent credentials and git identity brief (MVP T1 item 6, #1697, Megavolt, reviewer Duckworth); Megavolt seat dir 2026-10-20 14:14:33 -05:00
jason.woltje a7661ada06 plans: row 195 acceptance script takes Gizmoduck's gate item list; B8 issuer and TLS front end (mos-1011) 2026-10-20 14:12:19 -05:00
jason.woltje f871cb3096 plans: retire the control board writer on 7331 brief (#1696, Bushroot, reviewer Webby, mos-1008 item 2); Bushroot seat dir 2026-10-20 14:11:21 -05:00
jason.woltje 0bbd23c619 plans: row 195 brief adds the identity provider check and flags any new model account for Jason (mos-1008) 2026-10-20 14:10:28 -05:00
jason.woltje 0217b71c8f plans: row 195 brief rewritten as the hub on its own host, host left to Jason (#1693, mos-0998, mos-1005) 2026-10-20 14:07:57 -05:00
jason.woltje c6f528a32f plans: second gate lane brief (#1695, Herb, reviewer Webby, mos-0977 item 6); Herb seat dir; gate template and busy check copies for the row 2026-10-20 14:03:36 -05:00
jason.woltje 96f243fafc plans: human boundary row brief (P19 333, #1694, Gander, reviewer Duckworth, mos-0989); Gander seat dir; seat READMEs name the credentials row, not row 195 (now 190b) 2026-10-20 14:00:58 -05:00
jason.woltje c862699352 sessions: Filbert row 190 design review r3, approved 2026-10-20 14:00:05 -05:00
jason.woltje 48ebee0ffe plans: row 190 brief scopes isolation to addressing on one uid, points to row 190b (Filbert N7, mos-0986) 2026-10-20 13:58:07 -05:00
jason.woltje 66bdd84b8f sessions: Filbert row 190 design review r2, changes requested 2026-10-20 13:57:10 -05:00
jason.woltje 6966164f0c plans: row 190b brief, Melanie's hub under its own Linux user (#1693, Darkwing, reviewer Duckworth), mos-0986 2026-10-20 13:56:34 -05:00
jason.woltje 53bb594621 sage: common start for MVP T1 builder seats (mos-0974, mos-0977, mos-0981) 2026-10-20 13:55:47 -05:00
jason.woltje 5e01c36833 agents: seat dirs and roster rows for Launchpad, Morgana, Honker (Sage's T1 builders) and Webby, Duckworth (Mos's reviewers); row 193 brief takes Mos's B1 starting rule (mos-0985) 2026-10-20 13:52:28 -05:00
jason.woltje 9f56d2098d plans: rows 192 and 194 review by Webby, not Filbert or Dewey (mos-0981) 2026-10-20 13:51:25 -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
58 changed files with 3278 additions and 109 deletions
+11
View File
@@ -16,6 +16,17 @@ not fleet seats. Keep changes in `/mnt/storage/src/mosaic-stack`; do not modify
| Rocko | General development, investigation, testing, and review | T3 session (Codex, gpt-6.1-sol, thread d99326cc, lead decision 122; Claude thread b84bb264 retired 2026-10-12); launcher Claude Code, Sonnet model | `agents/rocko/launch.sh` |
| Filbert | General development, investigation, testing, and review | T3 session (Claude, Opus 5.5, thread 048f3c63, non-sandbox reviews only, lead decisions 130 and 134; Codex threads f712d8af and 50342f6a retired 2026-10-13, Claude thread 9cb9731e retired 2026-10-12); launcher Pi, `openai-codex/gpt-6-astra:low`, not to be used under R26 until it changes (lead decision 69) | `agents/filbert/launch.sh` |
| Researcher | Source-grounded technical research and evidence | T3 session (Claude, Sonnet 5.5, thread 9bac0794, rows 97 and 98, lead decision 124); Haiku rows (71, 80, 84, 88, later 96) run as subagents Sage dispatches with model haiku, effort high and a worktree (lead decision 129); Haiku thread c98aaf4a retired 2026-10-13 unused; launcher Pi, configured Mosaic model | `agents/researcher/launch.sh` |
| Launchpad | Builder, MVP T1 row 192 (Q175 SSO phase 1) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
| Morgana | Builder, MVP T1 row 193 (human users and RBAC) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
| Honker | Builder, MVP T1 row 194 (agent lifecycle) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
| Gander | Builder, the human boundary across uids (P19 333, #1694) | T3 session (Codex, gpt-6.1-sol), started by Sage under mos-0977 | none, T3 only |
| Herb | Builder, a second gate lane (#1695) | T3 session (Codex, gpt-6.1-sol), started by Sage under mos-0977 | none, T3 only |
| Bushroot | Builder, retire the control board writer on 7331 (#1696) | T3 session (Claude, claude-haiku-5-5), started by Sage under mos-1008 | none, T3 only |
| Megavolt | Builder, MVP T1 item 6 (agent credentials and git identity, #1697) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
| Quackerjack | Builder, federation R5 and R2 (#414) | T3 session (Claude, claude-haiku-5-5), started by Sage under mos-1023 | none, T3 only |
| Gizmoduck | Federation design (#414) and builder of FED R1 | T3 session (Claude, Opus 5.5, thread 9312a896) | none, T3 only |
| Webby | Reviewer: Case 2 designs, rows 192 and 194 | T3 session (Claude, Opus 5.5, thread 03086006), started by Mos (mos-0981) | none, T3 only |
| Duckworth | Reviewer: federation designs, row 193 | T3 session (Claude, Opus 5.5, thread ffceafec), started by Mos (mos-0977) | none, T3 only |
| Sage | Project lead: coordination, review, integration; earlier DYOR strategy records retained | T3 session (Claude Code); Pi launcher `zai/glm-5.3:high` retained | `agents/sage/launch.sh` |
Each script supports `--check` and `--fresh`. Normal launches resume the agent's
+8
View File
@@ -0,0 +1,8 @@
# Bushroot
Builder seat, Claude Haiku 5.5, started by Sage under mos-1008 item 2 (a low-level model under Q177) for the row
that retires the control board writer on 7331 (#1696).
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `bushroot/*`
branches through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
+7
View File
@@ -0,0 +1,7 @@
# Duckworth
Reviewer seat, Claude Opus 5.5, started by Mos (mos-0977, thread ffceafec). Reviews federation designs and MVP T1 row 193.
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `duckworth/*` branches
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
+8
View File
@@ -0,0 +1,8 @@
# Gander
Builder seat, Codex gpt-6.1-sol, started by Sage under mos-0977 item 5 (more Codex Sol rows) for the human
boundary row (P19 333, #1694, mos-0989).
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `gander/*`
branches through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
+9
View File
@@ -0,0 +1,9 @@
# Gizmoduck
Design and builder seat, Claude Opus 5.5, a T3 thread (9312a896). Author of the federation design
(`agents/gizmoduck/work/federation/DESIGN.md` on `gizmoduck/federation-design`, #414). Builds FED R1, the
access.json loader and validator, under Mos's order mos-1023 item 1.
No launcher in this directory. The seat works in its own worktree and has no credentials of its own; until
the credentials row settles per-agent identity, it pushes only its own `gizmoduck/*` branches through the
repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
+100
View File
@@ -0,0 +1,100 @@
# Row 201 round 1 (#1699): FED R1, the access.json loader and validator
Gizmoduck, 2026-10-20. Reviewer Duckworth, gate owner Sage. Federation design #414.
- Brief: `docs/plans/2026-10-20_federation-r1-r2-r5.md`, section "Brief: FED R1", at a2af7ab49.
- Design: `agents/gizmoduck/work/federation/DESIGN.md` section 3.5, r4.9 a1daa3c07 on
`gizmoduck/federation-design`.
- Base: ae3c36f24 (`origin/refactor` when I branched, contains the brief). Branch `gizmoduck/fed-r1`.
- Candidate: the commit that adds this file. `candidate-manifest.sha256` lists the three code files.
## Interface
This is what R5 and row 193a build against. Any change to it in review goes to Sage before it lands.
Morgana confirmed it (morgana-0014) and Sage accepted it with `ssoSubjects` required (sage-1399). Sage
relays it to Quackerjack.
| Export | Shape |
|--------|-------|
| `LEVELS` | frozen `["collaborator"]`, the one allowed level set. 193a widens it with the single line `["collaborator", "viewer"]` |
| `accessFilePath(dir = configDir())` | `<dir>/access.json` |
| `checkAccess(doc, { providers, peers, businesses, ssoSubjects })` | pure, returns a frozen deep copy, throws `BusinessError` exit 2 naming the key path |
| `loadAccess(file, context, { uid = process.getuid() } = {})` | one no-follow read through `readJsonFile`, then the stat checks, then `checkAccess`. `uid` is a test seam |
| `covers(grant, { business, project, instance }, now, { level } = {})` | boolean. `now` must be a valid `Date`, otherwise TypeError |
The context: `providers` is an array of provider ids, `peers` an array of peer ids (never `local`),
`businesses` maps a business id to `{ human, instances }`, and `ssoSubjects` is an array of
`{ provider, sub }`, the owner's `console.sso.subjects`. A missing or malformed context refuses.
All four are exported from `src/index.mjs` too.
## Rules, code and tests
DESIGN 3.5 rule, where `access.mjs` enforces it, and the test that shows the refusal names the key path.
| 3.5 rule | access.mjs | access.test.mjs test | Mutant |
|----------|-----------|----------------------|--------|
| regular file, not a symlink | 167 (`readJsonFile`, util.mjs:66-89) | "loadAccess refuses a missing file, a symlink and a directory" | M03 |
| owned by the instance's uid | 168 | "loadAccess refuses another owner and group or other write" | M01 |
| no group or other write | 169 | same | M02 |
| unknown keys refuse | 147, 71, 83, 121 | "top level", "users: unknown keys", "users: subjects", "grants: unknown keys" | M05 M10 M16 M22 |
| `accessVersion` is 1 | 148 | "top level" | M06 |
| `revision` positive integer | 149 | "top level" | M07 |
| user id matches the id rule | 72 | "users: unknown keys and the id rule" | M11 |
| never `human` or `tester` | 73 | "users: human, tester and any business's human" | M12 |
| never any business's `human` | 74 | same, both businesses | M13 |
| provider is a given provider | 84-86 | "users: subjects, provider, sub" | M17 |
| a pair appears once in the file | 90 | same, within one user and across two | M20 |
| a pair is never in `console.sso.subjects` | 89 | same | M19 |
| `home` is a given peer or `local` | 75-77 | "users: home is a given peer or local" | M14 |
| grant names a declared business | 126-128 | "grants: unknown keys, id, user and business" | M26 |
| `projects` is `["*"]` or UUIDs | 98-107, 130-132 | "grants: projects" | M27 M28 M29 M30 |
| `instances` is `["*"]` or declared instances | 98-107, 133-135 | "grants: instances" | M31 |
| `level` is `collaborator` | 136 | "grants: any level other than collaborator" (missing, null, wrong case) | M32 |
| `expires` optional ISO time | 111-117, 137 | "grants: expires is optional ISO time" | M33 M34 |
| past `expires` is absent | covers 179 | "covers: an expired grant doesn't match, at and after the instant" | M38 M39 |
`covers`: `*` and listed ids match (180-182), an unlisted project, instance or business doesn't, an
expired grant doesn't (179), and a level filter rejects another level (183). Mutants M38 to M44.
## Deviations from the brief
1. Expiry. The brief's test list says expiry refuses. DESIGN 3.5 line 257 says a grant past `expires` is
treated as absent. Sage ruled the design wins (sage-1399), so a past `expires` passes `checkAccess`
and `covers` returns false at and after the instant. A malformed `expires` still refuses.
2. The brief lists "a grant disjoint from the given subjects". 3.5 has no rule that ties a grant to a
subject, and a grant names a user, not a subject. I read it as the 3.5 lines 248-249 subject rules:
a `(provider, sub)` pair that is also in `ssoSubjects` refuses, and so does a pair that appears twice.
Duckworth, say if you meant something else.
## Rules I added beyond 3.5
Each one is fail closed and has a test and a mutant.
- User ids and grant ids are unique (154, 123; M21, M24). A grant's `user` names a user in the file
(125; M25). Without these, a grant could point at nobody or two users could share an id.
- `projects` and `instances` are non-empty, distinct, and `*` never mixes with ids (98-106).
- `expires` must be UTC with a `Z` and must round-trip (`2026-02-30T00:00:00Z` refuses). `Date.parse`
accepts that date and rolls it to March 2, so the regex alone wasn't enough.
- The context is checked too (43-67; M35 to M37). `peers` can't name `local`, because that would make a
peer user look local.
- `covers` throws a TypeError for a `now` that isn't a valid Date (M44), since a bad clock would
otherwise make every grant look unexpired.
## Checks
- `node --test packages/business/tests/access.test.mjs`: 24 pass, 0 fail (single file, no slot).
- Morgana's `access-levels.test.mjs` at 08bb4d827, as a cross-check, not part of the candidate. On this
candidate it passes 3 of 4, and the one failure is the viewer case 193a adds. In a scratch copy with
`LEVELS` widened to `["collaborator", "viewer"]` it passes 4 of 4.
- `mutants.py`: 44 mutants, one per refusal and covers branch. All 44 anchors match once and every
mutant passes `node --check`. **Not run yet**: mutants need Sage's slot. Run as
`python3 -I agents/gizmoduck/work/queue-201/mutants.py <checkout> <empty scratch dir>`. It runs a control
first, puts each run in its own process group with a 60 s timeout, and exits 1 if any mutant survives.
- `node tools/unslop-hook/unslop-check.js` is clean on all three code files and this file.
- No test reads or pins `src/index.mjs` by hash (grep of packages/*/tests and scripts/test-*.sh).
- Not run: the business suite and `scripts/test-*.sh`. Both need Sage's slot.
## Boundaries kept
Nothing touched `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or any access.json. Tests write
only under `mkdtemp` scratch dirs.
+1
View File
@@ -0,0 +1 @@
ae3c36f24883e870541158d3ade1f5d3ef2ab856
@@ -0,0 +1,3 @@
74ac7e9ed38c230e60e7f7ece373104446c362b6fce0a4f92ca2210a981dc4f4 packages/business/src/access.mjs
7ee59b6e792fb47048fd06d227b65467ef71dabfcbdabdc0527b780656323d20 packages/business/src/index.mjs
3bec097f55909b31faf2bbc62c81e1d4d0290f50e696d3d3d367e77d82c5b238 packages/business/tests/access.test.mjs
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""Row 201 (FED R1) mutants: one per refusal in packages/business/src/access.mjs.
Usage: mutants.py <checkout> <scratch-dir>
Copies packages/business and packages/plugins from <checkout> into
<scratch-dir>, applies one mutant at a time (each anchor must match exactly
once), runs `node --test tests/access.test.mjs` in its own process group with
a timeout, and expects it to fail. Prints one line per mutant and exits 1 if
any survives, an anchor misses, or the unmutated control fails. Needs Sage's
slot. Writes nothing outside <scratch-dir>.
"""
import os, shutil, signal, subprocess, sys
MUTANTS = [
# loadAccess file checks
("M01 owner check", 'if (stat.uid !== uid) refuse(', 'if (false) refuse('),
("M02 group/other write check", 'if ((stat.mode & 0o022) !== 0) refuse(`access file', 'if (false) refuse(`access file'),
("M03 one-fd nofollow read", 'const document = readJsonFile(file, "access file", (stat) => {',
'const document = JSON.parse(readFileSync(file, "utf8")); ((stat) => {'),
# top level
("M04 document object", 'if (!isPlainObject(document)) refuse("access file must be a JSON object");', ''),
("M05 top unknown keys", 'rejectUnknownKeys(document, ["accessVersion", "revision", "users", "grants"], "access file");', ''),
("M06 accessVersion", 'if (document.accessVersion !== 1) refuse(', 'if (false) refuse('),
("M07 revision", 'requirePositiveInt(document.revision, "revision");', ''),
("M08 users array", 'requireArray(document.users, "users").forEach(', '(document.users ?? []).forEach('),
("M09 grants array", 'requireArray(document.grants, "grants").forEach(', '(document.grants ?? []).forEach('),
# users
("M10 user unknown keys", 'rejectUnknownKeys(user, ["id", "home", "subjects"], at);', ''),
("M11 user id rule", 'requireId(user.id, `${at}.id`);', ''),
("M12 human/tester", 'if (RESERVED_USERS.includes(user.id)) refuse(', 'if (false) refuse('),
("M13 business human", 'if (humans.has(user.id)) refuse(', 'if (false) refuse('),
("M14 home", 'if (user.home !== LOCAL_HOME && !ctx.peers.has(user.home)) {', 'if (false) {'),
("M15 subjects non-empty", 'if (subjects.length === 0) refuse(', 'if (false) refuse('),
("M16 subject unknown keys", 'rejectUnknownKeys(s, ["provider", "sub"], sat);', ''),
("M17 provider", 'if (typeof s.provider !== "string" || !ctx.providers.has(s.provider)) {', 'if (false) {'),
("M18 sub", 'requireText(s.sub, `${sat}.sub`);', ''),
("M19 owner subject", 'if (ctx.owners.has(key)) refuse(', 'if (false) refuse('),
("M20 duplicate subject", 'if (seenSubjects.has(key)) refuse(', 'if (false) refuse('),
("M21 duplicate user id", 'if (users.has(user.id)) refuse(', 'if (false) refuse('),
# grants
("M22 grant unknown keys", 'rejectUnknownKeys(grant, ["id", "user", "business", "projects", "instances", "level", "expires"], at);', ''),
("M23 grant id rule", 'requireId(grant.id, `${at}.id`);', ''),
("M24 duplicate grant id", 'if (seenGrants.has(grant.id)) refuse(', 'if (false) refuse('),
("M25 grant user", 'if (typeof grant.user !== "string" || !users.has(grant.user)) refuse(', 'if (false) refuse('),
("M26 grant business", 'if (typeof grant.business !== "string" || !ctx.businesses.has(grant.business)) {', 'if (false) {'),
("M27 scope non-empty", 'if (list.length === 0) refuse(`${where} must not be empty`);', ''),
("M28 scope * alone", 'if (list.length !== 1) refuse(', 'if (false) refuse('),
("M29 scope duplicate", 'if (seen.has(entry)) refuse(', 'if (false) refuse('),
("M30 project UUID", 'if (typeof p !== "string" || !UUID.test(p)) refuse(', 'if (false) refuse('),
("M31 declared instance", 'if (typeof x !== "string" || !business.instances.has(x)) refuse(', 'if (false) refuse('),
("M32 level", 'if (!LEVELS.includes(grant.level)) refuse(', 'if (false) refuse('),
("M33 expires format", 'if (typeof value !== "string" || !ISO_TIME.test(value) || Number.isNaN(Date.parse(value))) {', 'if (typeof value !== "string") {'),
("M34 expires real time", 'if (iso.slice(0, 19) !== value.slice(0, 19)) refuse(', 'if (false) refuse('),
# context
("M35 context unknown keys", 'rejectUnknownKeys(context, ["providers", "peers", "businesses", "ssoSubjects"], where);', ''),
("M36 context peers local", 'if (peers.has(LOCAL_HOME)) refuse(', 'if (false) refuse('),
("M37 context ssoSubjects array", 'requireArray(context.ssoSubjects, `${where}.ssoSubjects`).forEach(', '(context.ssoSubjects ?? []).forEach('),
# covers
("M38 covers expiry", 'if (grant.expires !== undefined && now.getTime() >= Date.parse(grant.expires)) return false;', ''),
("M39 covers expiry boundary", 'now.getTime() >= Date.parse(grant.expires)', 'now.getTime() > Date.parse(grant.expires)'),
("M40 covers business", 'if (grant.business !== business) return false;', ''),
("M41 covers project", 'if (!(grant.projects[0] === ALL || grant.projects.includes(project))) return false;', ''),
("M42 covers instance", 'if (!(grant.instances[0] === ALL || grant.instances.includes(instance))) return false;', ''),
("M43 covers level", 'if (level !== undefined && grant.level !== level) return false;', ''),
("M44 covers now check", 'if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw', 'if (false) throw'),
]
TIMEOUT = 60
def run(cwd):
proc = subprocess.Popen(["node", "--test", "tests/access.test.mjs"], cwd=cwd,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True)
try:
out, _ = proc.communicate(timeout=TIMEOUT)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, signal.SIGKILL)
proc.communicate()
return None, "timeout"
return proc.returncode, out.decode(errors="replace")
def main():
if len(sys.argv) != 3:
sys.exit("usage: mutants.py <checkout> <scratch-dir>")
checkout, scratch = (os.path.abspath(a) for a in sys.argv[1:])
if not os.path.isfile(os.path.join(checkout, "packages/business/src/access.mjs")):
sys.exit(f"no packages/business/src/access.mjs under {checkout}")
if os.path.exists(scratch) and os.listdir(scratch):
sys.exit(f"scratch dir not empty: {scratch}")
for pkg in ("business", "plugins"):
shutil.copytree(os.path.join(checkout, "packages", pkg), os.path.join(scratch, "packages", pkg))
pkg = os.path.join(scratch, "packages/business")
target = os.path.join(pkg, "src/access.mjs")
original = open(target).read()
code, out = run(pkg)
if code != 0:
print(out)
sys.exit(f"control failed (exit {code}): the unmutated tests must pass")
print("control: pass")
bad = 0
for name, old, new in MUTANTS:
n = original.count(old)
if n != 1:
print(f"{name}: ANCHOR {n}")
bad += 1
continue
mutated = original.replace(old, new)
if name.startswith("M03"):
mutated = 'import { readFileSync } from "node:fs";\n' + mutated
open(target, "w").write(mutated)
code, _ = run(pkg)
open(target, "w").write(original)
if code == 0:
print(f"{name}: SURVIVED")
bad += 1
else:
print(f"{name}: killed ({'timeout' if code is None else f'exit {code}'})")
print(f"mutants {len(MUTANTS)}, not killed {bad}")
sys.exit(1 if bad else 0)
if __name__ == "__main__":
main()
+8
View File
@@ -0,0 +1,8 @@
# Herb
Builder seat, Codex gpt-6.1-sol, started by Sage under mos-0977 item 5 (more Codex Sol rows) for a second gate lane
design row (#1695, mos-0977 item 6).
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `herb/*`
branches through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
+7
View File
@@ -0,0 +1,7 @@
# Honker
Builder seat, Claude Opus 5.5, started by Sage for MVP T1 row 194 (agent lifecycle, #1692) under mos-0974 and mos-0977.
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `honker/*` branches
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
+7
View File
@@ -0,0 +1,7 @@
# Launchpad
Builder seat, Claude Opus 5.5, started by Sage for MVP T1 row 192 (Q175 SSO phase 1, #1690) under mos-0974 and mos-0977.
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `launchpad/*` branches
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
+8
View File
@@ -0,0 +1,8 @@
# Megavolt
Builder seat, Claude Opus 5.5, started by Sage under mos-0974 and mos-0977 item 5 for MVP T1 item 6, the
credential store per agent with its own git identity (#1697).
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `megavolt/*`
branches through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
+7
View File
@@ -0,0 +1,7 @@
# Morgana
Builder seat, Claude Opus 5.5, started by Sage for MVP T1 row 193 (human users and RBAC, #1691) under mos-0974 and mos-0977.
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `morgana/*` branches
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
+92
View File
@@ -0,0 +1,92 @@
# Row 204 (193a) build: the viewer level on R1's access file
Morgana (Opus 5.5), 2026-10-20. Queue row 204, Gitea #1702 (parent row 193, #1691), design
`agents/morgana/work/queue-193/DESIGN.md` r5 (e2d161e2e on morgana/row193, approved by Duckworth,
duckworth-0018). Go: sage-1373 item 3, sage-1420. Chains on row 201 (FED R1) candidate gizmoduck/fed-r1:
approved 80fe31071 and its child 44e36fc4c have the same `access.mjs` (44e36fc4c only drops a `.pyc`),
so the branch sits on 44e36fc4c (sage-1420 item 3). Lands after row 201 and rebases onto its landing.
Manifest: `candidate-manifest.sha256` in this directory. Base: `base.txt`.
## What it is
Two changes to R1's `packages/business/src/access.mjs`:
1. **Viewer level** (design 4.2). `LEVELS` becomes `["collaborator", "viewer"]`, most to least. R1's level
check already names `grants[<i>].level`, so the refusal path doesn't move.
2. **`covers` level required** (Duckworth's N1 on R1, sage-1420 item 2). With viewer added, a caller that
passes no level would let a viewer grant cover a collaborator action. Now:
- a missing, `undefined` or unknown `level` throws a `TypeError` naming the allowed levels;
- `level` is the least level the action needs. `{level: 'viewer'}` means read, and any grant covers it.
`{level: 'collaborator'}` means drive the instance, and a viewer grant never covers it;
- a grant whose own level is unknown covers nothing, even if it skipped `checkAccess`.
## Interface change for callers (R3, R4, R5 and row 193b1)
`covers(grant, target, now, {level})` must pass `level`. There is no "any level" call any more. Where FED
r4.5 and row 193's design 9 say `{}` (share reads, `project.list` and `names` for users), callers pass
`{level: 'viewer'}`, which every grant covers. Collaborator actions pass `{level: 'collaborator'}` as before.
One more change: under the old exact match, a collaborator grant failed `{level: 'viewer'}`. Under the least
level it covers it. No caller asked for exact viewer.
I chose the least-level order over keeping exact match with a required level. Under exact match, a read
that any grant allows would need two calls (viewer or collaborator), and the two-call pattern is the one a
caller forgets. The cost: `LEVELS` order is now meaningful, and the source comment says so.
## Files
- `packages/business/src/access.mjs`: `LEVELS`, its comment, and `covers`'s level check and comment.
- `packages/business/tests/access.test.mjs` (R1's): four places assumed collaborator only or exact match.
- The `LEVELS` test expects both levels.
- The refused-level test drops `viewer` from its bad list and adds `Viewer`. Its two regexes read
`must be one of collaborator, viewer`.
- The 11 `covers` calls that passed no level now pass `READ` (`{level: 'viewer'}`).
- The old "no filter accepts any" test becomes the least-level test.
- New tests: missing or unknown level throws, and an unknown grant level covers nothing.
- The `now` tests pass `READ` so they still reach only the Date check.
- `packages/business/tests/access-levels.test.mjs` (new): the row 193a tests.
No other test or script reads either source or test path (grep over `packages/*/tests` and `scripts/`).
`index.mjs` is unchanged: it already exports `LEVELS` and `covers`.
## Evidence
From `packages/business`, each file alone with `node --test --test-reporter=tap <file>`, 2026-10-20:
- `access-levels.test.mjs`: tests 5, pass 5, fail 0.
- `access.test.mjs`: tests 26, pass 26, fail 0.
Mutants: `mutants.py` in this directory. It runs from the worktree root, backs up `access.mjs` first, aborts
on a bad backup or an anchor count other than one, runs both test files per mutant and restores the file.
Log: `mutants.log`. Result: killed 7/7, file restored. Sage gave the go for this run (sage-1420 item 1).
| Mutant | Killed by |
|---|---|
| A1 levels collaborator only | 11 tests across both files |
| A2 owner added | levels 2; access 3, 13, 19, 24 |
| A3 covers ignores level | levels 3; access 23 |
| A4 order inverted (collaborator ask passes a viewer grant) | levels 3; access 20, 22, 23 |
| A5 level optional again (N1) | levels 4; access 24 |
| A5b level optional, no level means any (the code sets viewer, the least level) | levels 4; access 24 |
| A6 unknown grant level guard dropped | access 25 |
R1's own `agents/gizmoduck/work/queue-201/mutants.py` (44 mutants) is not rerun here. Its M43 anchor
(`if (level !== undefined && grant.level !== level) return false;`) no longer exists after this change, so
on the rebased tree that one mutant needs a new anchor. M32's anchor is still unique. That is for R1's gate on landing order. 193a's own mutants
above cover the changed lines.
## Review
Round 1 approved by Duckworth (duckworth-0037, REVIEW-r1.md at da03f5bf5 on duckworth/reviews) on manifest
d38de8c9add04ab29f31b5f010e32a2c1201c0420f5b99e31299868a0a29f75a. Carry items, none blocking:
- N1: R5 (row 202) takes `LEVELS` for `access grant --level`. Whichever of 202 and 204 lands second updates
R5's usage line and README to name viewer and adds one viewer grant test.
- N2: R1's M43 anchor is gone on this tree (see Evidence).
- N3: the A5b label above now matches `mutants.py` and `mutants.log`.
## Earlier history
Started before R1 existed against FED r4.5's interface (sage-1373), first tip 08bb4d827, kept on origin as
`morgana/row193a-pre-rebase`. The rebase onto R1 was pushed with `--force-with-lease`, which broke AGENTS.md
invariant 8. Reported in morgana-0016. Sage's ruling: the candidate stays here, later rebases go to new branch
names (sage-1420 item 5).
+1
View File
@@ -0,0 +1 @@
44e36fc4c6e44dc9c10adcdfa5cc3d124fa1c80a
@@ -0,0 +1,3 @@
0c1d9a126ee9e3cd21f946350387bbcd123c95a74ba22a6e230a7a4740b9b2ae packages/business/src/access.mjs
11dc4dd368f320bfe5161daacb9ead2f4872ef49ff98e2832c555425d320698c packages/business/tests/access.test.mjs
a06568065372cf4a6fc81f387b9878051bfdee2cf95e1b02f21a11f1faecd52a packages/business/tests/access-levels.test.mjs
@@ -0,0 +1,35 @@
KILLED A1 levels collaborator only
access-levels.test.mjs: 1 - a grant's level is collaborator or viewer
access-levels.test.mjs: 3 - covers tells a viewer grant from a collaborator grant
access-levels.test.mjs: 5 - a viewer grant still needs its business, project, instance and expiry to cover
access.test.mjs: 3 - LEVELS is the single allowed level set, collaborator and viewer (row 193a)
access.test.mjs: 13 - grants: any level other than collaborator or viewer refuses, naming grants[i].level
access.test.mjs: 20 - covers: * and listed ids match
access.test.mjs: 21 - covers: an unlisted project, instance or business doesn't match
access.test.mjs: 22 - covers: an expired grant doesn't match, at and after the instant
access.test.mjs: 23 - covers: level is the least level asked for (row 193a)
access.test.mjs: 24 - covers: a missing or unknown level throws (row 193a, N1)
access.test.mjs: 25 - covers: a grant with an unknown level covers nothing (row 193a)
KILLED A2 owner added
access-levels.test.mjs: 2 - any other level refuses with the key path named
access.test.mjs: 3 - LEVELS is the single allowed level set, collaborator and viewer (row 193a)
access.test.mjs: 13 - grants: any level other than collaborator or viewer refuses, naming grants[i].level
access.test.mjs: 19 - loadAccess refuses bad JSON and a bad document with exit 2
access.test.mjs: 24 - covers: a missing or unknown level throws (row 193a, N1)
KILLED A3 covers ignores level
access-levels.test.mjs: 3 - covers tells a viewer grant from a collaborator grant
access.test.mjs: 23 - covers: level is the least level asked for (row 193a)
KILLED A4 order inverted (collaborator ask passes a viewer grant)
access-levels.test.mjs: 3 - covers tells a viewer grant from a collaborator grant
access.test.mjs: 20 - covers: * and listed ids match
access.test.mjs: 22 - covers: an expired grant doesn't match, at and after the instant
access.test.mjs: 23 - covers: level is the least level asked for (row 193a)
KILLED A5 level optional again (N1)
access-levels.test.mjs: 4 - covers refuses a call with no level, so a viewer grant can't slip past a collaborator check
access.test.mjs: 24 - covers: a missing or unknown level throws (row 193a, N1)
KILLED A5b level optional, no level means any
access-levels.test.mjs: 4 - covers refuses a call with no level, so a viewer grant can't slip past a collaborator check
access.test.mjs: 24 - covers: a missing or unknown level throws (row 193a, N1)
KILLED A6 unknown grant level guard dropped
access.test.mjs: 25 - covers: a grant with an unknown level covers nothing (row 193a)
killed 7/7; restored: True
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env python3
# Row 204 (193a) mutants on packages/business/src/access.mjs. Run from the row193a worktree root.
import os, shutil, subprocess, sys
SRC = os.path.abspath("packages/business/src/access.mjs")
BAK = "/mnt/storage/scratch/morgana-r204/access.mjs.orig" # any scratch path outside the tree
TESTS = ["tests/access-levels.test.mjs", "tests/access.test.mjs"]
CMP = " if (!LEVELS.includes(grant.level) || LEVELS.indexOf(grant.level) > LEVELS.indexOf(level)) return false;"
MUTANTS = [
("A1 levels collaborator only", 'export const LEVELS = Object.freeze(["collaborator", "viewer"]);', 'export const LEVELS = Object.freeze(["collaborator"]);'),
("A2 owner added", 'export const LEVELS = Object.freeze(["collaborator", "viewer"]);', 'export const LEVELS = Object.freeze(["collaborator", "viewer", "owner"]);'),
("A3 covers ignores level", CMP, " if (!LEVELS.includes(grant.level)) return false;"),
("A4 order inverted (collaborator ask passes a viewer grant)", CMP, CMP.replace("> LEVELS.indexOf(level)", "< LEVELS.indexOf(level)")),
("A5 level optional again (N1)", " if (!LEVELS.includes(level)) throw", " if (level !== undefined && !LEVELS.includes(level)) throw"),
("A5b level optional, no level means any", " if (!LEVELS.includes(level)) throw", " if (level === undefined) level = \"viewer\";\n if (!LEVELS.includes(level)) throw"),
("A6 unknown grant level guard dropped", CMP, " if (LEVELS.indexOf(grant.level) > LEVELS.indexOf(level)) return false;"),
]
shutil.copyfile(SRC, BAK)
orig = open(BAK).read()
if orig != open(SRC).read(): sys.exit("backup mismatch, abort")
killed = 0
try:
for name, a, b in MUTANTS:
if orig.count(a) != 1: sys.exit(f"{name}: anchor count {orig.count(a)}, abort")
open(SRC, "w").write(orig.replace(a, b))
failing = []
for t in TESTS:
p = subprocess.run(["node", "--test", "--test-reporter=tap", t], cwd="packages/business", capture_output=True, text=True, timeout=60)
failing += [f"{t.split('/')[-1]}: {l[7:]}" for l in p.stdout.splitlines() if l.startswith("not ok")]
status = "KILLED" if failing else "SURVIVED"
killed += bool(failing)
print(f"{status} {name}")
for f in failing: print(f" {f}")
finally:
shutil.copyfile(BAK, SRC)
print(f"killed {killed}/{len(MUTANTS)}; restored: {open(SRC).read() == orig}")
+10
View File
@@ -0,0 +1,10 @@
# Quackerjack
Builder seat, Claude Haiku 5.5 at high effort, started by Sage under mos-1023 item 1 and Q177 for federation
rows R5 (`mosaic access list, grant, revoke`) and R2 (the `federation` config key), per Gizmoduck's design
section 7 (#414).
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own
`quackerjack/*` branches through the repository credential helper (mos-0981), never `refactor`, `main` or
another seat's branch.
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
n=0
for p in /proc/[0-9]*; do
c=$(tr '\0' ' ' < $p/cmdline 2>/dev/null) || continue
case "$c" in *shell-snapshots*|*chrome-devtools-mcp*|*sage-busy-now*) continue;; esac
if echo "$c" | grep -qE 'node --test|gate\.sh|mutant|scripts/test-[a-z-]+\.sh|docker compose'; then echo "BUSY ${p#/proc/}: ${c:0:160}"; n=$((n+1)); fi
done
[ $n -eq 0 ] && echo CLEAR
+24
View File
@@ -0,0 +1,24 @@
#!/bin/bash
set -o pipefail
# Landing gate template (2026-10-14): sed the G= and OUT= lines and this comment.
# Sequential suites, each teed to its own log; stops at the first failure.
G=/mnt/storage/scratch/SET-ME-wt
OUT=/mnt/storage/scratch/SET-ME-out
cd "$G" || exit 9
mkdir -p "$OUT" || exit 9 # a missing OUT failed the row 142 gate at step 1 (2026-10-15)
# test-task.sh live cases run `docker compose` from $G, so without a name the project is "wt"
# and leaves wt_default behind (row 142 gate, 2026-10-15). Name it per gate dir and remove it on exit.
export COMPOSE_PROJECT_NAME="$(basename "$(dirname "$G")")"
trap 'docker network rm "${COMPOSE_PROJECT_NAME}_default" >/dev/null 2>&1 && echo "removed network ${COMPOSE_PROJECT_NAME}_default"' EXIT
# A gate tree without the node_modules link fails cli tests with ENOENT (2026-10-13).
[ -d "$G/node_modules" ] || { echo "STOP no node_modules in $G"; exit 8; }
echo "base $(git rev-parse --short HEAD) dirty $(git status --short | grep -vc node_modules) DOCKER_HOST=${DOCKER_HOST:-unset}"
run() { local name="$1" dir="$2" cmd="$3"
(cd "$G/$dir" && bash -c "$cmd") > "$OUT/$name.log" 2>&1; local rc=$?
echo "$name rc=$rc $(grep -E '^ℹ (pass|fail) [0-9]+' "$OUT/$name.log" | tr '\n' ' ') | $(tail -n1 "$OUT/$name.log")"
[ $rc -eq 0 ] || { echo "STOP $name"; exit $rc; }; }
# No pkg-queue step: scripts/test-queue.sh runs packages/queue/tests/ itself (same 156 tests; CI debate item 2a, Vikunja #274).
for d in packages/*/; do p=$(basename "$d"); [ -d "$d/tests" ] || continue; [ "$p" = queue ] && continue
run "pkg-$p" "packages/$p" 'node --test tests/*.test.mjs'; done
for s in scripts/test-*.sh; do n=$(basename "$s" .sh); run "script-${n#test-}" . "bash $s"; done
echo GATE_PASSED
+36
View File
@@ -0,0 +1,36 @@
# Row 189 gate timing (for row 197, Herb)
Sage, 2026-10-20T19:25:04Z. Gate on 0c177e9ef (base 0217b71c8), one lane. The slot was mine. Seats may have run single test files at the same time, which the slot rules allow, and I did not record that.
Start 2026-10-20T19:09:37Z, end 2026-10-20T19:20:36Z. Total wall time: 659 s.
Wall time per suite is the gap between one suite's log mtime and the one before it (the first suite starts at start.txt), so it includes node start-up. node's duration_ms is listed where the suite prints it. Scripts print no duration.
| Suite | Wall s | duration_ms s | Ended (UTC) |
|---|---|---|---|
| pkg-bus | 3 | 3.8 | 19:09:40 |
| pkg-business | 3 | 2.2 | 19:09:43 |
| pkg-cli | 388 | 388.5 | 19:16:11 |
| pkg-control-board | 1 | 0.8 | 19:16:12 |
| pkg-conversation | 35 | 35.4 | 19:16:47 |
| pkg-discord | 3 | 2.6 | 19:16:50 |
| pkg-harness | 12 | 11.6 | 19:17:02 |
| pkg-ledger | 13 | 13.5 | 19:17:15 |
| pkg-mosaic | 3 | 2.3 | 19:17:18 |
| pkg-plugins | 0 | 0.3 | 19:17:18 |
| pkg-project | 1 | 1.2 | 19:17:19 |
| pkg-runs | 1 | 0.8 | 19:17:20 |
| pkg-seat | 6 | 6.2 | 19:17:26 |
| pkg-tasks | 1 | 1.2 | 19:17:27 |
| pkg-webui | 58 | 57.9 | 19:18:25 |
| script-auth | 1 | - | 19:18:26 |
| script-conductor | 5 | - | 19:18:31 |
| script-config | 2 | - | 19:18:33 |
| script-discord | 8 | - | 19:18:41 |
| script-extension-package | 1 | - | 19:18:42 |
| script-foundation | 40 | - | 19:19:22 |
| script-land-work | 11 | - | 19:19:33 |
| script-queue | 21 | - | 19:19:54 |
| script-release | 11 | - | 19:20:05 |
| script-task | 31 | - | 19:20:36 |
Source: /mnt/storage/scratch/sage-g189/out/*.log mtimes and gate.log. The cli suite is about 59% of the run, and webui plus conversation add about 14%.
+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.
+86
View File
@@ -0,0 +1,86 @@
# Row 189: `stack-release.sh activate --no-send`
Gitea #1687. Vikunja P19 #413 (Q175 interim, #412). Brief: docs/plans/2026-10-20_activate-no-send.md.
Rulings: mos-0972 (the Console runs without `--send` until Q175 phase 2), mos-0973 (go; Sage builds,
Dewey reviews; gate is the cli suite plus every scripts/test-*.sh). Base: def2e7932 (refactor, the
brief commit on 9ae8c73ac).
## What it does
`activate ID BUSINESS --no-send` drops `--send` from `RUN_CONSOLE` in a new `send_for_target`, which
`console_for_target` calls after `proof_for_target`. `install_stack` renders the unit from
`RUN_CONSOLE` and `write_pointer` writes the pointer from it, so both lose the flag and `flags_drift`
stays quiet afterwards. Only `activate` takes the flag; the parser sends every other op to usage
(exit 4).
`check_target` lets a `--no-send` activate name the pointer's copy only when the pointer's Console
flags hold `--send`. That run is a live-copy re-pin, the same path row 187 uses for the proof pair:
the plan prints the live-copy note, the run stops both units, backs up the stopped store and starts
them again. A plain same-copy activate, or a same-copy `--no-send` once `--send` is gone, still
refuses `already-active`.
`--plan` prints one `send:` line after the `proof:` line, for activate and rollback:
- `send: dropped; --no-send was given`
- `send: off; --no-send was given and the pointer holds no --send`
- `send: carried` (the pointer's `--send` goes on unchanged)
- `send: off`
The steps log gets `drop --send; --no-send was given`, or `--no-send was given and the pointer holds no
--send, so there is nothing to drop`.
Rollback and pm-on carry the pointer's flags as they are, so once `--no-send` has run, nothing adds
`--send` back. There is no re-enable flag (mos-0973). Q175 phase 2 changes the Console flags in its
own row.
docs/TOOLS.md: `[--no-send]` in the usage block and one paragraph after the proof paragraph.
## Tests (packages/cli/tests/stack-release.test.mjs, T55)
1. Seeded preview pointer with `--send --tester-port 7333`: a same-copy `activate B acme --no-send`
passes. The pointer and the unit ExecStart lack `--send` and keep the other flags in order, status
says the unit flags match, the steps log has the drop line, the log line has from = to = B and the
backup sum equals the stopped store's, and the units stop and start in order.
2. Plain same-copy activate, with `--tester-port` and with `--no-proof`, refuses `already-active`. So does
a same-copy `--no-send` once `--send` is gone, and on a pointer that never held it.
3. The four `send:` lines, the live-copy note on the same-copy plan only, and the Console line right
after `send:` without `--send`. The run on a pointer with no `--send` logs the nothing-to-drop line
and no drop line.
4. After `--no-send` on C1, a rollback to B leaves `--send` out of the pointer and the unit, the steps
log never names `--send`, and status matches. pm-on after `activate --pm-after-harness --no-send`
keeps it off.
5. `--no-send` on rollback (with and without `--plan`), pm-on, status, adopt and rehearse is a usage
error with the usage text, nothing written, started or logged. The usage text shows `[--no-send]`.
6. A pointer holding both the proof pair and `--send`: the plans for `--no-proof`, `--no-send` and both
name what each drops and carries. `--no-send` keeps the pair, `--no-send` with a new pair replaces it,
`--no-proof` keeps `--send`, and both together leave neither. Status matches after each.
`planTail` gains `'send: off'` after `'proof: off'`, because every plan now prints the line. T36 takes
`out.slice(-n)` around `planTail`, so its four slice counts grow by one.
## Mutants
mutants.sh in this packet, run on the candidate. Each mutates scripts/stack-release.sh and runs T55
plus T1 and T2.
- M1: the same-copy exception ignores `--no-send`.
- M2: the same-copy exception allows `--no-send` without `--send` in the pointer.
- M3: with `--no-send`, the pointer is written from `P_CONSOLE`, so it keeps `--send` while the unit
drops it.
- M4: rollback re-adds `--send`.
- M5: the parser accepts `--no-send` for rollback.
Results: mutants-run.log, all five killed (M1 5 failures, M2 2, M3 7, M4 2, M5 2).
## Deviations from the brief
- The brief's line numbers were taken before row 163 landed. The candidate corrects them to the
9ae8c73ac lines (check_target :249-256, flags_drift :646-657, console_for_target :664-693, adopt
:828).
- Test 5 also covers rehearse and `rollback --plan`, which the brief did not list.
## Outbound calls
None. The change edits an array before the existing unit render and pointer write. Every test uses
the stand-in systemctl, units, stores and data root the file already builds; no test touches
`~/.mosaic-dev`, the live units or a slot file.
@@ -0,0 +1,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,5 @@
M1 KILLED fail=5
M2 KILLED fail=2
M3 KILLED fail=7
M4 KILLED fail=2
M5 KILLED fail=2
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Row 189 mutants M1-M5: each mutates scripts/stack-release.sh in the sage-b189 worktree, runs T55 and
# the plan-tail tests, and restores the file from a checked backup.
set -uo pipefail
WT=/mnt/storage/scratch/sage-b189
OUT=/mnt/storage/scratch/sage-b189-mut/out
S=$WT/scripts/stack-release.sh
mkdir -p "$OUT" || exit 1
cp -p "$S" "$OUT/stack-release.sh.orig" || { echo "backup failed"; exit 1; }
ORIG=$(sha256sum "$S" | cut -d' ' -f1)
[ "$(sha256sum "$OUT/stack-release.sh.orig" | cut -d' ' -f1)" = "$ORIG" ] || { echo "backup differs"; exit 1; }
restore() { cp -p "$OUT/stack-release.sh.orig" "$S"; [ "$(sha256sum "$S" | cut -d' ' -f1)" = "$ORIG" ] || { echo "RESTORE FAILED"; exit 1; }; }
trap restore EXIT
mutate() {
case "$1" in
M1) sed -i '/{ \[ "\$NO_SEND" = 1 \] && \[\[ " \${P_CONSOLE\[\*\]} " == \*" --send "\* \]\]; } ||/d' "$S" ;;
M2) sed -i 's/{ \[ "\$NO_SEND" = 1 \] && \[\[ " \${P_CONSOLE\[\*\]} " == \*" --send "\* \]\]; } ||/{ [ "$NO_SEND" = 1 ]; } ||/' "$S" ;;
M3) sed -i 's/^ write_pointer "\$TO" "\$RUN_PM" "\$RUN_PENDING" "\$P_CLAUDE" "\$T_SCHEMA" "\${RUN_CONSOLE\[@\]}"$/ if [ "$NO_SEND" = 1 ]; then write_pointer "$TO" "$RUN_PM" "$RUN_PENDING" "$P_CLAUDE" "$T_SCHEMA" "${P_CONSOLE[@]}"; else write_pointer "$TO" "$RUN_PM" "$RUN_PENDING" "$P_CLAUDE" "$T_SCHEMA" "${RUN_CONSOLE[@]}"; fi/' "$S" ;;
M4) sed -i 's/^ SEND_PLAN="send: off"$/ SEND_PLAN="send: off"; [ "$OP" != rollback ] || RUN_CONSOLE+=(--send)/' "$S" ;;
M5) sed -i 's/--no-send) \[ "\$OP" = activate \] || usage;/--no-send) { [ "$OP" = activate ] || [ "$OP" = rollback ]; } || usage;/' "$S" ;;
esac
}
for m in M1 M2 M3 M4 M5; do
restore
mutate "$m"
if [ "$(sha256sum "$S" | cut -d' ' -f1)" = "$ORIG" ]; then echo "$m NOT-APPLIED"; continue; fi
bash -n "$S" || { echo "$m SYNTAX"; continue; }
diff "$OUT/stack-release.sh.orig" "$S" > "$OUT/$m.diff"
(cd "$WT" && TMPDIR=/mnt/storage/scratch/t189 timeout 900 node --test --test-reporter=tap --test-name-pattern='T55|T1 |T2 ' packages/cli/tests/stack-release.test.mjs) > "$OUT/$m.log" 2>&1
fails=$(awk '/^# fail /{print $3}' "$OUT/$m.log")
if [ "${fails:-0}" -gt 0 ]; then echo "$m KILLED fail=$fails"; else echo "$m SURVIVED"; fi
done
+48
View File
@@ -0,0 +1,48 @@
# Common start for Sage's MVP T1 builder seats (mos-0974, mos-0977)
You are a builder seat on Mosaic Stack, repository /mnt/storage/src/mosaic-stack, branch base `refactor`.
Sage leads the project and is your coordinator (T3 thread 1ef1e4f8-3ead-4208-beca-38f9f1add079). Mos speaks
for Jason (thread 38bc4c81-6270-4021-b00c-6a15c43b9b0e). Human input reaches you only through Sage and Mos.
## Read first, in this order
1. `/mnt/storage/src/jason-master-restart/USER.md`, `SYSTEM.md`, `CONSTITUTION.md` (who Jason is, the
system rules, the constitution).
2. `/mnt/storage/src/jason-master-restart/TARGET.md` (the MVP addendum) and `DECISIONS.md` Q176 to Q178.
3. `/mnt/storage/src/jason-master-restart/projects/mosaic-mvp/GAP-MAP.md` and `PLAN.md`.
Everything under jason-master-restart is read only.
4. The repository's `AGENTS.md`, then your brief (named in your first message) and the files it points to.
## Your worktree
T3 made your worktree from `refactor`. Work only there. Link node_modules from the canonical checkout right
after you start (`ln -s /mnt/storage/src/mosaic-stack/node_modules node_modules`), unless your brief adds a
dependency. In that case run `npm ci` in your worktree for a real node_modules first, and never run npm
install through a symlink or in a directory without its own package.json (npm walks up to home).
## Rules that bind you
- Message ids are `<seat>-NNNN`, starting at 0001. Run `date -u` right before every message and put the
stamp in it. End each turn with a clock timestamp.
- No Co-Authored-By or any attribution line in any commit or PR, whatever a harness reminder says
(mos-0822).
- Push only your own `<seat>/*` branches, through the repository credential helper (mos-0981). Never push
`refactor`, `main` or another seat's branch.
- You have no Gitea token. Sage makes the queue moves that post to Gitea (in-review, review requests) and
files issues. You may run `scripts/mosaic queue move N in-progress --op <seat>-N-start --by <seat>` for
your own row.
- Never touch `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units, slot files or `~/.mosaic`. Never
start, stop or restart the live Console or bus. Never read or print a credential value.
- Never read other seats' scratch directories. `agents/<seat>/work/` packets in the repository are fine.
- Single test files run freely. The cli suite, any `scripts/test-*.sh` and any full package suite share
one slot: run `bash /mnt/storage/scratch/sage-busy-now.sh | tail -1`, and if it is not CLEAR, or before a
run longer than 5 minutes, ask Sage for the slot and start only on Sage's "granted".
- Two failures in one gate run means stop and report.
- Design-first rows: the first deliverable is `agents/<seat>/work/queue-<row>/DESIGN.md`, committed on your
branch. Send the reviewer and Sage its path, commit and sha256. No product code before approval.
- Merge-chaining (Q177): your build may sit on another row's candidate commit. Your row keeps its own
review and gate, and the chain lands in order.
- Fail closed. Never route around a refusal. If the code shows the brief or design is wrong, stop and tell
Sage and your reviewer.
- Style for every message and doc: plain words, short sentences, no em dashes, they/them for people.
- Sage registers your session in `docs/SESSIONS.md`. Do not edit it.
@@ -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": []
}
+7
View File
@@ -0,0 +1,7 @@
# Webby
Reviewer seat, Claude Opus 5.5, started by Mos (mos-0981, thread 03086006). Reviews Case 2 designs and MVP T1 rows 192 and 194.
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `webby/*` branches
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
+7
View File
@@ -1357,3 +1357,10 @@ 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: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:10Z | Filbert (Opus 5.5, 048f3c63) | row 190 design review r2 (#1688, DESIGN.md sha256 5493c9df3ec0) | CHANGES to Darkwing, cc Sage: B1 claim check on MOSAIC_CONFIG-only selection, B2 shared prefix drop-in dirs; N1-N7 notes; static read, no slot, no Gitea or queue record
2026-10-20T19:00:04Z | Filbert (Opus 5.5, 048f3c63) | row 190 design review r3 (#1688, DESIGN.md sha256 98236a1aff98) | APPROVE to Darkwing, cc Sage: B1 and B2 closed with M13 and M14, N7 settled by Sage's 48ebee0ff; static read, no slot; manifest round gets a queue review record (sage-1337)
2026-10-20T19:57:00Z | Gizmoduck (Opus 5.5, 9312a896) | row 201 FED R1 (#1699, #414) round 1: packages/business/src/access.mjs, index export, tests/access.test.mjs, packet agents/gizmoduck/work/queue-201 | 24/24 single file; 44 mutants written, not run (needs Sage's slot); expiry per sage-1399; to Duckworth for manifest review
+9 -1
View File
@@ -368,7 +368,7 @@ reports checkout or copy identity and refactor drift, and writes nothing.
```
scripts/stack-release.sh adopt BUSINESS
scripts/stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-port N]
[--proof-header NAME --proof-file PATH | --no-proof] [--timeout S] [--plan]
[--proof-header NAME --proof-file PATH | --no-proof] [--no-send] [--timeout S] [--plan]
scripts/stack-release.sh pm-on BUSINESS [--timeout S]
scripts/stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
scripts/stack-release.sh status BUSINESS
@@ -480,6 +480,14 @@ copies. It checks the file with `stat` and `realpath` and never reads the value.
or `off`. On the live copy it also notes that the run re-pins the pointer's copy
and only the Console flags change.
`activate ... --no-send` (row 189, mos-0972) drops `--send` from the pointer's
Console flags and the unit, so the Console serves reads and no human sends. It
may name the live copy while the pointer holds `--send`; once `--send` is gone a
same-copy `--no-send` refuses `already-active`. Later activates, `pm-on` and
rollbacks carry the pointer, so send stays off. No flag puts `--send` back.
`--plan` prints one `send:` line: `dropped`, `off` with or without the
`--no-send` note, or `carried`.
#### A target past c216f47db: the PM after the harness kind
From c216f47db on, the bus launches the PM only with a `harness` kind in the
+73
View File
@@ -0,0 +1,73 @@
# Brief: activate --no-send drops the Console's --send
Sage, 2026-10-20, for Mos's ruling mos-0972 (Q175 interim, Vikunja #412). Owner Sage, reviewer Dewey, gate owner
Sage. Base: refactor 9ae8c73ac (row 163 landed). Line numbers are at that commit. The candidate corrects them;
the first draft took them before row 163.
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:664-693) carries the pointer's flags unchanged. Only
`--tester-port` and the row 187 proof pair can change.
- `check_target` (:249-256) lets an activate name the pointer's copy only for a proof-pair change.
- `adopt` refuses once a pointer exists (:828).
- A hand `webui-service.sh install` without `--send` makes `flags_drift` (:646-657) refuse
`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.
@@ -0,0 +1,57 @@
# Brief: a credential store per agent, own git identity, pushes to a remote
Sage, 2026-10-20, for mos-0974 (MVP T1 item 6) and the MVP plan
(`jason-master-restart/projects/mosaic-mvp/PLAN.md` item 6, GAP-MAP G11 and G13; TARGET.md: "credentials store
available for all agents; secrets protected with RLS equivalent" and "Agent uses own credentials for git and
other actions"). Gitea #1697. Owner Megavolt (Opus 5.5, a new builder seat), reviewer Duckworth, gate owner
Sage. Base: refactor at the brief commit. Design first. The first deliverable is
`agents/megavolt/work/queue-ROW/DESIGN.md`.
This row draws a security boundary. Sage reviews the design's authority model, and Mos rules on it before any
code is written.
## What exists today (GAP-MAP, measured at 9ae8c73ac)
- G11. Credential references exist per instance for Gitea and Vikunja only
(`packages/business/src/credentials.mjs`, `packages/bus/src/credentials.mjs`, `vocabulary.mjs:103`). One
Claude env file is shared by every seat.
- G13. Agents use their own Vikunja token (`packages/tasks/src/adapter.mjs`). Git goes through the host
socket (`packages/cli/src/git-socket.mjs`). Gitea tokens are loaded but never used.
- The development repository's helper (`scripts/git-credential-mosaic.mjs`, wired in `.git/config` for
git.mosaicstack.dev) pushes as one identity. Until this row lands, seats push only their own `<seat>/*`
branches through it (mos-0981).
## What the design must settle
1. The store. Where each agent's credentials live, per instance, under the row 190 config canon. File modes,
ownership and the read path. Which process may read which entry, and how the bus enforces that (the "RLS
equivalent"). A process asking for another agent's entry is refused and logged.
2. Kinds. Gitea (git over HTTPS, and the API), Vikunja, and the model provider. Say which kinds 10-27 needs
and which wait. The model provider entry must not copy jwoltje's `claude-session.env`. If it needs a new
provider account or subscription, flag it for Jason and don't pick one.
3. Git identity and push. Each agent commits with its own name and email and pushes with its own token to a
remote it is granted. Which refs an agent may push (its own branches, never the default branch or another
agent's), and where that's enforced: the Gitea side (branch protection, token scopes) and our side. How
this fits the host git socket, or replaces it.
4. Lifecycle. Issue, rotate (`rotateBy`, `expires`), revoke and delete, and what row 194 (agent lifecycle) calls
when an agent is hired or fired. A fired agent's credentials stop working at once.
5. Who does what. Creating a Gitea user or token, or any account outside the fleet, is Jason's (Q173
exclusions). The design lists each such step as a command or UI action for Jason. Agents never mint
accounts.
6. Migration. Today's seats (jarvis helper, seat tokens) keep working until each moves. The mos-0981 push
rule ends only when Mos says so.
7. What this row closes in G11 and G13, and what stays open.
## Boundaries
- No credential value is read, printed, logged or committed. Tests use fake tokens in scratch roots and a
fake Gitea.
- No change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units, slot files, `~/.mosaic` or the live
`.git/config` of the canonical checkout.
- No request to git.mosaicstack.dev, tasks.woltje.com or any provider from a test.
## Gate
Duckworth approves DESIGN.md, Mos rules on the authority model, then Duckworth approves the manifest. The bus,
business, cli and tasks suites and every `scripts/test-*.sh` are green on Sage's gate, and the design's
mutants are killed.
+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 Webby (mos-0981), gate
owner Sage. Base: refactor at the brief commit.
Design first. The first deliverable is `agents/honker/work/queue-194/DESIGN.md`. Webby reviews it before any
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
Webby 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.
@@ -0,0 +1,100 @@
# Federation build rows R1, R2 and R5 (#414)
Sage, 2026-10-20, for Mos's orders mos-1023 item 1, mos-1028 item 2 and mos-1034 item 1. The design is
Gizmoduck's `agents/gizmoduck/work/federation/DESIGN.md` at a1daa3c07 on `gizmoduck/federation-design` (r4.9,
sha256 a31cbebd070f9e3a5ca4ccb158b1747c6f5a83c3469855705b0026a581fb5c6a). Duckworth approved r4.5
(duckworth-0012), r4.6, r4.7 (duckworth-0020) and r4.8 (duckworth-0021, 8a22d0897 on `duckworth/reviews`),
and acked r4.9 (duckworth-0024). r4.7 applies Jason's Q181 hosts, r4.8 adds N22's R8 checks and r4.9 names who
runs them. None of them changes R1, R2 or R5. Section 7's table
sets each row's scope and test gate. Vikunja P19 #414. Seats push only their own `<seat>/*` branches
(mos-0981).
The other R rows are filed when their parents have candidates: R3 after R1 and row 196, R4 after R3, R5 and
row 193's viewer level (193a, mos-1019), R6 after #412 phase 1, R1, R2 and R3, R7 after R1-R6 and row 190.
R8 is a live window, not a code row. Its brief is the last section here.
## Brief: FED R1, the access.json loader and validator
Owner Gizmoduck, reviewer Duckworth, gate owner Sage. Base: refactor at this brief's commit. No parent.
`packages/business/src/access.mjs`: the access.json loader and validator from DESIGN.md section 3.5, and the
pure `covers`. Providers, peers and businesses come in as arguments. One read plus the file checks. The
exported interface is what R5 and row 193a build against, so the manifest names it, and any change to it in
review goes to Sage before it lands. Section 7 names `covers` but not the loader and validator exports or
the context shape. Morgana's 193a (508ee4d61 on `morgana/row193a`) assumes `checkAccess(doc, {providers,
peers, businesses})`, with `businesses` mapping an id to `{human, instances}`, throwing a `BusinessError` that
names the key path. Use those names, or tell Morgana and Quackerjack yours before the manifest goes to
Duckworth. Row 193a later widens the level check to `viewer` on R1's candidate (mos-1019).
Tests, in `packages/business/tests/`: every rule in 3.5 refuses with the key path named. That covers a
symlink, a non-regular file, another owner, group or other write, expiry, `*`, a grant disjoint from the given
subjects, `human` and `tester` ids, any level other than `collaborator`, and a project entry that isn't a
UUID. For `covers`: `*` and listed ids match, while an unlisted project, instance or business doesn't, an
expired grant doesn't, and a level filter rejects another level. One mutant per refusal, each killed.
Boundaries: no change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or any live access.json. Tests
use scratch roots. Single test files run freely, and suites need Sage's slot.
Gate: Duckworth approves the manifest. The business suite and every `scripts/test-*.sh` are green on Sage's
gate, and the mutants are killed.
## Brief: FED R5, mosaic access list, grant and revoke
Owner Quackerjack, reviewer Duckworth, gate owner Sage. Chained on R1 (Q177). The build starts now against
R1's exported interface as the R1 manifest names it, and R5 lands after R1 and rebases onto R1's landing.
`mosaic access list`, `grant` and `revoke`: human proof, atomic write, revision bump, a receipt line. The help
text states the `instances` limit and the provider-removal order (DESIGN.md section 7, row R5). R4 later makes
these verbs call `access.reload`, so R5 leaves no other reload path.
Tests, in `packages/cli/tests/`: tester and agent callers are refused, a concurrent write refuses on a
revision conflict, the receipt line is appended, and a bad grant is refused before anything is written. One
mutant per refusal, each killed.
Boundaries: no change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or any live access.json. The
human proof in tests uses the scratch stand-in pattern (a scratch bus verifies its own human-cli path).
Single test files run freely, and suites need Sage's slot.
Gate: Duckworth approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate,
and the mutants are killed.
## Brief: FED R2, the federation key in the system config
Owner Quackerjack, reviewer Webby, gate owner Sage. Chained on row 192 (#412 phase 1). The build starts when
row 192 has a candidate, on that candidate.
A `federation` key in `scripts/mosaic-config.mjs` with the shape in DESIGN.md section 3. A config with no
`federation` key still loads as it does today. This changes the config schema, so the manifest lists every
reader of the key.
Tests, in `scripts/test-config.sh`: a valid key loads, an unknown sub-key and a bad origin refuse, and a
config without the key loads. One mutant per refusal, each killed.
Boundaries: `~/.config/mosaic-dev/config.json` is user-authored and is not touched. Tests use scratch config
dirs only.
Gate: Webby approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate, and
the mutants are killed.
## Brief: FED R8, the hub window on 10.1.1.54
A live window, not a code row (DESIGN.md section 7, row R8, r4.9). Run by Jason and Mos. Nothing here
authorizes a step on any host; each step waits for the window's go.
Order (mos-1042 item 2). R8 runs after Bean and Chet are live in Case 2 and both Hermes gateways on 10.1.1.54
are stopped, because R8 requires that the hub host runs no other instance or agent and Q181 keeps Melanie's
Hermes on .54 until then. Case 2 go-live doesn't wait on R8. The risk, stated for 10-27: federation between
the two instances now depends on Melanie's switch-over date.
Entry checks, all before anything starts:
- R7, #412 phase 2, the hub host live (Vikunja #418) and Jason's #419 answer, as section 7 lists.
- The four hub user checks on 10.1.1.54 (N22, mos-1035 item 1): `sudo -l -U mosaic` grants nothing, `id
mosaic` shows only its own group, `~mosaic/.ssh/authorized_keys` is absent or empty, and no file under
`/etc/sudoers.d` names `mosaic`. Jason runs them as localadmin with his new key (#421 step 1), and the output
goes into the R8 record. No factory uid runs them, because R8 requires every factory root path to .54 to
fail (r4.9, mos-1037).
- The factory privilege checks, the key fingerprint comparison and the section 8 privilege path table, each
as section 7's R8 row states them.
Done when every entry check has its output recorded, Jason signs in to the hub and sends one message, Melanie
revokes, and his next request is refused.
+77
View File
@@ -0,0 +1,77 @@
# Brief: Melanie's hub instance on its own host (row 195, was 190b)
Sage, 2026-10-20. Rewritten for Mos's rulings mos-0998 and mos-1005 (Vikunja #418, #419). The first version
put the hub under its own Linux user on dragon-lin. That form can't pass Duckworth's B7 there, because the
factory uid's `sudo -n` succeeds on dragon-lin (Mos's recon, brain-redux
`projects/mosaic-mvp/MELANIE-HOST-RECON.md`). Melanie's own server is ruled out too: it has 1.2 GiB RAM free
with her two Hermes bots running, and co-tenancy would share her user and her passwordless sudo. The hub runs
on a separate host. Which host is Jason's decision (#419): hardware outside the cluster, a cluster VM with
factory PVE root removed, or a cluster VM with that residual accepted. This row is written so the host choice
doesn't change it. Gitea #1693. On the 10-27 MVP path. Federation R8 (Gizmoduck's design r4, 04a6112be) needs
this row or nothing.
Owner Darkwing (Opus 5.5), reviewer Duckworth (author of B5 and B7), gate owner Sage. Chained on row 190
(Q177): the hub runs under row 190's config canon. Design first. The first deliverable is
`agents/darkwing/work/queue-190b/DESIGN.md`.
## Goal
Melanie's hub instance (Case 2, C2-13) runs on its own host. No factory agent can read or write the hub's
config, data root or secrets, reach its bus socket, or log in to the hub host. Her writing reaches the hub only
through the existing git.mosaicstack.dev remotes, books repo only.
## Provision (blank until Jason answers #419)
Who provisions the host and how is left open. Sizing from Mos (mos-0998): 4 vCPU, 8 GB RAM, 64 GB disk,
internal network only. Each agent process measures 300 to 390 MB RSS on dragon-lin. The design takes the host
as given: a fresh Linux host with a named admin path, reachable by the deploying seat while the row runs.
## The design must state
1. The deploy. Exactly what Darkwing installs on the hub host and how: the stack copy, node, the units, the
config dir and data root under row 190's canon (O1: `~/.config/mosaic-NAME`, and "`~` is the home of the OS
user that runs the instance"). Which commands run as which user on the hub host, and which need its root.
2. Re-pin, rollback and backup on the hub host, and who runs them after the deploy key is gone.
3. How the hub gets model access without a copy of jwoltje's `claude-session.env` or any factory credential.
If that needs the credentials row (T1 item 6), say so and name the interim. If it needs a new subscription
or a new provider account, flag it for Jason and don't pick one (mos-1008).
4. The books repo path. Which git.mosaicstack.dev remote, which credential the hub holds for it, and that it
can't read or push any other repository.
5. The admin path and the end state. The host ends with no key the factory uid can read. The design names the
admin path that remains (for example the hypervisor console, or a key kept off the factory uid) and who
holds it.
6. The acceptance script, as code Duckworth can read. It takes Gizmoduck's list of infrastructure gate items
as its input (mos-1011), not a fixed set of checks. Each item names a path, its closed form and its
accepted form, and the script reports which form holds for each, or fails. The list covers at least:
- federation R8's separate-host form: the hub's bus socket, data root and config dir don't exist on the
factory host, the factory host mounts nothing of the hub's, and no factory agent holds a login or key
for the hub host
- B7: from the factory uid, `sudo -n`, `doas -n`, `su`, polkit, and `ssh -o BatchMode=yes` to the hub
host all fail
- the hypervisor and backup paths ops-01 measured (mos-1011: one PVE node the factory key has root on,
PBS on that node with unencrypted backups, a flat /16 LAN), each closed or accepted by Jason
7. The identity provider and TLS front end (B8, mos-1008, mos-1011). Either the factory holds no credential
that can create users, change groups, issue tokens or impersonate in the issuer the hub trusts, or the hub
uses an issuer the factory can't administer. A brand, tenant or app inside the same Authentik does not
count as separate. The same holds for the TLS front end that serves the hub. The proof is a failed admin
call from every token the factory's root can read. No credential value is printed in that proof.
## Boundaries
- Provisioning, hypervisor access and any infra or k3s change are Jason's (Q173 exclusions). Mos takes the
hypervisor residual (factory root on the PVE nodes, Q172) to Jason. It doesn't block this row.
- Melanie's server (`[email protected]`) is reference only, read only, under Jason's authorization in
mos-0995. Nothing on it changes, and her Hermes bots keep running.
- No tunnel, Traefik or other exposure outside the internal network. That waits for Jason, after Q175 SSO.
- No credential value is printed, logged or committed. Keys made for the deploy are removed at the end, and
the acceptance script proves it.
- The admin-call proof in point 7 and any call to the issuer or the TLS front end run only in a window Mos
approves, never as a test or an anonymous probe (Q175).
- Tests in the repository use scratch roots and stand-ins. The live deploy and the acceptance run on the hub
host are a window with a backup first (where there is state) and a STATE report after.
## Gate
Duckworth approves DESIGN.md, then the manifest. Any repository change is green on Sage's gate (cli suite and
every `scripts/test-*.sh`), with the design's mutants killed. The row is done when the acceptance script passes
on the hub host from the factory uid, Duckworth reads its output, and Mos confirms.
+62
View File
@@ -0,0 +1,62 @@
# Brief: the human boundary across uids and humans (P19 333)
Sage, 2026-10-20, for Mos's order mos-0989 (P19 333, MOSAICSTA-173, is now on the 10-27 path: federation R3 and
row 193 need it). Gitea #1694. Owner Gander (Codex gpt-6.1-sol, a new builder seat under mos-0977 item 5),
reviewer Duckworth, gate owner Sage. Latest-by: DESIGN.md approved by 2026-10-22, landed by 2026-10-24, and in
any case before federation R3 lands. R3 and row 193 may chain on this row's candidate (Q177). Base: refactor at
the brief commit. Design first. The first deliverable is `agents/gander/work/queue-ROW/DESIGN.md`.
## The problem, as filed and as it now matters
P19 333 as filed (row 87, mos-0657): the Console listens on a loopback TCP port, and any host process that
reaches it, under any uid, acts as Jason. Jason accepted that as a documented limit for a one-user host
(mos-0787, row 161). Two things change that:
- Row 190b (#1693, mos-0986) puts Melanie's hub under its own Linux user. Factory processes then sit on another
uid, on the same host, next to the hub's ports and socket. The other-uid reach is now the Case 2 threat.
- Federation r2 (gizmoduck-0004, 60b9b112a) binds a bus user only from a user-cli whose proof passes the human
walk, whose direct parent is the bus's own Console, and which holds a live grant. That rule rests on the
path-bound human proof (#225): today a same-uid process that runs the tree's `human-cli.mjs` acts as the
human, without touching the Console. The Q175 design (section 8) names that limit and leaves it here.
## What already settles part of it
- Q175 phase 2 (#412) settles the Console-ingress part on the main port. Under `--sso`, 7332 answers any
non-public Host with 403 except `GET /healthz`, and every other request needs a verified OIDC session. A
process on another uid that reaches the port gets only `/healthz`. Phase 1 (row 192) builds it; it is live
only after the phase 2 window.
- Row 189 (`--no-send`) is interim only. Until phase 2 the Console takes no human writes, so the reach is
read-only. It settles nothing.
- Row 190b separates files and units by uid. Whether the hub's `broker.sock` refuses the factory uid depends on
its directory and socket modes, which this row checks and tests rather than assumes.
## What the design must settle
1. Inventory every listener and entry point per instance: the Console port, the tester listener (7333 today),
`broker.sock`, the human CLI wrapper, the Discord connector and anything row 190 adds. For each: who can
reach it today from the same uid and from another uid, with and without `--sso`.
2. The tester listener. It serves reads and the row 175 tester writes to anything that reaches it, on any uid.
Close the other-uid reach (for example a unix socket with 0600 modes, or a peer-uid check on loopback), or
bring Mos a concrete reason it must stay open. bus.tester stays Jason's switch.
3. The bus human boundary. How the bus tells the human's own verified call from a same-uid process that runs
`human-cli.mjs`. State what federation R3's direct-parent rule needs from the bus (peer credentials on
`broker.sock`, the parent check, how it can't be forged by a child of an agent), and what stays possible for
a same-uid process after this row. Say it plainly where a residual limit remains.
4. Fit with row 193 (Morgana, human users and roles; Mos's B1 rule: a principal comes only from something the
bus verifies) and with federation R3. One mechanism, not three.
5. Migration: the live single-user Console and bus keep working until each instance re-pins.
6. Which parts of P19 333 this row closes, which parts Q175 phase 2 closes, and what P19 333 says when it is
closed.
## Boundaries
- Who can reach the Console from outside stays Jason's. This row changes what a host process can do once it
reaches a local port or socket.
- No test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or a slot file. No test creates a real
second uid; use stand-ins as rows 190 and 190b do, and name any part only a Q173 window can prove.
- No request to the live 127.0.0.1:7332 or 7333, mosaic.woltje.com or auth.diversecanvas.com.
## Gate
Duckworth approves DESIGN.md, then the manifest. The bus, webui and cli suites and every `scripts/test-*.sh`
are green on Sage's gate, and the design's mutants are killed.
@@ -0,0 +1,66 @@
# Row 193 build rows: 193a, 193b1, 193b2, 193c and 193d (#1691)
Sage, 2026-10-20, for Mos's go mos-1034 item 4. Row 193's design is Morgana's
`agents/morgana/work/queue-193/DESIGN.md` at e2d161e2e on `morgana/row193` (r5, sha256
dd7fcb419ed8510e9d8d74b569183386d0942af8b68eecc0bbb5e182e677b523), approved by Duckworth (duckworth-0018).
Section 8 lists the tests and mutants, and section 9 the split. The brief behind them is
`docs/plans/2026-10-20_human-users.md`. Vikunja P19 #414/#415, Gitea #1691.
Owner Morgana for every row, reviewer Duckworth, gate owner Sage. Each row chains on its parent's candidate
(Q177) and rebases onto the parent's landing. Seats push only their own `<seat>/*` branches (mos-0981).
Single test files run freely, and suites and mutant batches need Sage's slot.
Boundaries for every row: no change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units, slot files, any
live access.json or `~/.mosaic`. No request to the live Console, mosaic.woltje.com or auth.diversecanvas.com.
No user thread goes live before 193b1's message filter has landed (mos-1021 B2, FED R8).
The owner-visible records are fixed by Mos (mos-1023 item 2) and named in one user sentence, which FED R6
reuses word for word: "The business owner sees your thread's agent's decisions, inbox entries, claims, tasks
and project files, but not your messages."
## Brief: 193a, the access level check widened to viewer
Chained on FED R1. Started early on `morgana/row193a` (508ee4d61) against R1's interface as the FED brief
names it, with Sage's go (sage-1373). R1's level check accepts `viewer` and still refuses any other new
level. Tests in `packages/business/tests/` (section 8, Business), with the four mutants in Morgana's
BUILD.md. FED R4 chains on this row.
Gate: Duckworth approves the manifest. The business suite and every `scripts/test-*.sh` are green on Sage's
gate, and the mutants are killed.
## Brief: 193b1, viewer verbs, board refusals and the message filter
Chained on FED R3, FED R4 and row 196's verifier. The bus gets the viewer verb list, per-grant level
checks, board reads refused to users, and the section 5.7 `trail` and `replies` filter with `#readable`
owner-only. No schema change. The governance pin test names each item in the user sentence above. Bus tests
and mutants 1, 3, 6, 10, 11 and 12 from section 8.
Gate: Duckworth approves the manifest. The bus suite and every `scripts/test-*.sh` are green on Sage's gate,
and the mutants are killed.
## Brief: 193b2, thread shares (schema 3j)
Chained on 193b1. `thread_shares`, the share verbs, the share rules of `#readable` and `#threadRead`, and the
section 5.5 share filters. Schema 3i to 3j. The table is append-only, and row 181's restore guard and
`COUNTED` include it. Bus tests for shares and the migration, and mutants 2, 4, 7, 8 and 9 from section 8.
Going live needs a `--migrate` window with Jason. Sage takes it to Mos when this row is near.
Gate: Duckworth approves the manifest. The bus suite, the cli suite and every `scripts/test-*.sh` are green on
Sage's gate, and the mutants are killed.
## Brief: 193c, mosaic access user verbs and --level viewer
Chained on FED R5. `mosaic access user add/remove` and `grant --level viewer`, only where R5 lacks them. They
refuse tester and agent callers, write a receipt line and call `access.reload` (section 8, CLI). If R5
already has all of them, this row records that and closes with no code.
Gate: Duckworth approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate,
and the mutants are killed.
## Brief: 193d, the Console's shared list, share controls and viewer read-only
Chained on FED R6, row 192 and 193b2 (for share controls). A viewer has no composer or share control, and
with each UI check removed the bus still refuses (section 8, Webui).
Gate: Duckworth approves the manifest. The webui suite and every `scripts/test-*.sh` are green on Sage's
gate, and the mutants are killed.
+56
View File
@@ -0,0 +1,56 @@
# 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. Starting rule (Mos, mos-0985, from Duckworth's federation review B1 at
b402197ff on origin duckworth/reviews): the bus takes a human principal only from something it verifies,
such as the Console's OIDC session under #412. A process's own claim of who it is never counts.
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.
+70
View File
@@ -0,0 +1,70 @@
# 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. No instance's
commands, units or tools address another instance's state by default or by mistake. On one uid this is
addressing only, not a security boundary: a same-uid process can still read another instance's files.
Isolation against that is row 190b (#1693, mos-0986), one Linux user per instance. The current instance keeps working unchanged: its
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.
@@ -0,0 +1,57 @@
# Brief: retire the control board writer on 7331
Sage, 2026-10-20, for Mos's ruling mos-1008 item 2. Gitea #1696. MVP 10-27 work. Owner Bushroot (Haiku 5.5, a
low-level model under Q177), reviewer Webby, gate owner Sage. Base: refactor at the brief commit.
## Why
`packages/control-board/src/serve.mjs` is a loopback HTTP service on 7331 with no authentication. It reads
transcripts and writes through `/api/reply`. Any host process that reaches the port can do both. That fails
Q175 ("zero trust with verification"). Gander found it in the row 196 inventory (gander-0004). Nothing listens
on 7331 now (`ss -ltnp`, 2026-10-20T19:05:09Z), so retiring it costs the least.
## Steps, in order
1. Inventory first. List every reference to the control board and to 7331: scripts, units, `scripts/mosaic`
and other CLI dispatch, docs (AGENTS.md, READMEs, TOOLS.md), package.json scripts, tests, and the Console
(`packages/webui`). For each, say whether it starts the writer, reads its code, or only mentions it.
Commit the list as `agents/bushroot/work/queue-ROW/INVENTORY.md` and send it to Sage and Webby.
2. Stop if something depends on it. If a reference shows a live dependency (the Console or another package
imports code that only the control board has, or a unit or script still starts it), stop. Sage takes it to
Mos before anything is removed.
3. Otherwise retire the writer. Either remove the `serve` entry point, or make it refuse to start with a
message that points to the Console. Keep any read-only code another package imports, and say which.
Update the docs that tell people to start it. The AGENTS.md text about the control-board reply convention
is a separate matter. Change it only if it tells someone to start 7331.
## Mos's call on the Console dependency (mos-1019)
The inventory (004f133bd) found that the Console proxies `/api/board`, `/api/seen`, `/api/reply` and the
conversation routes to `DEFAULT_BOARD` on 7331 (`packages/webui/src/serve.mjs:12`), so the board view returns
502 today. Mos took Sage's recommendation: retire the 7331 listener and its README start commands, and have
the Console call the board's read code in-process, behind the Console's own auth (Q175 SSO on 7332).
`/api/reply` stays only under `--send`. The DESIGN.md for that change comes before code, and Webby approves
it.
## Acceptance
Nothing in the stack can start a writer on 7331. A test proves it: the entry point refuses (or is gone), no
script or unit starts it, and a grep check finds no starter. Each change gets a mutant that brings the writer
back and the test kills it. Mos added three items (mos-1019):
- The board view answers from the Console with nothing listening on 7331.
- Every board route sits behind the Console's auth.
- A test proves `/api/reply` refuses without `--send`.
Test fixtures that bind port 0 may stay if they only test the code in-process.
## Boundaries
- No change to the live units, `~/.mosaic-dev`, `~/.config/mosaic-dev`, slot files or `~/.mosaic`.
- No request to any live port.
- Single test files run freely. Suites need Sage's slot.
## Gate
Webby approves INVENTORY.md, then DESIGN.md, then the manifest. The control-board and webui package suites, the cli suite and
every `scripts/test-*.sh` are green on Sage's gate, and the mutants are killed.
+42
View File
@@ -0,0 +1,42 @@
# Brief: a second gate lane, two full gates at once on dragon-lin
Sage, 2026-10-20, for Mos's order mos-0977 item 6 ("put the design for a second gate lane on the list now")
and the MVP push (mos-0974). Gitea #1695. Owner Herb (Codex gpt-6.1-sol, a new builder seat), reviewer
Webby, gate owner Sage. Base: refactor at the brief commit. Design first. The first deliverable is
`agents/herb/work/queue-ROW/DESIGN.md`.
## Why
Every full run shares one slot today. A full gate (cli suite plus every `scripts/test-*.sh`) takes about 13
minutes or more, reviewers' runs and mutants take the same slot, and with eight or more seats building for
10-27 the slot is the bottleneck. The slot exists because concurrent runs broke each other. The busy check is
`agents/sage/work/gate-lane/busy-now.sh` (a copy of Sage's), and the gate is
`agents/sage/work/gate-lane/gate-template.sh` (a copy of Sage's template). The host has 16 cores and 62 GB.
## What the design must settle
1. Every shared resource two concurrent runs can collide on, found from the code and tests, not guessed:
Docker compose project names and networks (`scripts/test-task.sh` and others), fixed TCP ports in tests,
unix socket paths and their 107-byte limit under TMPDIR, the sandbox's private `/tmp`, shared caches,
scratch and worktree paths, the queue lock, and any test that reads host-wide state (process lists,
`pgrep`, `/proc`). For each: how a lane gets its own copy, or why it is safe to share.
2. Timing. Which tests have time limits that a doubled CPU load could break, measured, and what changes (a
bound, or a lane rule that keeps those suites apart).
3. The lane mechanism: lane ids, a lock per lane that a run takes and releases even on a crash, the busy
check that reports per lane, and how a seat asks for a lane. Sage still grants it.
4. The gate template takes a lane id and isolates everything from point 1 by it.
5. Proof: two full gates on different candidates at once, three times, with zero cross-failures, plus the
wall time of each against a single-lane run. Any flake found is named with its cause.
## Boundaries
- No change to the live units, `~/.mosaic-dev`, `~/.config/mosaic-dev` or slot files. The proof runs use
scratch roots only, and it needs Sage's grant of the whole slot while it runs.
- Test code changes that only isolate a resource are in scope. A change to what a test checks is not, and
goes to its owner.
- Docker networks and containers a run makes are removed by the run (trap), and nothing else is touched.
## Gate
Webby approves DESIGN.md, then the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's
gate, both singly and in the two-lane proof.
+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 Webby (mos-0981), 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, Webby 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
Webby approves the manifest. Filbert approved the design and answers design questions. 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.
@@ -0,0 +1,39 @@
# Brief: stack-release.sh activates a Console with --sso (Q175 phase 2 prerequisite)
Sage, 2026-10-20, for Launchpad's finding F3 on row 192 (launchpad-0001) and Q175 (#412). Gitea #1698. Owner
Launchpad, reviewer Webby, gate owner Sage. Chained on row 192's candidate (Q177): it lands after row 192 and
before the phase 2 window. Base: row 192's candidate.
## Why
The phase 2 window re-pins the Console to the phase 1 commit with `--sso --send --tester-port 7333`
(`agents/sage/work/q175-sso/DESIGN.md`, section 5). `scripts/stack-release.sh` doesn't know `--sso` today.
Its `proof_checks` (`:751`) requires `--public-host`, `--auth-header` and `--allow-user` whenever a proof pair is
carried, and the pointer's Console flags still hold the forward-auth pair (`--auth-header X-authentik-username
--allow-user jason.woltje`) that `--sso` refuses to start with. So the window can't run through the script, and
a hand-edited pointer is not an option.
## What the row does
1. `activate ID BUSINESS --sso` writes Console flags with `--sso` and without `--auth-header`, `--allow-user`
and any proof pair, and `--plan` names each flag it drops and carries, in the style of row 189's `send:`
line. The design names the full flag set the phase 2 Console starts with and checks it against row 192's
refusals.
2. Before anything stops, the script refuses a target copy whose Console doesn't support `--sso`, and refuses
when the config's `console` key or its slot file fails the checks row 192's `--sso` start runs. It reads
neither value and only uses the same stat and shape checks.
3. Rollback from an `--sso` pointer to a pre-phase-1 copy follows the design's phase 2 rollback order (remove
the `console` key first). The script refuses a rollback that would start a Console on a config it can't
read, and says which step comes first.
4. `docs/TOOLS.md` gets the flag.
## Boundaries
- No change to the live pointer, units, `~/.mosaic-dev` or `~/.config/mosaic-dev`. Tests use scratch roots
and seeded pointers, as rows 187 and 189 do.
- No request to the live Console, mosaic.woltje.com or auth.diversecanvas.com.
## Gate
Webby approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate, and the
mutants are killed (one per refusal, and one that keeps `--auth-header` in the written flags).
+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"
}
]
}
+190
View File
@@ -0,0 +1,190 @@
// The access file, <configDir>/access.json (federation design section 3.5,
// FED R1, #414). It names the extra users who may sign in to this instance
// and the grants that let them reach a business's threads. The validator is
// pure: providers, peers, businesses and the owner's sign-in subjects come in
// as arguments. Any failed rule refuses the whole file, which leaves every
// user with no grant (fail closed).
import { join } from "node:path";
import { refuse } from "./errors.mjs";
import { configDir } from "./business.mjs";
import { isPlainObject, requireObject, rejectUnknownKeys, requireId, requirePositiveInt, readJsonFile, deepFreeze } from "./util.mjs";
// The grant levels, most to least (row 193a, #1691): a viewer reads threads shared with it and drives
// no instance. covers() relies on this order.
export const LEVELS = Object.freeze(["collaborator", "viewer"]);
// Ids a user may never take: the bus's own principals.
const RESERVED_USERS = Object.freeze(["human", "tester"]);
const LOCAL_HOME = "local";
const ALL = "*";
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
const SUB_MAX = 255;
export function accessFilePath(dir = configDir()) {
return join(dir, "access.json");
}
function requireArray(value, where) {
if (!Array.isArray(value)) refuse(`${where} must be an array`);
return value;
}
function requireText(value, where) {
if (typeof value !== "string" || value.length === 0 || value.length > SUB_MAX || value.includes("\0")) {
refuse(`${where} must be a non-empty string of at most ${SUB_MAX} characters`);
}
return value;
}
function subjectKey(provider, sub) {
return JSON.stringify([provider, sub]);
}
function checkContext(context) {
const where = "access context";
requireObject(context, where);
rejectUnknownKeys(context, ["providers", "peers", "businesses", "ssoSubjects"], where);
const providers = new Set(requireArray(context.providers, `${where}.providers`).map((p, i) => requireText(p, `${where}.providers[${i}]`)));
const peers = new Set(requireArray(context.peers, `${where}.peers`).map((p, i) => requireId(p, `${where}.peers[${i}]`)));
if (peers.has(LOCAL_HOME)) refuse(`${where}.peers must not name ${JSON.stringify(LOCAL_HOME)}`);
requireObject(context.businesses, `${where}.businesses`);
const businesses = new Map();
for (const [id, business] of Object.entries(context.businesses)) {
const at = `${where}.businesses.${id}`;
requireId(id, `${where}.businesses key`);
requireObject(business, at);
requireId(business.human, `${at}.human`);
const instances = requireArray(business.instances, `${at}.instances`).map((x, i) => requireId(x, `${at}.instances[${i}]`));
businesses.set(id, { human: business.human, instances: new Set(instances) });
}
const owners = new Set();
requireArray(context.ssoSubjects, `${where}.ssoSubjects`).forEach((s, i) => {
const at = `${where}.ssoSubjects[${i}]`;
requireObject(s, at);
owners.add(subjectKey(requireText(s.provider, `${at}.provider`), requireText(s.sub, `${at}.sub`)));
});
return { providers, peers, businesses, owners };
}
function checkUser(user, at, ctx, humans, seenSubjects) {
requireObject(user, at);
rejectUnknownKeys(user, ["id", "home", "subjects"], at);
requireId(user.id, `${at}.id`);
if (RESERVED_USERS.includes(user.id)) refuse(`${at}.id must not be ${JSON.stringify(user.id)}, a bus principal`);
if (humans.has(user.id)) refuse(`${at}.id must not be a business's human (${JSON.stringify(user.id)})`);
if (user.home !== LOCAL_HOME && !ctx.peers.has(user.home)) {
refuse(`${at}.home must be a configured peer or ${JSON.stringify(LOCAL_HOME)} (got ${JSON.stringify(user.home)})`);
}
const subjects = requireArray(user.subjects, `${at}.subjects`);
if (subjects.length === 0) refuse(`${at}.subjects must not be empty`);
subjects.forEach((s, i) => {
const sat = `${at}.subjects[${i}]`;
requireObject(s, sat);
rejectUnknownKeys(s, ["provider", "sub"], sat);
if (typeof s.provider !== "string" || !ctx.providers.has(s.provider)) {
refuse(`${sat}.provider must be a configured provider (got ${JSON.stringify(s.provider)})`);
}
requireText(s.sub, `${sat}.sub`);
const key = subjectKey(s.provider, s.sub);
if (ctx.owners.has(key)) refuse(`${sat} is an owner sign-in subject (console.sso.subjects)`);
if (seenSubjects.has(key)) refuse(`${sat} appears more than once in the file`);
seenSubjects.add(key);
});
}
// Either ["*"] alone or a non-empty list of distinct entries, each checked.
function checkScope(value, where, check) {
const list = requireArray(value, where);
if (list.length === 0) refuse(`${where} must not be empty`);
if (list.includes(ALL)) {
if (list.length !== 1) refuse(`${where} must be ["*"] alone or list entries without "*"`);
return;
}
const seen = new Set();
list.forEach((entry, i) => {
check(entry, `${where}[${i}]`);
if (seen.has(entry)) refuse(`duplicate entry in ${where}: ${JSON.stringify(entry)}`);
seen.add(entry);
});
}
function checkExpires(value, where) {
if (typeof value !== "string" || !ISO_TIME.test(value) || Number.isNaN(Date.parse(value))) {
refuse(`${where} must be an ISO time such as 2026-12-31T00:00:00Z (got ${JSON.stringify(value)})`);
}
const iso = new Date(value).toISOString();
if (iso.slice(0, 19) !== value.slice(0, 19)) refuse(`${where} is not a real time: ${value}`);
}
function checkGrant(grant, at, ctx, users, seenGrants) {
requireObject(grant, at);
rejectUnknownKeys(grant, ["id", "user", "business", "projects", "instances", "level", "expires"], at);
requireId(grant.id, `${at}.id`);
if (seenGrants.has(grant.id)) refuse(`${at}.id is a duplicate grant id: ${grant.id}`);
seenGrants.add(grant.id);
if (typeof grant.user !== "string" || !users.has(grant.user)) refuse(`${at}.user must name a user in this file (got ${JSON.stringify(grant.user)})`);
if (typeof grant.business !== "string" || !ctx.businesses.has(grant.business)) {
refuse(`${at}.business must name a declared business (got ${JSON.stringify(grant.business)})`);
}
const business = ctx.businesses.get(grant.business);
checkScope(grant.projects, `${at}.projects`, (p, where) => {
if (typeof p !== "string" || !UUID.test(p)) refuse(`${where} must be "*" or a bus project id, a lowercase UUID (got ${JSON.stringify(p)})`);
});
checkScope(grant.instances, `${at}.instances`, (x, where) => {
if (typeof x !== "string" || !business.instances.has(x)) refuse(`${where} must be "*" or a declared instance of ${grant.business} (got ${JSON.stringify(x)})`);
});
if (!LEVELS.includes(grant.level)) refuse(`${at}.level must be one of ${LEVELS.join(", ")} (got ${JSON.stringify(grant.level)})`);
if (grant.expires !== undefined) checkExpires(grant.expires, `${at}.expires`);
}
// Check a parsed access document against the instance's context. Returns the
// document frozen. Every refusal is a BusinessError naming the key path. An
// expired grant is not refused: covers() treats it as absent.
export function checkAccess(document, context) {
const ctx = checkContext(context);
const humans = new Set([...ctx.businesses.values()].map((b) => b.human));
if (!isPlainObject(document)) refuse("access file must be a JSON object");
rejectUnknownKeys(document, ["accessVersion", "revision", "users", "grants"], "access file");
if (document.accessVersion !== 1) refuse(`accessVersion must be 1 (got ${JSON.stringify(document.accessVersion)})`);
requirePositiveInt(document.revision, "revision");
const users = new Set();
const seenSubjects = new Set();
requireArray(document.users, "users").forEach((user, i) => {
checkUser(user, `users[${i}]`, ctx, humans, seenSubjects);
if (users.has(user.id)) refuse(`users[${i}].id is a duplicate user id: ${user.id}`);
users.add(user.id);
});
const seenGrants = new Set();
requireArray(document.grants, "grants").forEach((grant, i) => checkGrant(grant, `grants[${i}]`, ctx, users, seenGrants));
return deepFreeze(structuredClone(document));
}
// Read and check the access file. One descriptor, opened without following a
// link, serves the stat checks and the read (readJsonFile). The file holds
// authority, so it must belong to this uid and not be writable by group or
// other, as the business file is.
export function loadAccess(file, context, { uid = process.getuid() } = {}) {
const document = readJsonFile(file, "access file", (stat) => {
if (stat.uid !== uid) refuse(`access file must belong to uid ${uid}: ${file}`);
if ((stat.mode & 0o022) !== 0) refuse(`access file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)}): ${file}`);
});
return checkAccess(document, context);
}
// Does a checked grant cover a thread? `target` is the thread's business, bus
// project id and instance. `level` is required (row 193a, Duckworth's N1 on R1):
// it is the least level the action needs (LEVELS runs from most to least), so `viewer` asks for
// read and any grant covers it, while `collaborator` asks to drive the instance
// and a viewer grant never covers it. An expired grant covers nothing.
export function covers(grant, { business, project, instance }, now, { level } = {}) {
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError("covers needs a valid Date for now");
if (!LEVELS.includes(level)) throw new TypeError(`covers needs a level, one of ${LEVELS.join(", ")} (got ${JSON.stringify(level)})`);
if (grant.expires !== undefined && now.getTime() >= Date.parse(grant.expires)) return false;
if (grant.business !== business) return false;
if (!(grant.projects[0] === ALL || grant.projects.includes(project))) return false;
if (!(grant.instances[0] === ALL || grant.instances.includes(instance))) return false;
// A grant whose own level is unknown covers nothing, even if it skipped checkAccess.
if (!LEVELS.includes(grant.level) || LEVELS.indexOf(grant.level) > LEVELS.indexOf(level)) return false;
return true;
}
+1
View File
@@ -10,5 +10,6 @@ export { validateRoleDocument, loadRoleFile, loadRole } from "./role.mjs";
export { LAYERS, REGISTRY, THINKING, FALLBACK_VARS, checkVars, mergeVars } from "./vars.mjs";
export { parseCredentialRef, checkCredentialRef } from "./credentials.mjs";
export { configDir, businessFilePath, validateBusinessDocument, loadBusiness } from "./business.mjs";
export { LEVELS, accessFilePath, checkAccess, loadAccess, covers } from "./access.mjs";
export { projectFilePath, validateProjectDocument, loadProject } from "./project.mjs";
export { systemVars, resolveInstance, classify } from "./resolve.mjs";
@@ -0,0 +1,81 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { BusinessError } from "../src/errors.mjs";
import * as access from "../src/access.mjs";
// Row 193a (#1691): the viewer level. R1 (federation design r4.5, section 7) owns access.mjs and its
// tests; this file pins only what row 193 adds: a grant's level is collaborator or viewer, and covers()
// tells the two apart with a required least level. Every call into R1 goes through check() below, so a change to R1's loader
// interface edits one place.
const CONTEXT = {
providers: ["authentik"],
peers: ["factory"],
businesses: { "creative-hub": { human: "melanie", instances: ["researcher", "writer"] } },
ssoSubjects: [],
};
const check = (doc) => access.checkAccess(doc, CONTEXT);
const PROJECT = "0b6f4a52-6f1e-4c7a-9a0e-3f1d2c4b5a69";
const NOW = new Date("2026-10-20T19:30:00Z");
function file(level) {
const grant = { id: "g-1", user: "jason", business: "creative-hub", projects: ["*"], instances: ["researcher"] };
if (level !== undefined) grant.level = level;
return {
accessVersion: 1,
revision: 1,
users: [{ id: "jason", home: "local", subjects: [{ provider: "authentik", sub: "s-1" }] }],
grants: [grant],
};
}
function refuses(fn, pattern) {
assert.throws(fn, (error) => {
assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`);
assert.equal(error.exitCode, 2);
assert.match(error.message, pattern);
return true;
});
}
test("a grant's level is collaborator or viewer", () => {
for (const level of ["collaborator", "viewer"]) {
assert.doesNotThrow(() => check(file(level)), level);
}
});
test("any other level refuses with the key path named", () => {
for (const level of ["owner", "admin", "member", "Viewer", "viewer ", "", null, 1, ["viewer"], undefined]) {
refuses(() => check(file(level)), /grants\[0\]\.level/);
}
});
test("covers tells a viewer grant from a collaborator grant", () => {
const target = { business: "creative-hub", project: PROJECT, instance: "researcher" };
const viewer = file("viewer").grants[0];
const collaborator = file("collaborator").grants[0];
// The level is the least one the action needs: share reads ask viewer; create, open, send and share ask
// collaborator (design 9).
assert.equal(access.covers(viewer, target, NOW, { level: "viewer" }), true);
assert.equal(access.covers(viewer, target, NOW, { level: "collaborator" }), false);
assert.equal(access.covers(collaborator, target, NOW, { level: "viewer" }), true);
assert.equal(access.covers(collaborator, target, NOW, { level: "collaborator" }), true);
});
test("covers refuses a call with no level, so a viewer grant can't slip past a collaborator check", () => {
const target = { business: "creative-hub", project: PROJECT, instance: "researcher" };
const viewer = file("viewer").grants[0];
assert.throws(() => access.covers(viewer, target, NOW), TypeError);
assert.throws(() => access.covers(viewer, target, NOW, {}), TypeError);
});
test("a viewer grant still needs its business, project, instance and expiry to cover", () => {
const viewer = { ...file("viewer").grants[0], projects: [PROJECT], expires: "2026-10-21T00:00:00Z" };
const target = { business: "creative-hub", project: PROJECT, instance: "researcher" };
const read = { level: "viewer" };
assert.equal(access.covers(viewer, target, NOW, read), true);
assert.equal(access.covers(viewer, { ...target, business: "other" }, NOW, read), false);
assert.equal(access.covers(viewer, { ...target, project: "7c1e2d3f-0a9b-4c8d-8e7f-6a5b4c3d2e1f" }, NOW, read), false);
assert.equal(access.covers(viewer, { ...target, instance: "writer" }, NOW, read), false);
assert.equal(access.covers(viewer, target, new Date("2026-10-21T00:00:01Z"), read), false);
});
+300
View File
@@ -0,0 +1,300 @@
// FED R1 (#414, row 201): the access file loader and validator, DESIGN.md
// section 3.5, and covers(). Every test works in a scratch directory.
import { test } from "node:test";
import assert from "node:assert/strict";
import { chmodSync, mkdirSync, symlinkSync } from "node:fs";
import { join } from "node:path";
import { BusinessError } from "../src/errors.mjs";
import { LEVELS, accessFilePath, checkAccess, loadAccess, covers } from "../src/access.mjs";
import { tmp, writeJson } from "./helpers.mjs";
const P1 = "0f8b6c1e-2a3d-4e5f-8a9b-0c1d2e3f4a5b";
const P2 = "11111111-2222-4333-8444-555555555555";
function context(over = {}) {
return {
providers: ["authentik"],
peers: ["factory"],
businesses: {
"creative-hub": { human: "melanie", instances: ["researcher", "writer"] },
acme: { human: "jason", instances: ["coder"] },
},
ssoSubjects: [{ provider: "authentik", sub: "owner-sub" }],
...over,
};
}
function doc() {
return {
accessVersion: 1,
revision: 3,
users: [
{ id: "bean", home: "factory", subjects: [{ provider: "authentik", sub: "s-1" }] },
{ id: "chet", home: "local", subjects: [{ provider: "authentik", sub: "s-2" }] },
],
grants: [
{ id: "g-1", user: "bean", business: "creative-hub", projects: ["*"], instances: ["researcher"], level: "collaborator", expires: "2026-12-31T00:00:00Z" },
{ id: "g-2", user: "chet", business: "creative-hub", projects: [P1], instances: ["*"], level: "collaborator" },
],
};
}
// Refuses with a BusinessError, exit 2 unless given, and a message matching `pattern`.
function refuses(fn, pattern, exitCode = 2) {
assert.throws(fn, (error) => {
assert.ok(error instanceof BusinessError, `not a BusinessError: ${error}`);
assert.equal(error.exitCode, exitCode);
assert.match(error.message, pattern);
return true;
});
}
function mutated(change) {
const d = doc();
change(d);
return d;
}
test("a valid document passes and comes back frozen", () => {
const checked = checkAccess(doc(), context());
assert.deepEqual(checked, doc());
assert.ok(Object.isFrozen(checked));
assert.ok(Object.isFrozen(checked.grants[0].projects));
});
test("checkAccess does not freeze or alias the caller's document", () => {
const d = doc();
const checked = checkAccess(d, context());
assert.ok(!Object.isFrozen(d));
assert.notEqual(checked.grants, d.grants);
});
test("LEVELS is the single allowed level set, collaborator and viewer (row 193a)", () => {
assert.deepEqual([...LEVELS], ["collaborator", "viewer"]);
assert.ok(Object.isFrozen(LEVELS));
});
test("accessFilePath is access.json in the config directory", () => {
assert.equal(accessFilePath("/x/y"), join("/x/y", "access.json"));
const dir = tmp();
const saved = process.env.MOSAIC_CONFIG;
process.env.MOSAIC_CONFIG = join(dir, "config.json");
try {
assert.equal(accessFilePath(), join(dir, "access.json"));
} finally {
if (saved === undefined) delete process.env.MOSAIC_CONFIG;
else process.env.MOSAIC_CONFIG = saved;
}
});
test("top level: object, unknown keys, accessVersion, revision, arrays", () => {
refuses(() => checkAccess([], context()), /access file must be a JSON object/);
refuses(() => checkAccess(mutated((d) => { d.extra = 1; }), context()), /unsupported access file key: "extra"/);
refuses(() => checkAccess(mutated((d) => { d.accessVersion = 2; }), context()), /accessVersion must be 1/);
refuses(() => checkAccess(mutated((d) => { delete d.accessVersion; }), context()), /accessVersion must be 1/);
for (const bad of [0, -1, 1.5, "3", undefined]) {
refuses(() => checkAccess(mutated((d) => { d.revision = bad; }), context()), /revision must be a positive integer/);
}
refuses(() => checkAccess(mutated((d) => { d.users = {}; }), context()), /^users must be an array/);
refuses(() => checkAccess(mutated((d) => { delete d.grants; }), context()), /^grants must be an array/);
});
test("users: unknown keys and the id rule", () => {
refuses(() => checkAccess(mutated((d) => { d.users[1].role = "x"; }), context()), /unsupported users\[1\] key: "role"/);
refuses(() => checkAccess(mutated((d) => { d.users[0] = "bean"; }), context()), /users\[0\] must be a JSON object/);
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "Bean"; }), context()), /users\[0\]\.id must match/);
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "bean"; }), context()), /users\[1\]\.id is a duplicate user id: bean/);
});
test("users: human, tester and any business's human are refused", () => {
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "human"; d.grants = []; }), context()), /users\[0\]\.id must not be "human"/);
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "tester"; d.grants = []; }), context()), /users\[1\]\.id must not be "tester"/);
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "melanie"; d.grants = []; }), context()), /users\[0\]\.id must not be a business's human \("melanie"\)/);
// The human of a business the grant doesn't name is refused too.
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "jason"; d.grants = []; }), context()), /users\[1\]\.id must not be a business's human \("jason"\)/);
});
test("users: home is a given peer or local", () => {
refuses(() => checkAccess(mutated((d) => { d.users[0].home = "elsewhere"; }), context()), /users\[0\]\.home must be a configured peer or "local" \(got "elsewhere"\)/);
refuses(() => checkAccess(mutated((d) => { delete d.users[1].home; }), context()), /users\[1\]\.home must be a configured peer/);
refuses(() => checkAccess(doc(), context({ peers: [] })), /users\[0\]\.home must be a configured peer/);
});
test("users: subjects, provider, sub, uniqueness and owner subjects", () => {
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects = []; }), context()), /users\[0\]\.subjects must not be empty/);
refuses(() => checkAccess(mutated((d) => { delete d.users[0].subjects; }), context()), /users\[0\]\.subjects must be an array/);
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].extra = 1; }), context()), /unsupported users\[0\]\.subjects\[0\] key: "extra"/);
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].provider = "google"; }), context()), /users\[1\]\.subjects\[0\]\.provider must be a configured provider \(got "google"\)/);
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].sub = ""; }), context()), /users\[0\]\.subjects\[0\]\.sub must be a non-empty string/);
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].sub = 7; }), context()), /users\[0\]\.subjects\[0\]\.sub must be a non-empty string/);
// A pair twice in one user, and across two users.
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects.push({ provider: "authentik", sub: "s-1" }); }), context()), /users\[0\]\.subjects\[1\] appears more than once/);
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].sub = "s-1"; }), context()), /users\[1\]\.subjects\[0\] appears more than once/);
// The owner's own sign-in subject can't be an extra user.
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].sub = "owner-sub"; }), context()), /users\[1\]\.subjects\[0\] is an owner sign-in subject/);
// The same sub under another provider is a different pair.
const twoProviders = context({ providers: ["authentik", "google"] });
assert.ok(checkAccess(mutated((d) => { d.users[1].subjects[0] = { provider: "google", sub: "owner-sub" }; }), twoProviders));
});
test("grants: unknown keys, id, user and business", () => {
refuses(() => checkAccess(mutated((d) => { d.grants[0].role = "x"; }), context()), /unsupported grants\[0\] key: "role"/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].id = "g-1"; }), context()), /grants\[1\]\.id is a duplicate grant id: g-1/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].id = "G 1"; }), context()), /grants\[0\]\.id must match/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].user = "nobody"; }), context()), /grants\[0\]\.user must name a user in this file \(got "nobody"\)/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].business = "other"; }), context()), /grants\[1\]\.business must name a declared business \(got "other"\)/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].business = "constructor"; }), context()), /grants\[1\]\.business must name a declared business/);
});
test("grants: projects is [\"*\"] or distinct lowercase UUIDs", () => {
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["stack"]; }), context()), /grants\[1\]\.projects\[0\] must be "\*" or a bus project id, a lowercase UUID \(got "stack"\)/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1.toUpperCase()]; }), context()), /grants\[1\]\.projects\[0\] must be "\*" or a bus project id/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1, 7]; }), context()), /grants\[1\]\.projects\[1\] must be "\*" or a bus project id/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["*", P1]; }), context()), /grants\[1\]\.projects must be \["\*"\] alone/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1, "*"]; }), context()), /grants\[1\]\.projects must be \["\*"\] alone/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["*", "*"]; }), context()), /grants\[1\]\.projects must be \["\*"\] alone/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1, P1]; }), context()), /duplicate entry in grants\[1\]\.projects/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = []; }), context()), /grants\[1\]\.projects must not be empty/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = "*"; }), context()), /grants\[1\]\.projects must be an array/);
assert.ok(checkAccess(mutated((d) => { d.grants[1].projects = [P1, P2]; }), context()));
});
test("grants: instances is [\"*\"] or distinct declared instances of that business", () => {
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["coder"]; }), context()), /grants\[0\]\.instances\[0\] must be "\*" or a declared instance of creative-hub \(got "coder"\)/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["*", "writer"]; }), context()), /grants\[0\]\.instances must be \["\*"\] alone/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["writer", "writer"]; }), context()), /duplicate entry in grants\[0\]\.instances/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = []; }), context()), /grants\[0\]\.instances must not be empty/);
assert.ok(checkAccess(mutated((d) => { d.grants[0].instances = ["researcher", "writer"]; }), context()));
});
test("grants: any level other than collaborator or viewer refuses, naming grants[i].level", () => {
for (const bad of ["owner", "Collaborator", "COLLABORATOR", "Viewer", "", null, 1]) {
refuses(() => checkAccess(mutated((d) => { d.grants[1].level = bad; }), context()), /grants\[1\]\.level must be one of collaborator, viewer/);
}
refuses(() => checkAccess(mutated((d) => { delete d.grants[0].level; }), context()), /grants\[0\]\.level must be one of collaborator, viewer \(got undefined\)/);
});
test("grants: expires is optional ISO time, and a past expiry is not refused", () => {
for (const bad of ["2026-12-31", "2026-12-31 00:00:00Z", "2026-12-31T00:00:00+01:00", "tomorrow", 1798675200000, null]) {
refuses(() => checkAccess(mutated((d) => { d.grants[0].expires = bad; }), context()), /grants\[0\]\.expires must be an ISO time/);
}
refuses(() => checkAccess(mutated((d) => { d.grants[0].expires = "2026-02-30T00:00:00Z"; }), context()), /grants\[0\]\.expires is not a real time/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].expires = "2026-13-01T00:00:00Z"; }), context()), /grants\[0\]\.expires must be an ISO time/);
assert.ok(checkAccess(mutated((d) => { d.grants[0].expires = "2026-12-31T00:00:00.500Z"; }), context()));
// DESIGN 3.5: a grant past expires is treated as absent, not refused (sage-1399).
assert.ok(checkAccess(mutated((d) => { d.grants[0].expires = "2001-01-01T00:00:00Z"; }), context()));
});
test("the context is required and checked", () => {
refuses(() => checkAccess(doc()), /access context must be a JSON object/);
for (const key of ["providers", "peers", "ssoSubjects"]) {
const c = context();
delete c[key];
refuses(() => checkAccess(doc(), c), new RegExp(`access context\\.${key} must be an array`));
}
refuses(() => checkAccess(doc(), context({ businesses: [] })), /access context\.businesses must be a JSON object/);
refuses(() => checkAccess(doc(), context({ extra: 1 })), /unsupported access context key: "extra"/);
refuses(() => checkAccess(doc(), context({ peers: ["local"] })), /access context\.peers must not name "local"/);
refuses(() => checkAccess(doc(), context({ ssoSubjects: [{ provider: "authentik" }] })), /access context\.ssoSubjects\[0\]\.sub must be a non-empty string/);
refuses(() => checkAccess(doc(), context({ businesses: { acme: { human: "jason" } } })), /access context\.businesses\.acme\.instances must be an array/);
});
// loadAccess: the file checks, then checkAccess.
test("loadAccess reads a valid file and checks it", () => {
const file = writeJson(join(tmp(), "access.json"), doc());
assert.deepEqual(loadAccess(file, context()), doc());
});
test("loadAccess refuses a missing file, a symlink and a directory with exit 4", () => {
const dir = tmp();
refuses(() => loadAccess(join(dir, "access.json"), context()), /access file not found/, 4);
const real = writeJson(join(dir, "real.json"), doc());
const link = join(dir, "access.json");
symlinkSync(real, link);
refuses(() => loadAccess(link, context()), /access file must be a regular, non-symbolic-link file/, 4);
const sub = join(dir, "d");
mkdirSync(sub);
refuses(() => loadAccess(sub, context()), /access file must be a regular, non-symbolic-link file/, 4);
});
test("loadAccess refuses another owner and group or other write", () => {
const file = writeJson(join(tmp(), "access.json"), doc());
refuses(() => loadAccess(file, context(), { uid: process.getuid() + 1 }), /access file must belong to uid/);
for (const mode of [0o620, 0o602, 0o666]) {
chmodSync(file, mode);
refuses(() => loadAccess(file, context()), /access file must not be writable by group or other/);
}
chmodSync(file, 0o644);
assert.ok(loadAccess(file, context()));
});
test("loadAccess refuses bad JSON and a bad document with exit 2", () => {
const dir = tmp();
refuses(() => loadAccess(writeJson(join(dir, "a.json"), "{"), context()), /access file is not valid JSON/);
refuses(() => loadAccess(writeJson(join(dir, "b.json"), mutated((d) => { d.grants[0].level = "owner"; })), context()), /grants\[0\]\.level/);
});
// covers.
const NOW = new Date("2026-10-20T12:00:00Z");
const target = (over = {}) => ({ business: "creative-hub", project: P1, instance: "researcher", ...over });
// Any level reads: the least level is viewer (row 193a).
const READ = { level: "viewer" };
test("covers: * and listed ids match", () => {
const [g1, g2] = checkAccess(doc(), context()).grants;
assert.equal(covers(g1, target({ project: P2 }), NOW, READ), true);
assert.equal(covers(g2, target({ instance: "writer" }), NOW, READ), true);
assert.equal(covers(g2, target(), NOW, READ), true);
});
test("covers: an unlisted project, instance or business doesn't match", () => {
const [g1, g2] = checkAccess(doc(), context()).grants;
assert.equal(covers(g2, target({ project: P2 }), NOW, READ), false);
assert.equal(covers(g1, target({ instance: "writer" }), NOW, READ), false);
assert.equal(covers(g2, target({ business: "acme" }), NOW, READ), false);
assert.equal(covers(g1, target({ business: "acme" }), NOW, READ), false);
});
test("covers: an expired grant doesn't match, at and after the instant", () => {
const [g1, g2] = checkAccess(doc(), context()).grants;
assert.equal(covers(g1, target(), new Date("2026-12-30T23:59:59.999Z"), READ), true);
assert.equal(covers(g1, target(), new Date("2026-12-31T00:00:00Z"), READ), false);
assert.equal(covers(g1, target(), new Date("2027-01-01T00:00:00Z"), READ), false);
// No expires never expires.
assert.equal(covers(g2, target(), new Date("2099-01-01T00:00:00Z"), READ), true);
});
test("covers: level is the least level asked for (row 193a)", () => {
const g = checkAccess(doc(), context()).grants[0];
const v = { ...g, level: "viewer" };
assert.equal(covers(g, target(), NOW, { level: "collaborator" }), true);
assert.equal(covers(g, target(), NOW, READ), true);
assert.equal(covers(v, target(), NOW, READ), true);
assert.equal(covers(v, target(), NOW, { level: "collaborator" }), false);
});
test("covers: a missing or unknown level throws (row 193a, N1)", () => {
const v = { ...checkAccess(doc(), context()).grants[0], level: "viewer" };
assert.throws(() => covers(v, target(), NOW), TypeError);
assert.throws(() => covers(v, target(), NOW, {}), TypeError);
assert.throws(() => covers(v, target(), NOW, { level: undefined }), TypeError);
for (const bad of ["owner", "Viewer", "", null, 1]) {
assert.throws(() => covers(v, target(), NOW, { level: bad }), /covers needs a level, one of collaborator, viewer/);
}
});
test("covers: a grant with an unknown level covers nothing (row 193a)", () => {
const g = checkAccess(doc(), context()).grants[0];
for (const bad of ["owner", "Collaborator", undefined]) {
assert.equal(covers({ ...g, level: bad }, target(), NOW, READ), false);
}
});
test("covers: now must be a valid Date", () => {
const g = checkAccess(doc(), context()).grants[0];
assert.throws(() => covers(g, target(), Date.now(), READ), TypeError);
assert.throws(() => covers(g, target(), new Date("nope"), READ), TypeError);
});
+112 -5
View File
@@ -381,7 +381,7 @@ async function seed(e,{copy=B,pm=false,store='3g',pointer:how='adopt'}={}){
const rolledBack=(code,to=B)=>`stack-release: ${code}; rolled back to ${to}, which passed the gate\n`;
const planBackup=(e)=>`backup: the run backs up the stopped store to ${e.data}/backups/stack/<activation>/ before any migration`;
const planNoBackup='backup: none; the run rolls back before the backup';
const planTail=(e,to,pm=false)=>[`bus: scripts/bus-service.sh install --copy ${to} --copies ${COPIES}${pm?' --pm':''} --claude ${e.claude}`,'proof: off',`Console: scripts/webui-service.sh install --copy ${to} --copies ${COPIES} --port ${e.port} --business acme`,`CLI: scripts/stack-copy.sh wrapper ${to} --copies ${COPIES} --bin ${e.bin}`];
const planTail=(e,to,pm=false)=>[`bus: scripts/bus-service.sh install --copy ${to} --copies ${COPIES}${pm?' --pm':''} --claude ${e.claude}`,'proof: off','send: off',`Console: scripts/webui-service.sh install --copy ${to} --copies ${COPIES} --port ${e.port} --business acme`,`CLI: scripts/stack-copy.sh wrapper ${to} --copies ${COPIES} --bin ${e.bin}`];
// --- The T table ---
@@ -1030,17 +1030,17 @@ test('T36 --plan shows the pending state and lists the row 169 refusals it can s
return r.out.split('\n');
};
let out=await quiet(0,'activate',C1,'acme','--pm-after-harness','--plan');
assert.deepEqual(out.slice(-6),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,C1),'']);
assert.deepEqual(out.slice(-7),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,C1),'']);
biz(e,HARNESS);
out=await quiet(3,'activate',A,'acme','--pm-after-harness','--plan');
assert.deepEqual(out.slice(-7),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,A),`refuses unknown-kind-for-target: the business file names plug-in kind(s) harness, which ${A} does not know`,'']);
assert.deepEqual(out.slice(-8),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,A),`refuses unknown-kind-for-target: the business file names plug-in kind(s) harness, which ${A} does not know`,'']);
out=await quiet(3,'activate',C1,'acme','--pm-after-harness','--plan');
assert.equal(out.at(-2),'refuses pm-after-harness-set: the business file already sets harness ["claude-code"]; use --pm');
biz(e);assert.equal((await run(e,'activate',C1,'acme','--pm-after-harness')).code,0);
out=await quiet(3,'activate',C,'acme','--plan');
assert.deepEqual(out.slice(-7),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,C,true),`refuses pm-pending: ${PENDING_MSG}`,'']);
assert.deepEqual(out.slice(-8),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,C,true),`refuses pm-pending: ${PENDING_MSG}`,'']);
out=await quiet(0,'rollback','acme','--plan');
assert.deepEqual(out.slice(-6),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,B,true),'']);
assert.deepEqual(out.slice(-7),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,B,true),'']);
});
// Row 176 test 11: supported and older Console copies deliberately differ from the checkout.
@@ -1706,6 +1706,113 @@ test('T54 row 188: a carried pair gets the proof file checks, and a proof file i
});
});
// Row 189 (#1687, mos-0972/0973): activate --no-send drops the pointer's --send; nothing turns it back on.
const sendLines=(out)=>out.filter((l)=>l.startsWith('send:'));
const noSend=(c)=>c.filter((x)=>x!=='--send');
const LIVE_NOTE=`note: ${B} is the pointer's copy; the run re-pins it and only the Console flags change`;
test('T55 row 189: activate --no-send drops --send, and rollback and pm-on carry it off',async(t)=>{
await t.test('test 1: a live-copy --no-send drops --send from the pointer and the unit, keeps the rest in order, logs the step and backs up the stopped store',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']),want=noSend(base);
assert.deepEqual(want.slice(-2),['--tester-port','7333']);
const sum=createHash('sha256').update(readFileSync(e.store)).digest('hex'),n=calls(e).length;
let r=await prun(e,'activate',B,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
assert.equal(pointer(e).copy,B);assert.deepEqual(pointer(e).console,want);
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));assert.doesNotMatch(conExec(e),/ --send( |$)/);
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
assert.match(stepText(e),/Z drop --send; --no-send was given\n/);
const act=pointer(e).activation,l=logLines(e).at(-1);
assert.deepEqual([l.op,l.from,l.to,l.result,l.backup],['activate',B,B,'ok',act]);assert.equal(backupSum(e,act),sum);
assert.deepEqual(startsStops(e,n),[`stop ${CON}`,`stop ${BUS}`,`start ${BUS}`,`start ${CON}`]);
await noLeak(e);
});
await t.test('test 2: a plain live-copy activate still refuses already-active, and so does --no-send once --send is gone',async(t)=>{
const e=await mk(t);await seedPreview(e,undefined,['--send']);
await refused(e,['activate',B,'acme'],'already-active',`${B} is the pointer's copy`);
await refused(e,['activate',B,'acme','--tester-port','7333'],'already-active',`${B} is the pointer's copy`);
await refused(e,['activate',B,'acme','--no-proof'],'already-active',`${B} is the pointer's copy`);
const r=await prun(e,'activate',B,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
await refused(e,['activate',B,'acme','--no-send'],'already-active',`${B} is the pointer's copy`);
const g=await mk(t);await seedPreview(g);
await refused(g,['activate',B,'acme','--no-send'],'already-active',`${B} is the pointer's copy`);
for(const x of [e,g])await noLeak(x);
});
await t.test('test 3: --plan prints one send: line in each case, with the live-copy note on a same-copy run',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send']);
let out=await planOnly(e,0,'activate',B,'acme','--no-send');
assert.deepEqual(sendLines(out),['send: dropped; --no-send was given']);
assert.deepEqual(out.filter((l)=>l.startsWith('note: ')),[LIVE_NOTE]);
assert.equal(out[out.indexOf('send: dropped; --no-send was given')+1],`Console: scripts/webui-service.sh install --copy ${B} --copies ${COPIES} ${noSend(base).join(' ')}`);
out=await planOnly(e,0,'activate',C1,'acme');
assert.deepEqual(sendLines(out),['send: carried']);assert.deepEqual(out.filter((l)=>l.startsWith('note: ')),[]);
assert.ok(out.find((l)=>l.startsWith('Console: ')).endsWith(' --send'));
assert.deepEqual(sendLines(await planOnly(e,0,'activate',C1,'acme','--no-send')),['send: dropped; --no-send was given']);
let r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,noSend(base));assert.match(stepText(e),/Z drop --send; --no-send was given\n/);
assert.deepEqual(sendLines(await planOnly(e,0,'activate',B,'acme','--no-send')),['send: off; --no-send was given and the pointer holds no --send']);
assert.deepEqual(sendLines(await planOnly(e,0,'activate',B,'acme')),['send: off']);
assert.deepEqual(sendLines(await planOnly(e,0,'rollback','acme')),['send: off']);
r=await prun(e,'activate',B,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
assert.match(stepText(e),/Z --no-send was given and the pointer holds no --send, so there is nothing to drop\n/);
assert.doesNotMatch(stepText(e),/drop --send/);
await noLeak(e);
});
await t.test('test 4: after --no-send, a rollback to the earlier copy leaves --send out of the pointer and the unit',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send']);
let r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
assert.equal(pointer(e).copy,B);assert.deepEqual(pointer(e).console,noSend(base));assert.doesNotMatch(conExec(e),/ --send( |$)/);
assert.doesNotMatch(stepText(e),/--send/);
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
await noLeak(e);
});
await t.test('test 4: pm-on after --no-send keeps --send off',async(t)=>{
const e=await mk(t),base=await seedPreview(e,{pm:true},['--send']);
let r=await prun(e,'activate',C1,'acme','--pm-after-harness','--no-send');assert.equal(r.code,0,r.out+r.err);
biz(e,HARNESS);
r=await prun(e,'pm-on','acme');assert.equal(r.code,0,r.out+r.err);
assert.equal(pointer(e).pm,true);assert.deepEqual(pointer(e).console,noSend(base));assert.doesNotMatch(conExec(e),/ --send( |$)/);
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
await noLeak(e);
});
await t.test('test 5: --no-send on rollback, pm-on, status, adopt or rehearse is a usage error',async(t)=>{
const e=await mk(t);await seedPreview(e,undefined,['--send']);
const usageText=(await prun(e,'--help')).err,n=calls(e).length,logs=logLines(e).length,before=fp(e);
for(const args of [['rollback','acme','--no-send'],['rollback','acme','--plan','--no-send'],['pm-on','acme','--no-send'],['status','acme','--no-send'],['adopt','acme','--no-send'],
['rehearse','acme','--backup','/tmp/x','--port','7400','--no-send']]){
const r=await prun(e,...args);assert.equal(r.code,4,args.join(' '));assert.equal(r.out,'');assert.equal(r.err,usageText);
}
assert.match(usageText,/\| --no-proof\] \[--no-send\]/);
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
await noLeak(e);
});
await t.test('test 6: with both a pair and --send in the pointer, --no-send and --no-proof each drop what they name and keep the rest',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send']),pre=noSend(base),f=await proofFile(e),g=await proofFile(e,'proof2.189',0o400);
let r=await prun(e,'activate',B,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,[...pre,...pair(f),'--send']);
let out=await planOnly(e,0,'activate',C1,'acme','--no-proof');
assert.deepEqual([...proofLines(out),...sendLines(out)],['proof: dropped; --no-proof drops header x-console-proof','send: carried']);
out=await planOnly(e,0,'activate',C1,'acme','--no-send');
assert.deepEqual([...proofLines(out),...sendLines(out)],['proof: carried, header x-console-proof','send: dropped; --no-send was given']);
out=await planOnly(e,0,'activate',B,'acme','--no-send','--no-proof');
assert.deepEqual([...proofLines(out),...sendLines(out)],['proof: dropped; --no-proof drops header x-console-proof','send: dropped; --no-send was given']);
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,[...pre,...pair(f)]);
r=await prun(e,'activate',C1,'acme','--no-send',...pair(g,'X-Proof-Two'));assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,[...pre,...pair(g,'X-Proof-Two')]);
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
const h=await mk(t),hbase=await seedPreview(h,undefined,['--send']),hpre=noSend(hbase),k=await proofFile(h);
r=await prun(h,'activate',B,'acme',...pair(k));assert.equal(r.code,0,r.out+r.err);
r=await prun(h,'activate',C1,'acme','--no-proof');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(h).console,[...hpre,'--send']);
r=await prun(h,'activate',C1,'acme','--no-send','--no-proof');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(h).console,hpre);assert.doesNotMatch(conExec(h),/--proof-| --send( |$)/);
const steps=stepText(h);
assert.match(steps,/Z --no-proof was given and the pointer holds no proof pair, so there is nothing to drop\n/);assert.match(steps,/Z drop --send; --no-send was given\n/);
r=await prun(h,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
for(const x of [e,h])await noLeak(x);
});
});
// --- rehearse (D2, row 163; the brief's "Stack rehearsal on a scratch root") ---
// Copies of their own in RCOPIES: the real scripts/mosaic, config loader, business loader, roles and
// mosaic-config.mjs, with stand-ins for the bus host (cli.mjs), the Console, the Store and
+38 -6
View File
@@ -3,7 +3,7 @@
# Moves the bus, the Console and the human CLI from the pointer's copy to another copy together,
# behind a health gate, and puts the old stack back inside the same run when the gate fails.
# Console tester listener flags follow target support (row 176, #1674); so does the row 178 proof
# pair, which activate sets or drops (row 187, #1685).
# pair, which activate sets or drops (row 187, #1685). activate --no-send drops --send (row 189, #1687).
# Design and tests: agents/darkwing/work/queue-162/BUILD.md.
#
# Who runs it. Decision 96 (Mos, mos-0716) lets this script stop and start the live units under
@@ -30,13 +30,13 @@ BIN="$HOME/.npm-global/bin"
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
CONSOLE_UNIT=mosaic-webui.service
TIMEOUT=60 WANT_PM=0 AFTER_HARNESS=0 MIGRATE=0 PLAN=0 RESTORE="" EVENTS="" TESTER_PORT=""
PROOF_HEADER="" PROOF_FILE="" NO_PROOF=0 R_BACKUP="" R_PORT=""
PROOF_HEADER="" PROOF_FILE="" NO_PROOF=0 NO_SEND=0 R_BACKUP="" R_PORT=""
usage() {
cat >&2 <<'USAGE'
usage: stack-release.sh adopt BUSINESS
stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-port N]
[--proof-header NAME --proof-file PATH | --no-proof] [--timeout S] [--plan]
[--proof-header NAME --proof-file PATH | --no-proof] [--no-send] [--timeout S] [--plan]
stack-release.sh pm-on BUSINESS [--timeout S]
stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
stack-release.sh status BUSINESS
@@ -85,6 +85,7 @@ parse_opts() {
--proof-header) [ "$OP" = activate ] && [ $# -ge 2 ] && [ -n "$2" ] || usage; PROOF_HEADER="$2"; shift 2 ;;
--proof-file) [ "$OP" = activate ] && [ $# -ge 2 ] && [ -n "$2" ] || usage; PROOF_FILE="$2"; shift 2 ;;
--no-proof) [ "$OP" = activate ] || usage; NO_PROOF=1; shift ;;
--no-send) [ "$OP" = activate ] || usage; NO_SEND=1; shift ;;
--migrate) [ "$OP" = activate ] || usage; MIGRATE=1; shift ;;
--restore) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]] || usage; RESTORE="$2"; shift 2 ;;
--backup) [ "$OP" = rehearse ] && [ $# -ge 2 ] && { [[ "$2" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]] || [[ "$2" =~ ^/[A-Za-z0-9/._+-]*$ ]]; } || usage; R_BACKUP="$2"; shift 2 ;;
@@ -244,14 +245,16 @@ target_schema() {
in_chain() { local v; for v in "${T_CHAIN[@]}"; do [ "$v" != "$1" ] || return 0; done; return 1; }
# The item 3 checks on the target copy, in order: copy, schema, already-active. An activate that
# sets a proof pair, or drops the pointer's pair with --no-proof, may name the pointer's copy: it
# re-pins the live copy with new Console flags (row 187, Part B's B3 and B-R1).
# sets a proof pair, drops the pointer's pair with --no-proof, or drops the pointer's --send with
# --no-send, may name the pointer's copy: it re-pins the live copy with new Console flags (row 187,
# Part B's B3 and B-R1; row 189, mos-0972).
check_target() {
copy_exists "$TO" 2>/dev/null || refuse copy-missing "no copy $TO in $COPIES"
copy_ok "$TO" 2>/dev/null || refuse copy-unreachable "$TO is not reachable from refactor and origin/refactor"
target_schema "$TO" || refuse copy-schema-unknown "cannot read SCHEMA_VERSION and its chain from $COPIES/$TO/packages/bus/src/store.mjs"
[ "$TO" != "$P_COPY" ] || [ -n "$PROOF_HEADER" ] ||
{ [ "$NO_PROOF" = 1 ] && [[ " ${P_CONSOLE[*]} " == *" --proof-header "* ]]; } ||
{ [ "$NO_SEND" = 1 ] && [[ " ${P_CONSOLE[*]} " == *" --send "* ]]; } ||
refuse already-active "$TO is the pointer's copy"
}
@@ -690,6 +693,33 @@ console_for_target() {
DROP_TESTER="--tester-port $inherited"
fi
proof_for_target
send_for_target
}
# Row 189 (mos-0972): --no-send drops --send from RUN_CONSOLE, so the unit and the pointer both lose
# it; without the flag the pointer's --send goes on unchanged. Rollback and pm-on never take it, so
# they carry the pointer as it is. Sets SEND_PLAN (the plan's send: line) and SEND_STEP.
send_for_target() {
local had=0 i
local -a without_send=()
for ((i = 0; i < ${#RUN_CONSOLE[@]}; i++)); do
if [ "${RUN_CONSOLE[i]}" = --send ]; then had=1; else without_send+=("${RUN_CONSOLE[i]}"); fi
done
SEND_STEP=""
if [ "$NO_SEND" = 1 ]; then
RUN_CONSOLE=("${without_send[@]}")
if [ "$had" = 1 ]; then
SEND_PLAN="send: dropped; --no-send was given"
SEND_STEP="drop --send; --no-send was given"
else
SEND_PLAN="send: off; --no-send was given and the pointer holds no --send"
SEND_STEP="--no-send was given and the pointer holds no --send, so there is nothing to drop"
fi
elif [ "$had" = 1 ]; then
SEND_PLAN="send: carried"
else
SEND_PLAN="send: off"
fi
}
# Row 187: the row 178 proof pair in RUN_CONSOLE. A new pair replaces the pointer's and goes last;
@@ -943,6 +973,7 @@ plan() {
echo "bus: scripts/bus-service.sh install --copy $TO --copies $COPIES$([ "$RUN_PM" = 0 ] || echo ' --pm') --claude $P_CLAUDE"
[ -z "$DROP_TESTER" ] || echo "drop $DROP_TESTER; the target Console does not support it"
echo "$PROOF_PLAN"
echo "$SEND_PLAN"
echo "Console: scripts/webui-service.sh install --copy $TO --copies $COPIES${RUN_CONSOLE[*]:+ ${RUN_CONSOLE[*]}}"
echo "CLI: scripts/stack-copy.sh wrapper $TO --copies $COPIES --bin $BIN"
local r
@@ -956,6 +987,7 @@ run_steps() {
capture || refuse capture-failed "could not write the rollback set $RS; nothing was stopped"
[ -z "$DROP_TESTER" ] || say "drop $DROP_TESTER; the target Console does not support it"
[ -z "$PROOF_STEP" ] || say "$PROOF_STEP"
[ -z "$SEND_STEP" ] || say "$SEND_STEP"
trap 'after_fail signal' INT TERM HUP
stop_both || rollback_in_run stop-timeout
if [ -e "$STORE" ]; then
@@ -1478,7 +1510,7 @@ STORE="$DATA/bus/bus.sqlite"
BUS_UNIT="mosaic-bus@$BUSINESS.service"
FROM="" TO="" ACT="" RS="" POST="" BACKUP="" TAKEN="" RESTORED="" STEPS="" COUNT="" UNCLEAN="" FAIL=""
STORE_SCHEMA="" START_EPOCH="" START_MS=0 GATE="" GATE_WHY="" S_CHANGED=""
P_COPY="" P_PM=0 P_PENDING=0 P_SCHEMA="" P_CLAUDE="" P_CONSOLE=() RUN_PM=0 RUN_PENDING=0 RUN_CONSOLE=() DROP_TESTER="" PROOF_PLAN="" PROOF_STEP="" PLAN_REFUSE=()
P_COPY="" P_PM=0 P_PENDING=0 P_SCHEMA="" P_CLAUDE="" P_CONSOLE=() RUN_PM=0 RUN_PENDING=0 RUN_CONSOLE=() DROP_TESTER="" PROOF_PLAN="" PROOF_STEP="" SEND_PLAN="" SEND_STEP="" PLAN_REFUSE=()
R="" R_RC=0 R_EARLY=() R_BK="" R_HOST_PID="" R_CONSOLE_PID="" R_STARTED="" LIVE_CFG="" LIVE_BIZ=""
case "$OP" in
adopt) do_adopt ;;