Compare commits

..
Author SHA1 Message Date
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
40 changed files with 1624 additions and 506 deletions
+6
View File
@@ -19,6 +19,12 @@ not fleet seats. Keep changes in `/mnt/storage/src/mosaic-stack`; do not modify
| Launchpad | Builder, MVP T1 row 192 (Q175 SSO phase 1) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
| Morgana | Builder, MVP T1 row 193 (human users and RBAC) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
| Honker | Builder, MVP T1 row 194 (agent lifecycle) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
| Gander | Builder, the human boundary across uids (P19 333, #1694) | T3 session (Codex, gpt-6.1-sol), started by Sage under mos-0977 | none, T3 only |
| Herb | Builder, a second gate lane (#1695) | T3 session (Codex, gpt-6.1-sol), started by Sage under mos-0977 | none, T3 only |
| Bushroot | Builder, retire the control board writer on 7331 (#1696) | T3 session (Claude, claude-haiku-5-5), started by Sage under mos-1008 | none, T3 only |
| 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` |
+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.
+1 -1
View File
@@ -3,5 +3,5 @@
Reviewer seat, Claude Opus 5.5, started by Mos (mos-0977, thread ffceafec). Reviews federation designs and MVP T1 row 193.
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
credentials of its own; until row 195 settles per-agent identity, it pushes only its own `duckworth/*` branches
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.
+1 -1
View File
@@ -3,5 +3,5 @@
Builder seat, Claude Opus 5.5, started by Sage for MVP T1 row 194 (agent lifecycle, #1692) under mos-0974 and mos-0977.
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
credentials of its own; until row 195 settles per-agent identity, it pushes only its own `honker/*` branches
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.
+1 -1
View File
@@ -3,5 +3,5 @@
Builder seat, Claude Opus 5.5, started by Sage for MVP T1 row 192 (Q175 SSO phase 1, #1690) under mos-0974 and mos-0977.
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
credentials of its own; until row 195 settles per-agent identity, it pushes only its own `launchpad/*` branches
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.
+1 -1
View File
@@ -3,5 +3,5 @@
Builder seat, Claude Opus 5.5, started by Sage for MVP T1 row 193 (human users and RBAC, #1691) under mos-0974 and mos-0977.
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
credentials of its own; until row 195 settles per-agent identity, it pushes only its own `morgana/*` branches
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.
-482
View File
@@ -1,482 +0,0 @@
# Row 193 design: many humans per business, human roles, private and shared threads
Morgana (Opus 5.5), 2026-10-20. Revision 5: sage-1373 item 1 (mos-1023 item 2), the user sentence names every record the owner sees. Revision 4: duckworth-0011 B3 and N5. Revision 3: duckworth-0008 B1, B2 and N1-N4, sage-1368. Revision 2: sage-1354 (5.7 private from the owner, 193b split) and gizmoduck-0018 (coverage and the R4 predicate in FED r4.1). Gitea #1691, queue row 193, brief `docs/plans/2026-10-20_human-users.md`
(blob f7472d1e47b1). Reviewer Duckworth, gate owner Sage. Base: `refactor` at 53bb59462.
Read for this design: COMMON.md (53bb59462), the brief, Gizmoduck's federation design r4.4 (b67bc6de9 on
gizmoduck/federation-design, cited as FED; r3.1 a2840a6be was read first), Duckworth's federation reviews (b402197ff, 03a663cd7,
45cbe5896 on duckworth/reviews), Sage's Q175 SSO design (`agents/sage/work/q175-sso/DESIGN.md`), the row 192
and row 196 briefs, Darkwing's row 190 packet (uncommitted, sha256 5493c9df3ec0...), and the bus, business,
cli and webui code at the lines cited. Messages: morgana-0001 to 0003, sage-1338, gizmoduck-0009,
gander-0002 and gander-0005.
## 1. Summary
- Three human roles: **owner**, **collaborator**, **viewer**. The owner is the business file's one `human`,
as today. Collaborators and viewers are access.json users, the same store federation uses (FED 3.5).
Their role is the `level` of a grant, so a role is per project and instance, not per person.
- Every human principal is minted by one bus verifier, owned by row 196 (Gander). Row 193 states what it
needs from that verifier (section 6) and adds no second mechanism.
- Threads stay private to their owner by default. A thread owner may share a thread, read-only, with a
named user, with the owner, or with everyone granted on it. Shares live in a new append-only bus table,
`thread_shares`. This is a deviation from brief point 4, agreed by Sage (sage-1338), for Duckworth to rule
on (section 5.1).
- A share never widens a grant. It counts only while the reader's live grant covers the thread's project
and instance, and while the thread's owner still has coverage. Revocation, expiry and unsharing take
effect on the next request.
- Existing businesses keep working unchanged: every existing thread is already owned by the business
human, and an instance without access.json has exactly one human, the owner.
- The build splits so the part without a schema change can land first. `thread_shares` needs schema 3j and
a `--migrate` window with Jason.
## 2. What exists today (measured at 53bb59462)
- One `human` per business (`packages/business/src/business.mjs:160`). `bindHuman` refuses any other name
(`packages/bus/src/broker.mjs:532-538`).
- The owner's CLI proof is path-bound (#225): `verifyHuman` checks uid, start time, nonce
(`MOSAIC_BUS_CLI_NONCE`), `argv[1]` equal to the tree's `human-cli.mjs`, then walks parents and refuses
agent markers, launch records and agent binaries (`packages/bus/src/human.mjs:64-96`).
`authenticateHuman` binds the proof to `businesses[business].human` (`packages/bus/src/runtime.mjs:175-182`).
- Threads: `threads(seq,id,at,business,project,instance,owner,name)`, append-only
(`packages/bus/schema.sql:247`, triggers at :310-312). `#owner(s)` returns `tester` or `s.human`
(broker.mjs:364-366). `#thread` refuses a non-owner with `thread-not-owned` (:357-362).
- Thread verbs are gated `if (!s.tester) this.#human(s)`: `project.list` (:2318), `thread.create` (:2325),
`thread.list` (:2351, owner filter), `thread.open` (:2365), `thread.send` (:2374, writes `human.input`
for the human only), `thread.messages` (:2401).
- Board reads are business-wide and human or reader only: `inbox`, `agents`, `tasks`, `trail` (:2143-2167),
`decisions` (:2174), `credentials` (:2195), `messages` (:2217, `to_role='human'`), `replies` (:2422).
`trail` and `replies` return any message in the business by id (:2679-2700, :2422-2432).
- The Console reads threads by polling the same verbs (`packages/webui/src/chat.mjs:22-26`). It has no
stream, so a refusal on the next poll ends access.
- The launcher restarts a thread with pending messages as the business human
(`packages/cli/src/launcher.mjs:533-542`).
## 3. The human model (brief point 1)
### 3.1 Who the humans are
| Human | Where it is declared | How many | Identity |
|---|---|---|---|
| owner | business file `human` (unchanged) | exactly one per business | the business human's name |
| user (collaborator or viewer) | access.json `users[]` with `home: "local"` (or a peer id under federation) | any number | `user:<id>` |
| tester | not a human; row 177 test principal | one | `tester` |
The owner stays outside access.json (FED 9 Q2). A business has no second owner and no admin level. Adding
one is out of scope; section 9 lists it as a follow-up for Jason.
### 3.2 How a sign-in or a call becomes exactly one human
| Entry | Becomes | Checked by |
|---|---|---|
| Console SSO session whose `(provider, sub)` is in `console.sso.providers[].subjects` (row 192) | the owner | the Console after OIDC checks, then the bus verifier (row 196) |
| Console SSO session whose `(provider, sub)` is a `users[].subjects` entry in access.json with a live grant | that one user | same |
| Console SSO session matching neither | nobody: refused | the Console, and the bus refuses any later claim |
| CLI call through `human-cli.mjs` that passes the human proof | the owner | the bus (`verifyHuman`) |
| Any other process, including an agent | nobody | the bus |
Why each is exactly one:
- The R1 loader refuses a `(provider, sub)` pair that appears twice in access.json or also in
`console.sso.subjects` (FED 3.5). So one subject maps to at most one human.
- A user id can't be `human`, `tester` or any business's human (FED 3.5). So a user can't be named as the
owner.
- A terminal maps only to the owner. The only identity the bus can verify for a local process is the
instance's OS user, and that is the owner's. A user has no CLI path in row 193. This follows Mos's B1 rule
(mos-0985): the principal comes from what the bus verifies, never the process's own claim.
- The bus never reads a user name from a request body as identity. The verifier returns the principal
(section 6).
## 4. Human roles (brief point 2)
### 4.1 The three roles
- **owner**: everything the business human does today. Only the owner restructures the organization
(business file writer, project and role changes) and only the owner manages users and grants.
- **collaborator**: FED 3.5's level, unchanged. Creates threads in granted projects on granted instances,
sends into and opens its own threads, and in row 193 also shares its own threads.
- **viewer**: new. Reads threads shared with it, within its grant. It creates, opens and sends nothing, so
it never drives an instance. FED 3.5 notes that a collaborator's message drives the instance with the
owner's role authority (F10). A viewer has none of that.
The word "collaborator" stays, so FED's tests and help text need no rename. The brief's example "member" is
this level.
A role is the `level` on a grant, and a grant is per business, project set and instance set. A user may
hold a collaborator grant on one project and a viewer grant on another. Every check asks "does a live
grant of a level that allows this verb cover this project and instance". A user has no single role.
### 4.2 R1 widening
FED R1 refuses any level but `collaborator`, because the 10-27 slice ships only that (FED 9 Q2).
Row 193 widens R1's level check to `collaborator` or `viewer`, with its own test that each is accepted and
anything else refuses. Nothing else in the access file changes. If R1 has not landed when row 193 builds,
this one-line widening goes into the row 193a candidate chained on R1's candidate (Q177).
### 4.3 Bus verbs per role
Every verb not in a user's column fails `access-refused` before its own checks, as FED 3.5 requires for
collaborators. That covers every owner verb, every board read, `message.*`, `decision.*`, project files,
`spec.read`, `assistant.stop`, `launch.*`, `name.set`, `usage.*` and `access.reload`.
| Verb | owner | collaborator | viewer |
|---|---|---|---|
| `project.list` | all | projects covered by any live grant | projects covered by any live grant |
| `thread.create` | yes | collaborator grant covers project and instance | refused |
| `thread.list` | own threads | own threads still covered | own threads still covered (from an earlier collaborator grant) |
| `thread.list {shared: true}` (new) | threads shared with the owner | threads shared with me and readable (5.4) | same |
| `thread.open` | own | own, collaborator grant still covers | refused |
| `thread.send` | own | own, collaborator grant still covers, no `human.input` | refused |
| `thread.messages` | own, or shared with the owner | own (any level still covering), or shared and readable | same as collaborator |
| `thread.share`, `thread.unshare` (new) | own threads | own threads, collaborator grant still covers | refused |
| `thread.shares` (new) | own threads | own threads | refused |
| `names` | all | instances covered by any live grant | instances covered by any live grant |
| every other verb | as today | refused | refused |
Notes:
- `thread.list` keeps its response shape exactly. Shared threads come only from `thread.list {shared: true}`,
whose rows add `owner`. The Console and existing tests read the plain list unchanged.
- A viewer can still read its own threads when a collaborator grant is lowered to viewer, but can't send
or open them. This is the downgrade path.
- The tester keeps its row 177 verbs. It can't share or be shared with: `thread.share`, `thread.unshare`
and `thread.shares` fail `tester-refused`, and a share target is never `tester`. Agent testers (Q173) test
sharing through the bus suites, not the live 7333 listener.
- `reader` sessions (Console read caps, broker.mjs:1303) are the owner's board view and stay owner-only.
A user never gets a reader session.
### 4.4 Console per role
- owner: as today, plus a share control on each own thread and a "Shared with me" list.
- collaborator: the chat view only, for granted projects (FED R6's granted-user sign-in), plus share
controls on its own threads and "Shared with me". No board.
- viewer: "Shared with me" and its own old threads, read-only. No composer, no open, no share control.
- The Console hides controls a role can't use, but the bus check is authoritative. A Console bug can't
widen a grant (FED 3.6). Each role gets a webui test with the UI check removed (mutant) that the bus still
refuses.
### 4.5 Who manages humans and roles
Only the owner, through `mosaic access` (FED R5), which checks the human proof and refuses tester and agent
callers. Row 193 needs from R5:
- `mosaic access user add <id> --home local --subject <provider>:<sub>` and `user remove <id>`;
- `--level viewer` on `grant`;
- the receipt line in `<dataRoot>/state/access-log.jsonl` and the `access.reload` call, as FED R5 has.
If R5 has no `user` verbs when row 193 builds, row 193c adds them on R5's candidate. The Console gets no
user or grant writes in row 193: a write from the Console would need a new owner verb that writes
access.json from the bus, and access.json is a config file only the owner's CLI writes. A read-only users
page for the owner is a follow-up (section 9).
The owner keeps every restructuring power (TARGET Case 1). Users can't touch the business file, projects,
roles or access.json.
## 5. Private and shared threads (brief point 3)
### 5.1 Deviation from brief point 4, agreed by Sage (sage-1338)
Brief point 4 says access.json is the one store. Users, roles and grants live there. Per-thread shares do
not, for three reasons (morgana-0002):
- F1. Thread ids are bus-generated UUIDs. A hand-typed share with a typo would fail the loader, and the
loader refuses the whole file on any failure, which revokes every user.
- F2. Only the owner writes access.json. A collaborator could never share its own thread.
- F3. Threads are append-only bus rows, and a share is a runtime act by a principal that wants a trail.
So shares go in an append-only bus table, gated by the grant. Sage agreed (sage-1338). Gizmoduck had no
objection (gizmoduck-0009) and recorded it as FED 9 Q2. Duckworth rules on it in this review.
### 5.2 Schema 3j
```sql
CREATE TABLE thread_shares (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
op TEXT NOT NULL CHECK (op IN ('share','unshare')),
target TEXT NOT NULL,
by TEXT NOT NULL
) STRICT;
CREATE INDEX thread_shares_thread ON thread_shares (thread, target, seq);
-- no_update, no_delete and no_replace triggers, as thread_opens has (schema.sql:319-321).
```
- `target` is an owner string: `user:<id>`, the business human's name, or `*`. A share whose target is a
name that is no longer the business's `human` (after a rename) is void: rule 1 in 5.4 compares against
the current owner string, so it matches nobody (Duckworth N4).
- `by` is the sharer's owner string, which is always the thread's owner.
- A share's state is its latest row per `(thread, target)` (Duckworth N12, REVIEW-r3.md:74-76). Shares
are not assumed to only grow.
- No existing row changes. Existing threads are already owned by the business human (`#owner` at
broker.mjs:364-366), so they are the owner's and private. The brief's "all become the current human's"
is already true.
The migration `3i-3j.sql` creates the table, index and triggers. `thread_shares` joins every table list that
holds timed append-only tables: `timedTables` in store.mjs (:60-74), the broker's clock list
(broker.mjs:223-235), the restore guard's counted tables (row 181) and `COUNTED` in
`scripts/stack-release.sh:315`. The schema digest for 3j is pinned as the other versions are.
### 5.3 Verbs
- `thread.share {thread, to}`: the caller must own the thread (`thread-not-owned`). For a user, a
collaborator grant must still cover the thread. `to` is `user:<id>`, the business human's name or `*`.
A `user:<id>` target must have a live grant, of any level, covering the thread's project and instance,
else `share-not-covered`. Sharing with yourself fails `invalid-request`. A share that is already in force
fails `share-exists`. Writes one `share` row.
- `thread.unshare {thread, to}`: the caller must own the thread. The share must be in force, else
`share-not-found`. Writes one `unshare` row. Unsharing the owner's or `*` shares works the same way.
- `thread.shares {thread}`: the caller must own the thread. Returns the shares in force, latest row each.
- Both writes go through `refuse()` on failure, so each refusal records `action.refused` with `by` set
(FED 3.6 item 6).
### 5.4 The read check, one function
`#readable(s, t)` decides whether a session may read thread `t` it doesn't own. True only when all hold:
1. a share for `t` is in force whose target is the caller's owner string, or `*`;
2. the caller is the owner, or the caller has a live grant of any level covering `t.project` and
`t.instance`;
3. if `t.owner` is a user, that user still has a live grant covering `t` (a revoked owner hides the
thread, sage-1338);
4. `t.owner` is not `tester`.
`#thread(s, id)` keeps its owner check for `thread.open`, `thread.send`, `thread.share`, `thread.unshare`
and `thread.shares`. A new `#threadRead(s, id)` admits the owner or `#readable`, and serves only
`thread.messages`. A sharee fails `thread-not-owned` on every write verb.
### 5.5 Every read path and its filter
| Read path | Today | After row 193 |
|---|---|---|
| `thread.list` | `owner=?` | unchanged for the owner; for users also each row's thread still covered |
| `thread.list {shared: true}` | none | rows where `#readable` holds |
| `thread.messages` | `#thread` | `#threadRead` |
| `thread.open`, `thread.send` | `#thread` | `#thread`, plus a collaborator grant for users |
| `project.list`, `names` | business-wide | per FED 3.5 for users, unchanged for the owner |
| `trail`, `replies` | human, reader or tester; any message by id | users refused (`access-refused`); for the others, messages in other users' unshared threads hidden (5.7) |
| `messages`, `inbox`, `agents`, `tasks`, `decisions`, `credentials` | human or reader | users refused (`access-refused`); owner unchanged. A user-thread run can't send to the human (`user-thread-human`, FED 3.6), so `messages` holds none of a user's thread |
| `message.receive` by a run | its own thread (:1933) | unchanged: runs read their own thread, never a share |
| `threadsState` (launcher) | all threads | adds `revoked` per 5.6 |
### 5.6 Revocation, expiry and unsharing
Gizmoduck asked that a grant loss end access to threads shared with that user (gizmoduck-0009 point 2).
- A sharee holds no claim and no run. Its access is only reads, and every read runs `#readable` against the
access state the broker already refreshes on each request (mtime, size, inode), on its 30 s timer, and on
`access.reload` (FED 3.7). So a removed, expired or invalid grant ends share reads on the next request,
with nothing to tear down. The Console polls, so a revoked sharee's next poll fails.
- An unshare row ends access on the next request in the same way.
- A user who loses coverage of their own thread also loses its shares: rule 3 in 5.4.
The downgrade case. With viewer, "no live grant covers" would be wrong for runs: a viewer grant covers the
thread but must not let a run continue. FED puts this in R4: `revoked`, claim end and the launch check call
`covers` with level `collaborator`, so a downgrade to viewer ends the run within one launcher tick. FED r4.4
(b67bc6de9, mos-1020) chains R4 after row 193's viewer level (193a) and keeps the level-loss test and its
mutant in R4. Row 193 carries neither (Duckworth N3), so the mutant has one owner.
### 5.7 What "private" means, stated plainly
Sage ruled (sage-1354): a user's thread is private from the owner on the bus too. Jason's words are "private
agent sessions per user", and in Case 2 the owner of Melanie's hub is not the user whose thread it is.
Duckworth's B1 asked where the line sits, since a user-thread run acts with the owner's role authority (F10).
I took Duckworth's option (a): **the user's messages are private, and the run's governance is not.** The
run spends the owner's authority, so its decisions, task writes and claims are the owner's to see and
answer. Filtering them too (option b) would need a thread link on `decisions`, more schema, and would hide
from the owner what is done in the owner's name.
Every read an owner or reader session has, and its side of the line:
| Read | Shows | Side |
|---|---|---|
| `thread.list`, `thread.messages`, `thread.open` | own threads only, plus shares to the owner from 193b2 | private: other users' threads never appear |
| `trail` | events, messages, decisions and deliveries by subject | messages filtered (below); decision rows and their events stay visible |
| `replies` | messages replying to an id | filtered (below) |
| `messages`, `message.receive`, `message.read` | messages `to_role='human'` (broker.mjs:1931-1936, :1953) | private by construction: a user-thread run can't send to the human (`user-thread-human`, FED 3.6 item 7) |
| `inbox` | open decisions routed to the human | governance, visible |
| `decisions {list: roles \| recent}` | decision rows with question, options and recommendation | governance, visible |
| `agents` | role claims and each held role's state | governance, visible |
| `tasks`, `state` | tracker tasks and launch state | governance, visible |
| `credentials`, `usage` | credential state, usage records | governance, visible; neither holds message text |
| `project.files`, `project.file` | project files, including records a run writes with `record.write` (broker.mjs:1395-1407, :1441-1450) | governance, visible |
| `project.list`, `names` | projects and instance names | business-wide for the owner, visible |
So a decision a user-thread run raises shows in the owner's `inbox` or `decisions`, with the free text the
run wrote. Its role claims show in `agents`, its task writes in `tasks`, and a record it writes in
`project.file`. Any of these can quote the user, and 5.7 doesn't call them private. The users' help text
(R5) and the Console's share and thread pages say so in one sentence, which FED R6 reuses (mos-1023 item 2,
duckworth-0012 N21):
> The business owner sees your thread's agent's decisions, inbox entries, claims, tasks and project files,
> but not your messages.
The gated-raise route. A user-thread run's uncited gated raise or hard wait routes to the human (broker.mjs:1677-1680).
FED r4.5 (acd5f1f42, B9) settled this with no guard: the tester guard at :1683 (`tester-thread-human`) is not
copied, and the decision reaches the owner's inbox and no other (sage-1368, mos-1021, mos-1022). FED R3 tests it.
Row 193 adds nothing here.
The message filter. A message is hidden from a session when it is in a thread (`message_threads`) whose owner
is a `user:<id>` other than the caller, and `#readable(s, t)` is false. Threads owned by the business human or
by `tester` are not affected, so row 177's behaviour is unchanged.
- `trail`: drops hidden messages, their `deliveries` rows, and `events` rows whose `subject` is a hidden
message id. A `subject` that is itself a hidden message returns `message-not-found`, as an unknown id does,
so the reply doesn't confirm the id exists.
- `replies {id}`: a hidden `id` fails `message-not-found`, and hidden messages are dropped from the result.
- It applies to every session that can call these verbs: the owner, reader sessions and the tester.
- It lands in 193b1, with no schema (Duckworth B2). Before 193b2 there are no shares, so `#readable` is false
for anyone but the thread's owner and b1 hides every message in another user's thread. 193b2 adds only the
share exception. So no build order can put a user thread live without the filter.
What it does not do. The owner holds the store as the instance's OS user and can read it with sqlite. That is
the same-uid limit in section 6, not a bus path. An owner audit verb, explicit and logged, is a follow-up row
only if Jason asks for one (sage-1354).
## 6. The bus human boundary (brief point 5)
Row 196 (Gander, #1694, brief `docs/plans/2026-10-20_human-boundary.md` at 96f243faf) owns P19 333 and the
verifier. Row 193 cites it and designs no boundary of its own (sage-1338: one mechanism).
Agreed with Gander (gander-0002, morgana-0003, gander-0005):
- One bus verifier, `verifyPrincipal(request, connection) -> {kind, business, id}`, kind `owner`, `user` or
`tester`. It mints one transient principal per request. It returns no role. The broker reads the level
from the live grant on every request, so a grant change acts without a new principal.
- Gander recommends FED 3.6's shape for users (path-bound `user-cli.mjs`, direct parent the bus tree's
`packages/webui/src/cli.mjs serve`) plus kernel peer PID binding, and weighs bus-side ID-token
verification as the larger alternative.
- Correction from Gander, accepted: the tester proof doesn't run the full agent walk, because agents bind
tester by design (Q173). Managed runs still fail it. The tester listener moves from TCP 7333 to a private
Unix socket, with `bus.tester` unchanged (agreed by Sage with Gander).
What row 193 needs from row 196:
- H1. Owner on the CLI keeps today's proof. Owner in the Console is a `console.sso.subjects` entry.
- H2. A user principal comes only from an OIDC sign-in, through a path the bus verifies. No CLI path for
users.
- H3. The user binding fails closed on no live grant, an unknown subject, a subject also in
`console.sso.subjects`, or a missing or invalid access.json. A broken access.json never stops the owner.
- H4. Agent processes never get an owner or user principal.
- H5. Revocation ends a bound user within 30 s and at once on `access.reload`, from the same access state
row 193's share check reads.
The same-uid limit. Within one instance, a non-agent process on the instance's uid can claim the owner, and
can start its own Console copy and claim any granted user (FED 3.6). An unsandboxed same-uid agent that
detaches from its ancestry can do the same. The walk doesn't stop that, and Gander's design won't claim it
does (gander-0005). Several humans don't make this worse: a user principal is no stronger than the owner
principal that the same process could already take. Users themselves are not on the uid. They reach the
bus only through the Console, so the limit is about local processes, not other humans.
Does row 190 narrow it? Row 190 runs several instances under one uid, so no. Row 190b puts an instance (the
hub) under its own Linux user (mos-0986). That narrows reach across instances: another instance's uid fails
the uid check (human.mjs:67) and can't read the hub's store or access.json. It does not narrow reach within
one instance. Private threads are private from other humans using the bus, not from processes running as
the instance's uid.
## 7. Migration and rollback (brief point 6)
- No access.json: the business has exactly its owner, every verb behaves as today, and the share verbs
work for the owner only (target `*` or none). A broken access.json revokes users and leaves the owner.
- Existing threads: already owned by the business human. Nothing is rewritten.
- Schema: 3i to 3j adds `thread_shares` only. A re-pin past a schema bump needs a `--migrate` window with
Jason, and row 185's backup before activate covers it.
- Rollback before any share is written: activate the previous copy with the store restored from the
pre-activate backup. A 3i copy refuses a 3j store, as it does for every bump.
- Rollback after shares are written: the same restore loses shares made since the window. Grants are in
access.json and survive. Users re-share. Row 181's restore guard counts `thread_shares`, so the loss is
shown, not silent.
- To keep that window small, the build splits (section 9): 193a, 193b1, 193c and 193d have no schema
change and can go live in any window. 193b2 carries 3j.
## 8. Tests and mutants
Bus, each in `packages/bus/tests/` (193b1: viewer, mixed grants, board reads, the 5.7 message filter and governance pin; 193b2: shares, the share exception to the filter, migration):
- Viewer: each allowed verb works, every other verb refuses `access-refused`, including `thread.create`,
`thread.open`, `thread.send` and `thread.share`.
- Mixed grants: collaborator on project A and viewer on B. Create in A works, create in B refuses.
- Downgrade: not here. FED R4 owns the level-loss test and its mutant (r4.4, mos-1020, N3).
- Shares: owner shares with `user:<id>`, the user reads `thread.messages` and gets `thread-not-owned` on
`thread.send` and `thread.open`. Share with `*` reaches every covered user and the owner. Share with a user
whose grant doesn't cover the project or instance refuses `share-not-covered`.
- A share never widens: the target's grant drops the project, the next read refuses.
- Revoke, expiry and an invalid file each end share reads on the next request. The 30 s timer and
`access.reload` paths each have a case.
- A revoked thread owner hides the thread from sharees.
- Unshare ends access. Share, unshare, share again leaves it shared (latest row wins).
- Non-owner `thread.share`, `thread.unshare`, `thread.shares` refuse. Tester refuses on all three.
- `thread.list` plain shape unchanged. `thread.list {shared: true}` returns only readable threads.
- Board reads refuse users and viewers.
- Governance pin (B1, sage-1368): a user-thread run raises a decision; it shows in the owner's
`decisions recent` with its question text, and `inbox` lists it when routed to the human; the run's
role claim shows in the owner's `agents`, its task write in `tasks`, and its `record.write` in
`project.file` (B3, sage-1373); the user's message bodies stay hidden from the owner's `trail` and
`replies`. The test names each item of the 5.7 user sentence, so the sentence and the pin match.
- `trail`, for the owner, a reader session and the tester (193b1): a user's thread message as `subject`
fails `message-not-found`; a decision trail that reaches such a message omits it, its deliveries and its
events. Once the thread is shared with the owner, the owner's trail shows it.
- `replies`, same three sessions (193b1): a hidden `id` fails `message-not-found`; hidden replies are dropped. The
owner's own and the tester's threads are unchanged.
- Migration: a 3i store with threads migrates to 3j, row counts unchanged, `thread_shares` empty and
append-only (update, delete, replace each abort). Restore-guard and `COUNTED` include it.
Business (193a): R1 accepts `viewer`, refuses any other new level.
CLI (193c): `mosaic access user add/remove` and `grant --level viewer` refuse tester and agent callers,
write a receipt line and call `access.reload`.
Webui (193d): a viewer has no composer or share control. With each UI check removed, the bus still refuses.
Mutants, each must be killed:
1. `#readable` rule 1 dropped (any covered user reads any thread).
2. Rule 2 dropped (a share widens the grant).
3. Rule 3 dropped (a revoked owner's thread stays visible).
4. Latest-row lookup replaced by "any share row" (unshare ignored).
5. (Moved to FED R4, N3: the level-loss mutant is R4's.)
6. Viewer allowed `thread.send`.
7. `#threadRead` used for `thread.send` (sharee writes).
8. `share-not-covered` check removed.
9. `thread.share` owner check removed (sharee re-shares).
10. A user allowed `trail`.
11. The `trail` filter removed (the owner reads a user's unshared message by id).
12. The `replies` filter removed (the owner reads replies in a user's unshared thread).
## 9. Build rows and order
| Row | Work | Chains on | Schema |
|---|---|---|---|
| 193a | R1 level check widened to viewer | FED R1 candidate | none |
| 193b1 | Bus: viewer verb list, per-grant level checks, users refused on board reads, the 5.7 `trail` and `replies` filter with `#readable` as owner-only | FED R3, R4 candidates, row 196 verifier | none |
| 193b2 | Bus: `thread_shares`, share verbs, the share rules of `#readable`, `#threadRead`, the 5.5 share filters | 193b1 candidate | 3j |
| 193c | `mosaic access` user verbs and `--level viewer`, if R5 lacks them | FED R5 candidate | none |
| 193d | Console: shared list, share controls, viewer read-only | FED R6, row 192 candidates, 193b2 for share controls | none |
Sage ruled the split (sage-1354): 193b1 has no schema change and can go live first. 193b2 carries 3j, and
Sage takes its migrate window to Mos when 193b2 is near. The 5.7 message filter is in 193b1 (Duckworth B2,
sage-1368), so it is live before any user thread can be, whatever order FED R8 and 193b2 land in. 193b2 adds
only the share exception. FED R4 chains on 193a (viewer level), and 193b1 chains on R4.
Coverage, settled in FED r4.1 (73677b87d, gizmoduck-0018, still under Duckworth's review). A grant's
`projects` is `["*"]` or bus project UUIDs (`projects.id`, broker.mjs:2306; `threads.project` references it).
R1 exports one pure `covers(grant, {business, project, instance}, now, {level})`. Row 193 uses only that
function and adds no second one (Sage confirmed, sage-1354):
- share reads (5.4 rules 2 and 3) and `share-not-covered`: `covers(g, thread, now, {})`, any level;
- `thread.create`, `thread.open`, `thread.send`, `thread.share`: `covers(g, thread, now, {level: 'collaborator'})`;
- `project.list` and `names` for users: any level, as FED 3.5.
`covers` needs no further argument for row 193. If r4.1 changes in review, 193b1 follows it.
Follow-ups, not in this row:
- A read-only users and grants page for the owner in the Console.
- A second owner or admin level, if Jason wants one.
- An explicit, logged owner audit verb for user threads, only if Jason asks for one (sage-1354).
## 10. Gate
Per the brief: Duckworth approves this design, then the manifest. The bus, business, cli and webui suites and
every `scripts/test-*.sh` pass on Sage's gate, and the section 8 mutants are killed. No test touches the live
directories. No product code before Duckworth approves.
+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%.
+86
View File
@@ -0,0 +1,86 @@
# Row 189: `stack-release.sh activate --no-send`
Gitea #1687. Vikunja P19 #413 (Q175 interim, #412). Brief: docs/plans/2026-10-20_activate-no-send.md.
Rulings: mos-0972 (the Console runs without `--send` until Q175 phase 2), mos-0973 (go; Sage builds,
Dewey reviews; gate is the cli suite plus every scripts/test-*.sh). Base: def2e7932 (refactor, the
brief commit on 9ae8c73ac).
## What it does
`activate ID BUSINESS --no-send` drops `--send` from `RUN_CONSOLE` in a new `send_for_target`, which
`console_for_target` calls after `proof_for_target`. `install_stack` renders the unit from
`RUN_CONSOLE` and `write_pointer` writes the pointer from it, so both lose the flag and `flags_drift`
stays quiet afterwards. Only `activate` takes the flag; the parser sends every other op to usage
(exit 4).
`check_target` lets a `--no-send` activate name the pointer's copy only when the pointer's Console
flags hold `--send`. That run is a live-copy re-pin, the same path row 187 uses for the proof pair:
the plan prints the live-copy note, the run stops both units, backs up the stopped store and starts
them again. A plain same-copy activate, or a same-copy `--no-send` once `--send` is gone, still
refuses `already-active`.
`--plan` prints one `send:` line after the `proof:` line, for activate and rollback:
- `send: dropped; --no-send was given`
- `send: off; --no-send was given and the pointer holds no --send`
- `send: carried` (the pointer's `--send` goes on unchanged)
- `send: off`
The steps log gets `drop --send; --no-send was given`, or `--no-send was given and the pointer holds no
--send, so there is nothing to drop`.
Rollback and pm-on carry the pointer's flags as they are, so once `--no-send` has run, nothing adds
`--send` back. There is no re-enable flag (mos-0973). Q175 phase 2 changes the Console flags in its
own row.
docs/TOOLS.md: `[--no-send]` in the usage block and one paragraph after the proof paragraph.
## Tests (packages/cli/tests/stack-release.test.mjs, T55)
1. Seeded preview pointer with `--send --tester-port 7333`: a same-copy `activate B acme --no-send`
passes. The pointer and the unit ExecStart lack `--send` and keep the other flags in order, status
says the unit flags match, the steps log has the drop line, the log line has from = to = B and the
backup sum equals the stopped store's, and the units stop and start in order.
2. Plain same-copy activate, with `--tester-port` and with `--no-proof`, refuses `already-active`. So does
a same-copy `--no-send` once `--send` is gone, and on a pointer that never held it.
3. The four `send:` lines, the live-copy note on the same-copy plan only, and the Console line right
after `send:` without `--send`. The run on a pointer with no `--send` logs the nothing-to-drop line
and no drop line.
4. After `--no-send` on C1, a rollback to B leaves `--send` out of the pointer and the unit, the steps
log never names `--send`, and status matches. pm-on after `activate --pm-after-harness --no-send`
keeps it off.
5. `--no-send` on rollback (with and without `--plan`), pm-on, status, adopt and rehearse is a usage
error with the usage text, nothing written, started or logged. The usage text shows `[--no-send]`.
6. A pointer holding both the proof pair and `--send`: the plans for `--no-proof`, `--no-send` and both
name what each drops and carries. `--no-send` keeps the pair, `--no-send` with a new pair replaces it,
`--no-proof` keeps `--send`, and both together leave neither. Status matches after each.
`planTail` gains `'send: off'` after `'proof: off'`, because every plan now prints the line. T36 takes
`out.slice(-n)` around `planTail`, so its four slice counts grow by one.
## Mutants
mutants.sh in this packet, run on the candidate. Each mutates scripts/stack-release.sh and runs T55
plus T1 and T2.
- M1: the same-copy exception ignores `--no-send`.
- M2: the same-copy exception allows `--no-send` without `--send` in the pointer.
- M3: with `--no-send`, the pointer is written from `P_CONSOLE`, so it keeps `--send` while the unit
drops it.
- M4: rollback re-adds `--send`.
- M5: the parser accepts `--no-send` for rollback.
Results: mutants-run.log, all five killed (M1 5 failures, M2 2, M3 7, M4 2, M5 2).
## Deviations from the brief
- The brief's line numbers were taken before row 163 landed. The candidate corrects them to the
9ae8c73ac lines (check_target :249-256, flags_drift :646-657, console_for_target :664-693, adopt
:828).
- Test 5 also covers rehearse and `rollback --plan`, which the brief did not list.
## Outbound calls
None. The change edits an array before the existing unit render and pointer write. Every test uses
the stand-in systemctl, units, stores and data root the file already builds; no test touches
`~/.mosaic-dev`, the live units or a slot file.
@@ -0,0 +1,5 @@
M1 KILLED fail=5
M2 KILLED fail=2
M3 KILLED fail=7
M4 KILLED fail=2
M5 KILLED fail=2
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Row 189 mutants M1-M5: each mutates scripts/stack-release.sh in the sage-b189 worktree, runs T55 and
# the plan-tail tests, and restores the file from a checked backup.
set -uo pipefail
WT=/mnt/storage/scratch/sage-b189
OUT=/mnt/storage/scratch/sage-b189-mut/out
S=$WT/scripts/stack-release.sh
mkdir -p "$OUT" || exit 1
cp -p "$S" "$OUT/stack-release.sh.orig" || { echo "backup failed"; exit 1; }
ORIG=$(sha256sum "$S" | cut -d' ' -f1)
[ "$(sha256sum "$OUT/stack-release.sh.orig" | cut -d' ' -f1)" = "$ORIG" ] || { echo "backup differs"; exit 1; }
restore() { cp -p "$OUT/stack-release.sh.orig" "$S"; [ "$(sha256sum "$S" | cut -d' ' -f1)" = "$ORIG" ] || { echo "RESTORE FAILED"; exit 1; }; }
trap restore EXIT
mutate() {
case "$1" in
M1) sed -i '/{ \[ "\$NO_SEND" = 1 \] && \[\[ " \${P_CONSOLE\[\*\]} " == \*" --send "\* \]\]; } ||/d' "$S" ;;
M2) sed -i 's/{ \[ "\$NO_SEND" = 1 \] && \[\[ " \${P_CONSOLE\[\*\]} " == \*" --send "\* \]\]; } ||/{ [ "$NO_SEND" = 1 ]; } ||/' "$S" ;;
M3) sed -i 's/^ write_pointer "\$TO" "\$RUN_PM" "\$RUN_PENDING" "\$P_CLAUDE" "\$T_SCHEMA" "\${RUN_CONSOLE\[@\]}"$/ if [ "$NO_SEND" = 1 ]; then write_pointer "$TO" "$RUN_PM" "$RUN_PENDING" "$P_CLAUDE" "$T_SCHEMA" "${P_CONSOLE[@]}"; else write_pointer "$TO" "$RUN_PM" "$RUN_PENDING" "$P_CLAUDE" "$T_SCHEMA" "${RUN_CONSOLE[@]}"; fi/' "$S" ;;
M4) sed -i 's/^ SEND_PLAN="send: off"$/ SEND_PLAN="send: off"; [ "$OP" != rollback ] || RUN_CONSOLE+=(--send)/' "$S" ;;
M5) sed -i 's/--no-send) \[ "\$OP" = activate \] || usage;/--no-send) { [ "$OP" = activate ] || [ "$OP" = rollback ]; } || usage;/' "$S" ;;
esac
}
for m in M1 M2 M3 M4 M5; do
restore
mutate "$m"
if [ "$(sha256sum "$S" | cut -d' ' -f1)" = "$ORIG" ]; then echo "$m NOT-APPLIED"; continue; fi
bash -n "$S" || { echo "$m SYNTAX"; continue; }
diff "$OUT/stack-release.sh.orig" "$S" > "$OUT/$m.diff"
(cd "$WT" && TMPDIR=/mnt/storage/scratch/t189 timeout 900 node --test --test-reporter=tap --test-name-pattern='T55|T1 |T2 ' packages/cli/tests/stack-release.test.mjs) > "$OUT/$m.log" 2>&1
fails=$(awk '/^# fail /{print $3}' "$OUT/$m.log")
if [ "${fails:-0}" -gt 0 ]; then echo "$m KILLED fail=$fails"; else echo "$m SURVIVED"; fi
done
+1 -1
View File
@@ -3,5 +3,5 @@
Reviewer seat, Claude Opus 5.5, started by Mos (mos-0981, thread 03086006). Reviews Case 2 designs and MVP T1 rows 192 and 194.
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
credentials of its own; until row 195 settles per-agent identity, it pushes only its own `webby/*` branches
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.
+3
View File
@@ -1361,3 +1361,6 @@ are never rewritten or removed; corrections are new entries.
2026-10-20T18:34:53Z | Filbert (Opus 5.5, 048f3c63) | Q175 SSO design review r2 (DESIGN.md blob 3eb718b5e131 at 1a268249e), static | changes: C1 config.json console key fails old loaders (mosaic-config.mjs:113 rejects unknown keys; key add inside phase 2 window, rollback removes it, loader test), C2 app.js signs out only on opaqueredirect so 401 never shows sign-in; notes on tests 15/7/9a, Q-B 200-HTML classification, scratch Console flags; Q-E keep iss/sub escaped and capped; verdict sent to Sage, no Gitea/queue record
2026-10-20T18:40:20Z | Filbert (Opus 5.5, 048f3c63) | Q175 SSO design review r3 (DESIGN.md blob a152fd58e18e at 817e8f51b), static | approve on condition D1 (slot file checks stay out of shared loader, shape-only there) as a line edit; D2 notes: slot root from configDir, scratch dataRoot, name discovery GETs for Mos; C1/C2 and notes verified; Mos C1 sequence confirm still open; no Gitea/queue record
2026-10-20T18:41:20Z | Filbert (Opus 5.5, 048f3c63) | Q175 SSO design D1/D2 check (DESIGN.md blob eba861f5d2bb at d55d86bb0), static git diff 817e8f51b..d55d86bb0 | ack: D1 and D2 match; design approved; Mos C1 sequence confirm still open; no Gitea/queue record
2026-10-20T18:57:10Z | Filbert (Opus 5.5, 048f3c63) | row 190 design review r2 (#1688, DESIGN.md sha256 5493c9df3ec0) | CHANGES to Darkwing, cc Sage: B1 claim check on MOSAIC_CONFIG-only selection, B2 shared prefix drop-in dirs; N1-N7 notes; static read, no slot, no Gitea or queue record
2026-10-20T19:00:04Z | Filbert (Opus 5.5, 048f3c63) | row 190 design review r3 (#1688, DESIGN.md sha256 98236a1aff98) | APPROVE to Darkwing, cc Sage: B1 and B2 closed with M13 and M14, N7 settled by Sage's 48ebee0ff; static read, no slot; manifest round gets a queue review record (sage-1337)
2026-10-20T19:57:00Z | Gizmoduck (Opus 5.5, 9312a896) | row 201 FED R1 (#1699, #414) round 1: packages/business/src/access.mjs, index export, tests/access.test.mjs, packet agents/gizmoduck/work/queue-201 | 24/24 single file; 44 mutants written, not run (needs Sage's slot); expiry per sage-1399; to Duckworth for manifest review
+9 -1
View File
@@ -368,7 +368,7 @@ reports checkout or copy identity and refactor drift, and writes nothing.
```
scripts/stack-release.sh adopt BUSINESS
scripts/stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-port N]
[--proof-header NAME --proof-file PATH | --no-proof] [--timeout S] [--plan]
[--proof-header NAME --proof-file PATH | --no-proof] [--no-send] [--timeout S] [--plan]
scripts/stack-release.sh pm-on BUSINESS [--timeout S]
scripts/stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
scripts/stack-release.sh status BUSINESS
@@ -480,6 +480,14 @@ copies. It checks the file with `stat` and `realpath` and never reads the value.
or `off`. On the live copy it also notes that the run re-pins the pointer's copy
and only the Console flags change.
`activate ... --no-send` (row 189, mos-0972) drops `--send` from the pointer's
Console flags and the unit, so the Console serves reads and no human sends. It
may name the live copy while the pointer holds `--send`; once `--send` is gone a
same-copy `--no-send` refuses `already-active`. Later activates, `pm-on` and
rollbacks carry the pointer, so send stays off. No flag puts `--send` back.
`--plan` prints one `send:` line: `dropped`, `off` with or without the
`--no-send` note, or `carried`.
#### A target past c216f47db: the PM after the harness kind
From c216f47db on, the bus launches the PM only with a `harness` kind in the
+6 -5
View File
@@ -1,7 +1,8 @@
# Brief: activate --no-send drops the Console's --send
Sage, 2026-10-20, for Mos's ruling mos-0972 (Q175 interim, Vikunja #412). Owner Sage, reviewer Dewey, gate owner
Sage. Base: refactor 9ae8c73ac (row 163 landed). Line numbers are at that commit.
Sage. Base: refactor 9ae8c73ac (row 163 landed). Line numbers are at that commit. The candidate corrects them;
the first draft took them before row 163.
This row builds the tool only. The live restart without `--send` is a separate step afterwards, a same-copy
activate under Q173 with a backup first and a STATE report after. No test here touches `~/.mosaic-dev`, the
@@ -14,11 +15,11 @@ Mos ruled that the Console runs without `--send` until Q175 phase 2, because wit
process can forge `X-authentik-username` on 127.0.0.1:7332 and send as Jason (design section 8). On the pinned
stack, the Console flags live in the pointer (`~/.mosaic-dev/state/stack-active.json`):
- `console_for_target` (scripts/stack-release.sh:658-687) carries the pointer's flags unchanged. Only
- `console_for_target` (scripts/stack-release.sh:664-693) carries the pointer's flags unchanged. Only
`--tester-port` and the row 187 proof pair can change.
- `check_target` (:247-249) lets an activate name the pointer's copy only for a proof-pair change.
- `adopt` refuses once a pointer exists (:822).
- A hand `webui-service.sh install` without `--send` makes `flags_drift` (:640-651) refuse
- `check_target` (:249-256) lets an activate name the pointer's copy only for a proof-pair change.
- `adopt` refuses once a pointer exists (:828).
- A hand `webui-service.sh install` without `--send` makes `flags_drift` (:646-657) refuse
`unit-flags-drift` on every later activate, pm-on and rollback.
So the tool can't drop `--send` today. This row adds that one path.
@@ -0,0 +1,57 @@
# Brief: a credential store per agent, own git identity, pushes to a remote
Sage, 2026-10-20, for mos-0974 (MVP T1 item 6) and the MVP plan
(`jason-master-restart/projects/mosaic-mvp/PLAN.md` item 6, GAP-MAP G11 and G13; TARGET.md: "credentials store
available for all agents; secrets protected with RLS equivalent" and "Agent uses own credentials for git and
other actions"). Gitea #1697. Owner Megavolt (Opus 5.5, a new builder seat), reviewer Duckworth, gate owner
Sage. Base: refactor at the brief commit. Design first. The first deliverable is
`agents/megavolt/work/queue-ROW/DESIGN.md`.
This row draws a security boundary. Sage reviews the design's authority model, and Mos rules on it before any
code is written.
## What exists today (GAP-MAP, measured at 9ae8c73ac)
- G11. Credential references exist per instance for Gitea and Vikunja only
(`packages/business/src/credentials.mjs`, `packages/bus/src/credentials.mjs`, `vocabulary.mjs:103`). One
Claude env file is shared by every seat.
- G13. Agents use their own Vikunja token (`packages/tasks/src/adapter.mjs`). Git goes through the host
socket (`packages/cli/src/git-socket.mjs`). Gitea tokens are loaded but never used.
- The development repository's helper (`scripts/git-credential-mosaic.mjs`, wired in `.git/config` for
git.mosaicstack.dev) pushes as one identity. Until this row lands, seats push only their own `<seat>/*`
branches through it (mos-0981).
## What the design must settle
1. The store. Where each agent's credentials live, per instance, under the row 190 config canon. File modes,
ownership and the read path. Which process may read which entry, and how the bus enforces that (the "RLS
equivalent"). A process asking for another agent's entry is refused and logged.
2. Kinds. Gitea (git over HTTPS, and the API), Vikunja, and the model provider. Say which kinds 10-27 needs
and which wait. The model provider entry must not copy jwoltje's `claude-session.env`. If it needs a new
provider account or subscription, flag it for Jason and don't pick one.
3. Git identity and push. Each agent commits with its own name and email and pushes with its own token to a
remote it is granted. Which refs an agent may push (its own branches, never the default branch or another
agent's), and where that's enforced: the Gitea side (branch protection, token scopes) and our side. How
this fits the host git socket, or replaces it.
4. Lifecycle. Issue, rotate (`rotateBy`, `expires`), revoke and delete, and what row 194 (agent lifecycle) calls
when an agent is hired or fired. A fired agent's credentials stop working at once.
5. Who does what. Creating a Gitea user or token, or any account outside the fleet, is Jason's (Q173
exclusions). The design lists each such step as a command or UI action for Jason. Agents never mint
accounts.
6. Migration. Today's seats (jarvis helper, seat tokens) keep working until each moves. The mos-0981 push
rule ends only when Mos says so.
7. What this row closes in G11 and G13, and what stays open.
## Boundaries
- No credential value is read, printed, logged or committed. Tests use fake tokens in scratch roots and a
fake Gitea.
- No change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units, slot files, `~/.mosaic` or the live
`.git/config` of the canonical checkout.
- No request to git.mosaicstack.dev, tasks.woltje.com or any provider from a test.
## Gate
Duckworth approves DESIGN.md, Mos rules on the authority model, then Duckworth approves the manifest. The bus,
business, cli and tasks suites and every `scripts/test-*.sh` are green on Sage's gate, and the design's
mutants are killed.
@@ -0,0 +1,100 @@
# Federation build rows R1, R2 and R5 (#414)
Sage, 2026-10-20, for Mos's orders mos-1023 item 1, mos-1028 item 2 and mos-1034 item 1. The design is
Gizmoduck's `agents/gizmoduck/work/federation/DESIGN.md` at a1daa3c07 on `gizmoduck/federation-design` (r4.9,
sha256 a31cbebd070f9e3a5ca4ccb158b1747c6f5a83c3469855705b0026a581fb5c6a). Duckworth approved r4.5
(duckworth-0012), r4.6, r4.7 (duckworth-0020) and r4.8 (duckworth-0021, 8a22d0897 on `duckworth/reviews`),
and acked r4.9 (duckworth-0024). r4.7 applies Jason's Q181 hosts, r4.8 adds N22's R8 checks and r4.9 names who
runs them. None of them changes R1, R2 or R5. Section 7's table
sets each row's scope and test gate. Vikunja P19 #414. Seats push only their own `<seat>/*` branches
(mos-0981).
The other R rows are filed when their parents have candidates: R3 after R1 and row 196, R4 after R3, R5 and
row 193's viewer level (193a, mos-1019), R6 after #412 phase 1, R1, R2 and R3, R7 after R1-R6 and row 190.
R8 is a live window, not a code row. Its brief is the last section here.
## Brief: FED R1, the access.json loader and validator
Owner Gizmoduck, reviewer Duckworth, gate owner Sage. Base: refactor at this brief's commit. No parent.
`packages/business/src/access.mjs`: the access.json loader and validator from DESIGN.md section 3.5, and the
pure `covers`. Providers, peers and businesses come in as arguments. One read plus the file checks. The
exported interface is what R5 and row 193a build against, so the manifest names it, and any change to it in
review goes to Sage before it lands. Section 7 names `covers` but not the loader and validator exports or
the context shape. Morgana's 193a (508ee4d61 on `morgana/row193a`) assumes `checkAccess(doc, {providers,
peers, businesses})`, with `businesses` mapping an id to `{human, instances}`, throwing a `BusinessError` that
names the key path. Use those names, or tell Morgana and Quackerjack yours before the manifest goes to
Duckworth. Row 193a later widens the level check to `viewer` on R1's candidate (mos-1019).
Tests, in `packages/business/tests/`: every rule in 3.5 refuses with the key path named. That covers a
symlink, a non-regular file, another owner, group or other write, expiry, `*`, a grant disjoint from the given
subjects, `human` and `tester` ids, any level other than `collaborator`, and a project entry that isn't a
UUID. For `covers`: `*` and listed ids match, while an unlisted project, instance or business doesn't, an
expired grant doesn't, and a level filter rejects another level. One mutant per refusal, each killed.
Boundaries: no change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or any live access.json. Tests
use scratch roots. Single test files run freely, and suites need Sage's slot.
Gate: Duckworth approves the manifest. The business suite and every `scripts/test-*.sh` are green on Sage's
gate, and the mutants are killed.
## Brief: FED R5, mosaic access list, grant and revoke
Owner Quackerjack, reviewer Duckworth, gate owner Sage. Chained on R1 (Q177). The build starts now against
R1's exported interface as the R1 manifest names it, and R5 lands after R1 and rebases onto R1's landing.
`mosaic access list`, `grant` and `revoke`: human proof, atomic write, revision bump, a receipt line. The help
text states the `instances` limit and the provider-removal order (DESIGN.md section 7, row R5). R4 later makes
these verbs call `access.reload`, so R5 leaves no other reload path.
Tests, in `packages/cli/tests/`: tester and agent callers are refused, a concurrent write refuses on a
revision conflict, the receipt line is appended, and a bad grant is refused before anything is written. One
mutant per refusal, each killed.
Boundaries: no change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or any live access.json. The
human proof in tests uses the scratch stand-in pattern (a scratch bus verifies its own human-cli path).
Single test files run freely, and suites need Sage's slot.
Gate: Duckworth approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate,
and the mutants are killed.
## Brief: FED R2, the federation key in the system config
Owner Quackerjack, reviewer Webby, gate owner Sage. Chained on row 192 (#412 phase 1). The build starts when
row 192 has a candidate, on that candidate.
A `federation` key in `scripts/mosaic-config.mjs` with the shape in DESIGN.md section 3. A config with no
`federation` key still loads as it does today. This changes the config schema, so the manifest lists every
reader of the key.
Tests, in `scripts/test-config.sh`: a valid key loads, an unknown sub-key and a bad origin refuse, and a
config without the key loads. One mutant per refusal, each killed.
Boundaries: `~/.config/mosaic-dev/config.json` is user-authored and is not touched. Tests use scratch config
dirs only.
Gate: Webby approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate, and
the mutants are killed.
## Brief: FED R8, the hub window on 10.1.1.54
A live window, not a code row (DESIGN.md section 7, row R8, r4.9). Run by Jason and Mos. Nothing here
authorizes a step on any host; each step waits for the window's go.
Order (mos-1042 item 2). R8 runs after Bean and Chet are live in Case 2 and both Hermes gateways on 10.1.1.54
are stopped, because R8 requires that the hub host runs no other instance or agent and Q181 keeps Melanie's
Hermes on .54 until then. Case 2 go-live doesn't wait on R8. The risk, stated for 10-27: federation between
the two instances now depends on Melanie's switch-over date.
Entry checks, all before anything starts:
- R7, #412 phase 2, the hub host live (Vikunja #418) and Jason's #419 answer, as section 7 lists.
- The four hub user checks on 10.1.1.54 (N22, mos-1035 item 1): `sudo -l -U mosaic` grants nothing, `id
mosaic` shows only its own group, `~mosaic/.ssh/authorized_keys` is absent or empty, and no file under
`/etc/sudoers.d` names `mosaic`. Jason runs them as localadmin with his new key (#421 step 1), and the output
goes into the R8 record. No factory uid runs them, because R8 requires every factory root path to .54 to
fail (r4.9, mos-1037).
- The factory privilege checks, the key fingerprint comparison and the section 8 privilege path table, each
as section 7's R8 row states them.
Done when every entry check has its output recorded, Jason signs in to the hub and sends one message, Melanie
revokes, and his next request is refused.
+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.
+4 -2
View File
@@ -12,8 +12,10 @@ before any code, as Q175 did. Then the build follows the design, with tests and
Two or more instances run side by side on dragon-lin. Each one has its own config file, data root (store,
broker.sock, host.lock, state, runs, sessions, workspaces), businesses, bus unit, Console unit and port,
tester port, Discord unit, Claude session env file, stack-active pointer and activation log. Nothing one
instance does can read or change another's state. The current instance keeps working unchanged: its
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)
@@ -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.
@@ -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).
+185
View File
@@ -0,0 +1,185 @@
// 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. Row 193a widens this to collaborator and viewer.
export const LEVELS = Object.freeze(["collaborator"]);
// 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. With `level`, the grant's level must be that one;
// without it, any level covers. 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 (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;
if (level !== undefined && grant.level !== 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";
+280
View File
@@ -0,0 +1,280 @@
// 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 only", () => {
assert.deepEqual([...LEVELS], ["collaborator"]);
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 refuses, naming grants[i].level", () => {
for (const bad of ["viewer", "owner", "Collaborator", "COLLABORATOR", "", null, 1]) {
refuses(() => checkAccess(mutated((d) => { d.grants[1].level = bad; }), context()), /grants\[1\]\.level must be one of collaborator/);
}
refuses(() => checkAccess(mutated((d) => { delete d.grants[0].level; }), context()), /grants\[0\]\.level must be one of collaborator \(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 });
test("covers: * and listed ids match", () => {
const [g1, g2] = checkAccess(doc(), context()).grants;
assert.equal(covers(g1, target({ project: P2 }), NOW), true);
assert.equal(covers(g2, target({ instance: "writer" }), NOW), true);
assert.equal(covers(g2, target(), NOW), 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), false);
assert.equal(covers(g1, target({ instance: "writer" }), NOW), false);
assert.equal(covers(g2, target({ business: "acme" }), NOW), false);
assert.equal(covers(g1, target({ business: "acme" }), NOW), 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")), true);
assert.equal(covers(g1, target(), new Date("2026-12-31T00:00:00Z")), false);
assert.equal(covers(g1, target(), new Date("2027-01-01T00:00:00Z")), false);
// No expires never expires.
assert.equal(covers(g2, target(), new Date("2099-01-01T00:00:00Z")), true);
});
test("covers: a level filter rejects another level, and no filter accepts any", () => {
const g = checkAccess(doc(), context()).grants[0];
assert.equal(covers(g, target(), NOW, { level: "collaborator" }), true);
assert.equal(covers(g, target(), NOW, {}), true);
assert.equal(covers(g, target(), NOW, { level: "viewer" }), false);
assert.equal(covers({ ...g, level: "viewer" }, target(), NOW, { level: "collaborator" }), false);
});
test("covers: now must be a valid Date", () => {
const g = checkAccess(doc(), context()).grants[0];
assert.throws(() => covers(g, target(), Date.now()), TypeError);
assert.throws(() => covers(g, target(), new Date("nope")), 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 ;;