12 KiB
Row 207 build notes: 193c, mosaic access user verbs and --level viewer (#1705)
Builder Megavolt, reviewer Duckworth, gate owner Sage (sage-2790, Q220, mos-1301). Branch megavolt/row207 off
aca16404c. The brief is "Brief: 193c, mosaic access user verbs and --level viewer" in
docs/plans/2026-10-20_human-users-build.md (blob c74af2510). Its gate: Duckworth approves the manifest, the cli
suite and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed.
Sources
- The brief, quoted: "
mosaic access user add/removeandgrant --level viewer, only where R5 lacks them. They refuse tester and agent callers, write a receipt line and callaccess.reload(section 8, CLI). If R5 already has all of them, this row records that and closes with no code." - Morgana's row 193 design, section 4.5 and section 8 (agents/morgana/work/queue-193/DESIGN.md at
e2d161e2eon morgana/row193):user add <id> --home local --subject <provider>:<sub>,user remove <id>,--level viewerongrant, a receipt line in<dataRoot>/state/access-log.jsonland theaccess.reloadcall. - R5 as it stands at
aca16404c: packages/cli/src/access.mjs, packages/cli/tests/access.test.mjs, packages/business/src/access.mjs (checkAccess, read only here), packages/bus/src/broker.mjs and runtime.mjs.
1. What R5 already has
grant --level viewer: present. LEVELS is["collaborator", "viewer"](packages/business/src/vocabulary.mjs), grant checks--levelagainst it, and access.test.mjs already grants at viewer. This row adds no code for it and records it here.user addanduser remove: absent. accessVerb takes only list, grant and revoke, and the only way to add a user today is to hand-edit access.json. This row adds them.access.reload: absent everywhere. No bus verb has that name. The R5 brief says "R4 later makes these verbs callaccess.reload, so R5 leaves no other reload path", FED R3's brief leaves it to R4, and access.mjs's proveHuman says "Row R4 swaps this call for access.reload". FED R4 is not filed.
2. Decisions (Q items for Sage and Duckworth, stated as assumptions, not blockers)
- Q1, the reload call. 193c adds no
access.reloadcall, because there is no verb to call. The user verbs take R5's path exactly: the sameproject.listhuman proof, and R4 swaps the proof foraccess.reloadon all five verbs at once. Nothing is lost meanwhile: runtime.mjs hands the broker() => loadAccess(...), and the broker reads the file once per user request (broker.mjs:384), so a written change acts on the next request. If Sage or Duckworth reads the brief as "addaccess.reloadhere", that is R4's bus verb and a scope change. - Q2, remove with grants.
user removerefuses withuser-has-grants, exit 2, naming the grant ids, while any grant at any business names the user. The other reading drops the user's grants in the same revision. Refusing is the smaller, fail-closed choice: nothing disappears that the owner did not name, and the help text already says to revoke first. checkAccess would also refuse the file (a grant naming a missing user), but with a key path, not a reason, and exit 3. - Q3, the subject.
--subject PROVIDER:SUBsplits at the first colon, so a sub may hold colons. One subject per add, because parse keeps one value per flag. A second subject is a later row if anyone needs it; checkAccess allows several. - Q4,
--business. Required on the user verbs too. The user list is instance-wide, but the human proof runs through one business's bus, and the receipt names it. - Q5, the receipt.
{at, verb: "user.add" | "user.remove", business, user, actor: "human", revisionBefore, revisionAfter, grants: []}: grant and revoke's shape, with an empty grants list. user removeof an id not in the file refuses, exit 2, and writes nothing, as revoke of nothing does.user addof a duplicate id, a reserved id (human, tester), a business's human, an unknown provider, a non-local home, an owner sso subject or a subject another user holds refuses through checkAccess, exit 3, before any write. The verb does not repeat those checks.
Rulings (sage-2810, 16:40Z, cc Duckworth)
- Q1 agreed: no reload call. The reason is broker.mjs:384-386, the
#accessfield's comment: "() => the checked access document (R1's loadAccess), read once per user request", wired in runtime.mjs:263 as() => loadAccess(access.file, access.context). A write acts on the next request. FED R4 owns any reload verb. - Q2 agreed:
user removerefuses withuser-has-grants, exit 2, naming the grant ids, and writes nothing. Dropping the grants silently would make removal revoke access without saying so. - Usage and help pins: grep the tests for the changed strings, sha256 and snapshot pins included, and run every reader whole (section 6).
- Row 203 (Quackerjack) does not touch access.mjs; its stale help lines stay a follow-up, so no overlap.
The usage and help pin sweep (sage-2810), on aca16404c plus this row
- Source readers of ACCESS_USAGE and ACCESS_HELP: packages/cli/src/access.mjs only (cli.mjs imports accessCommand and accessContextFor, not the strings).
git grepover 'packages//tests/' and 'scripts/*' for ACCESS_USAGE, ACCESS_HELP, "mosaic access", "access list", "access grant", "access revoke", "Access (FED" and the help's "revoke them here first": the only hits are packages/cli/tests/access.test.mjs (positive control:accessCommandhits it twice).- Hash pins: no test file names packages/cli/README.md or packages/cli/src/access.mjs next to a sha256 or a 64-hex value. The one 64-hex file that mentions a README (packages/queue/tests/fixtures/queue-marked.md) names packages/queue/README.md and packages/ledger/README.md, not this one. scripts/stack-copy.sh copies packages/cli/README.md; stack-copy.test.mjs builds its own fake repo and is in the cli package, run whole.
- packages/business/tests/access*.test.mjs import packages/business/src/access.mjs, a different file, unchanged.
- So the readers are access.test.mjs, run whole in step 1, and the cli package whole in step 2.
3. The change (code and docs)
- packages/cli/src/access.mjs: two ACCESS_USAGE lines and one help line; accessVerb accepts
user add IDanduser remove ID(three positionals) and hands off touserVerbafter the shared checks (tester, business, proof, file, context, revision).userVerbtakes the lock, checks the revision, builds the new users list, runs checkAccess on the whole document, writes atomically and appends the receipt. - packages/cli/README.md: the usage block and one paragraph.
- No change to packages/business, packages/bus or cli.mjs (it already routes
accessto accessCommand).
4. Tests (packages/cli/tests/access.test.mjs, whole file)
Manifest check against Morgana's row 275 (sage-2790), 10-22: morgana/row275 has no commits past aca16404c, and
row 275's brief ("193b1b, viewer verbs and board refusals") is bus only: the viewer verb list, covers level
checks and board refusals, gated on the bus suite. This row touches only packages/cli (access.mjs, README,
access.test.mjs) and this file, so no path is shared on the briefs. I recheck the paths when Morgana pushes.
- T1
user addwrites the user (id, home, one subject), bumps the revision by one, prints the line and appends one receipt with verbuser.addandgrants: []. A sub with a colon (authentik:bob:x) keepsbob:x. - T2
user addrefusals before any write, file and receipts unchanged: missing--homeor--subject, a subject with no colon, an empty provider or sub (exit 4); an unknown provider, a duplicate id,tester, the business's human, an owner sso subject, a subject alice already holds, a non-local home (exit 3). - T3
user removeof a user with no grants drops them, bumps the revision, receiptuser.remove. - T4
user removeof a user with a grant refusesuser-has-grantsnaming the grant id, exit 2, nothing written. - T5
user removeof an id not in the file refuses, exit 2, nothing written. - T6 a stale
--revisionrefusesrevision-conflicton both user verbs, exit 3. - T7
--testerand an agent env (MOSAIC_RUN_ID) refuse both user verbs, exit 3, and the fake human CLI is never called. - T8 a bad shape (
user,user add,user list x,user add a b) prints the help, exit 4. - T9 a bus that refuses the human proof (the fixture's
mode: "refuse") blocks both user verbs: exit 3, the file and receipts unchanged.
5. Mutants
Each runs the whole access.test.mjs and counts as killed only on an assertion in the named test.
- M1 drop the
user-has-grantsrefusal. Second fence: checkAccess then refuses the file with exit 3, so T4 must assert theuser-has-grantstext and exit 2, not only "nothing written". Killed by T4. - M2 drop the "no user to remove" check. The write then lands with an unchanged user list. Killed by T5.
- M3 split the subject at the last colon. Killed by T1's colon case.
- M4 skip checkAccess in userVerb (write the unchecked document). Killed by T2's exit-3 cases.
- M5 drop the receipt in userVerb. Killed by T1 and T3.
- M6 drop the revision check in userVerb. Killed by T6.
- M7 exempt the user verbs from the
--testercheck (&& sub !== "user"). Killed by T7's tester case. - M8 skip proveHuman for the user verbs (
if (sub !== "user")). Killed by T9. T7's agent case does not reach it: accessCommand calls refuseInsideAgent before accessVerb, so an agent env is refused whatever the order inside.
6. Gate plan (mos-1275)
On Sage's CLEAR, from a rebase onto 253's landing under a new branch name, stopping at the first red:
- access.test.mjs whole, then M1-M8, each under
timeout -k 10 180. - The cli package whole.
- Every
scripts/test-*.shastimeout -k 10 LIMIT bash scripts/test-X.sh, no script edited. LIMIT is row 281's table (Gander's row 278 limits, 3x the longest of three records plus 120 s): auth 126, conductor 145, config 130, discord 147, extension-package 130, foundation 240, land-work 158, queue 191, release 157, task 231 seconds. The base has the same ten scripts. Any timeout goes to Sage, then Mos, unplaced.
Runs
Gate on 712f79c0af (megavolt/row207-r3, this row's three commits rebased onto
01983cd3f after row 276 landed), Sage's CLEAR sage-3041, 22:07Z to 22:22Z 10-22. Gate worktree
/mnt/storage/scratch/megavolt-r207/gate-wt, TMPDIR /mnt/storage/scratch/tmp/gbLURml, logs in
/mnt/storage/scratch/megavolt-r207/logs/. Drift f2735bb6a..01983cd3f outside agents/: BUILD-LOG.md,
DEFERRED.md, QUEUE.md, queue.json, cli tests case-sudo-fixture.mjs and case1-accept.test.mjs, scripts
case1-accept.sh and case2-unit.sh; none is read by access.test.mjs.
- access.test.mjs whole: 31 tests, 31 pass, 0 fail, 0 skipped (base.log).
- Mutants, each the whole access.test.mjs under
timeout -k 10 180, applied by mutate.py (one exact match or refuse), restored bygit checkoutand checked clean after each. All 8 killed, each failing only its named test: M1 T4, M2 T5, M3 T1, M4 T2, M5 T1 and T3, M6 T6, M7 T7, M8 T9 (M1.log to M8.log). - cli package whole (
npm testin packages/cli): 894 tests, 894 pass, 0 fail, 0 skipped, 674 s (cli.log). - The ten scripts, rc 0 each, seconds against the limit: auth 0/126 (15 passed), conductor 7/145 (17), config 1/130 (31), discord 8/147 (81), extension-package 1/130 (18), foundation 43/240 (44), land-work 13/158 (86), queue 26/191 (28), release 3/157 (14), task 14/231 (91). Skips: queue's verify and render --check (the gate worktree is not the canonical root) and task's live provider cases (row 244). test-task printed no Docker-group skip.
Correction: a first run at 22:07Z was stopped after M2. Its kill check grepped TAP not ok, but node prints
the spec reporter's ✖ to a file, so it reported M1 SURVIVED while M1 had failed on T4. The check was fixed
to ^✖ T4 and M1-M8 were rerun from the start. That run's logs are in logs/run1-stopped/.