Files
jason.woltje ddfb29af7c row 202: FED R5, mosaic access list, grant and revoke (#1700)
Candidate quackerjack/fed-r5-r5 a74cd9f87 on c3d88840c, manifest of 6
paths (CANDIDATE.sha256). Duckworth APPROVE round 5, rev 3026 (#1700
c30184). C1 gate on Sage's slot by Quackerjack on the merge of a74cd9f87
onto a849d89b7 (3b86180b9), finished 01:56:24Z: cli suite 771/771, skip 0
(run 2; run 1 red on a too-long gate TMPDIR, harness only), every
scripts/test-*.sh rc 0 with Docker up, mutants M1-M13 killed by their
named tests. Landed onto 9c9732983 with the candidate's blobs and modes
for the 6 paths; c3d88840c..9c9732983 touches none of them.
2026-10-21 21:00:28 -05:00

8.3 KiB

Row 202 BUILD: FED R5, mosaic access list|grant|revoke

Quackerjack, 2026-10-20. Plan: PLAN.md (commit 2d0aa2937). Manifest: MANIFEST.md in this directory. Brief: docs/plans/2026-10-20_federation-r1-r2-r5.md, "Brief: FED R5". Reviewer Duckworth. Gate owner Sage. Review record: round 2 approved as duckworth-0041 (commit 2a1d17da7) on 48a3fb96e; viewer carry c204bef73 after it.

What was built

  • packages/cli/src/access.mjs: the three verbs, the human proof, the context (accessContextFor), the atomic write, the lock and the receipt.
  • packages/cli/src/cli.mjs: routes access to accessCommand.
  • scripts/mosaic: sends access to the CLI.
  • packages/cli/README.md: the verbs, the human proof, the lock, the instances limit, the removal order, and the limits on peers and subjects.
  • packages/cli/tests/access.test.mjs: 21 tests (round 2; 18 in round 1). Single file, green on this branch.

R1 (packages/business/src/access.mjs) is merged in unchanged at 80fe31071, the Duckworth-approved candidate. 44e36fc4c, the next commit on that branch, only removes a stray pyc file.

The lock file (not in PLAN.md; added at Sage's request)

  • Path: <configDir>/access.json.lock, the access file's path plus .lock. Mode 0600.
  • Created with O_EXCL (wx). The holder writes its pid and a newline into it, then holds it for the read, the check, the temp write and the rename. It is removed in a finally.
  • A second writer that finds the lock reads the pid. If the pid is alive (or the pid is reused by a live process), it refuses with refused: ... being written by another command; try again (exit 3). Nothing is written.
  • A lock whose pid is gone (kill(pid, 0) returns ESRCH) also refuses, and nothing reclaims it. The refusal names the path and the pid. A human checks that no write is running and removes the file. This is the crash case: the holder died mid-write, and a human clears it.
  • An empty lock, or one that is not a pid, refuses. Its holder may still be between create and write. A holder that died in that gap leaves an empty lock; a human removes it.

Reclaim was removed at Sage's ruling (sage-1429, 10-20). It was the one design with a race, so the race below is gone, not accepted.

Former limit, now removed: reclaim race. The old design let a writer take a stale lock by unlinking it and retaking it. The case that lost: writers A and B both read the same dead pid N. A unlinks the lock and takes it. B then unlinks A's live lock and takes its own. Both read the same revision, both write it, and the second rename silently replaces the first. The revision check did not catch it, because both had read the old revision before either wrote.

Known limits, stated plainly:

  1. A dead holder's lock blocks every write until a human removes it. That is the fail-closed price of no reclaim. The refusal says which pid and which path.
  2. A reused pid reads as alive, so a stale lock refuses. The fix is to remove the file by hand.
  3. The lock is per access file. It does not cover the bus's own reads.
  4. Crash-safety of the write path: the temp file is removed on any failure before or at the rename. The directory is not fsynced after the rename, so a power loss right after a rename can lose the rename. The bytes are never mixed.

Tests for the lock (all in access.test.mjs):

  • "a live holder's lock refuses and leaves the file and the lock alone": pid = this process.
  • "an empty or unreadable lock refuses": empty file, and not-a-pid.
  • "a lock left by a dead pid refuses, names the pid, and is not reclaimed": a child that has exited. The file, the lock and the receipt are unchanged.
  • "the lock is mode 0600 while held and removed after a refused write".

Tests for the write path and the refusals (added in this round):

  • "a failed rename leaves the old bytes, removes the temp file, and writes no receipt": the fs seam (deps.fs) makes renameSync throw. Checks the old bytes, no .access-*.tmp, no lock, no receipt.
  • "a group-writable access file refuses with exit 3, names the file, and changes nothing": a 0664 file. Exit 3, the file path in the message, nothing written.
  • "an agent or tester caller is refused before the context or the access file is read": contextFor throws if called, and the file is 0664. The refusal must come first.

The human proof, and the bus behavior behind test 10

The proof is one project.list call through humanTransport. The bus gates it with #human (packages/bus/src/broker.mjs, the project.list case at :2319, and #human at :377). The call is in one function, proveHuman in access.mjs, for R4 to swap.

Test 10 runs the real human-cli.mjs on a scratch bus from agent ancestry (CLAUDECODE=1 in the child's env). The bus refuses. The client reports outcome-unknown, not human-required, because the bus closes the socket on a failed proof and packages/bus/src/client.mjs:32-33 maps a closed socket to outcome-unknown. Exit 1 (busExit). The test pins that code and asserts the access file and the receipt are unchanged.

This is a bus behavior outside R5. It is not changed here. Gizmoduck and Gander own the verifier (row 196). Until it changes, a human who hits the agent refusal sees outcome-unknown.

Context

accessContextFor(system, env):

  • providers: system.console.sso.providers[].id. Row 192's shape (packages/webui/src/sso-config.mjs on row 192's candidate).
  • ssoSubjects: each provider's subjects[].sub, paired with the provider id. Gizmoduck's design says console.sso.subjects; Sage ruled that is shorthand for this (erratum).
  • peers: [] until row 203 adds the federation key. A grant that names a peer home refuses.
  • businesses: loadBusiness for each <configDir>/businesses/*.json; human and the role names as instances. A business file that fails to load fails the command.

No console key gives empty providers and subjects, so every grant naming a subject refuses. The checkConsole check from row 192 is not on this branch yet. R5 rebases onto row 192's landing, and then reads console through checkConsole.

Mutant list (one per refusal; run on Sage's go, before review)

# Mutant Expected kill
M1 remove the refuseInsideAgent call "an agent caller is refused before the bus is asked"
M2 remove the --tester check "--tester is refused on every verb"
M3 the proof's error is swallowed "a bus that refuses the human proof blocks the write"
M4 the revision compare is removed "a stale revision refuses with revision-conflict"
M5 takeLock unlinks a dead-pid lock and retakes it (the reclaim this round removed) "a lock left by a dead pid refuses, names the pid, and is not reclaimed"
M6 pidAlive always returns true (a dead pid reads as live) "a lock left by a dead pid refuses, names the pid, and is not reclaimed"
M11 writeAtomic writes straight to access.json, no temp file or rename "a failed rename leaves the old bytes, removes the temp file, and writes no receipt"
M12 the BusinessError catch in accessCommand is removed "a group-writable access file refuses with exit 3, names the file, and changes nothing"
M13 contextFor() is called before the agent and tester refusals "an agent or tester caller is refused before the context or the access file is read"
M7 checkAccess is skipped before the write "a grant naming an unknown instance is refused before any write"
M8 the receipt append is removed "grant writes ... a receipt line"
M9 a project name is written as given (no lookup) "a project name resolves to its id"
M10 the lock is not taken "a live holder's lock refuses"

Round 2 closed the direct-write gap: writeAtomic takes the fs calls through deps.fs (default: the real node:fs functions), and a rename that throws is tested (M11 is the direct-write mutant). Not yet run: the mutant batch, the cli suite and scripts/test-*.sh. All wait on Sage's slot.

Round 2 also changed the file's refusal path: a BusinessError from loadAccess or loadBusiness now exits 3 with the file path and key path on stderr, no stack trace (Duckworth B1, Sage sage-1437).

Not done in this row

  • --peers and the federation key: row 203.
  • access.reload: row R4.
  • Gizmoduck's console.sso.subjects name: erratum, the flattened shape is used.
  • The suite (packages/cli and every scripts/test-*.sh) and the mutant batch: on Sage's gate.