Candidate quackerjack/fed-r5-r5a74cd9f87onc3d88840c, 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 ofa74cd9f87ontoa849d89b7(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 onto9c9732983with the candidate's blobs and modes for the 6 paths; c3d88840c..9c9732983 touches none of them.
128 lines
8.3 KiB
Markdown
128 lines
8.3 KiB
Markdown
# 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.
|