diff --git a/agents/quackerjack/work/queue-202/PLAN.md b/agents/quackerjack/work/queue-202/PLAN.md new file mode 100644 index 000000000..db8549478 --- /dev/null +++ b/agents/quackerjack/work/queue-202/PLAN.md @@ -0,0 +1,106 @@ +# Row 202, FED R5: `mosaic access list`, `grant`, `revoke` + +Quackerjack, 2026-10-20. Brief: `docs/plans/2026-10-20_federation-r1-r2-r5.md`, "Brief: FED R5". Design: +`agents/gizmoduck/work/federation/DESIGN.md` at a1daa3c07 (sections 3.5, 3.7, 7 row R5). Reviewer Duckworth. +Chained on row 201 (R1, Gizmoduck). Interface as Sage relayed it (sage-1400, gizmoduck-0030). + +## Status before build + +- Claim: `scripts/mosaic queue move 202 in-progress --op quackerjack-202-start --by quackerjack`, run from + the canonical checkout, exits 2: "row 202 waits on 201 (done; now briefed)". Row 201 is `briefed`, so the + claim is not made. I will not build past the plan until Sage says how to proceed. This plan is a file in + my own worktree; it changes no queue row. +- `gizmoduck/fed-r1` is not on origin yet. R5 imports `packages/business/src/access.mjs` only after R1 lands. + Until then, code and tests run against the names in sage-1400, with the R1 import stubbed in one place. + +## Interface I build against (R1, as relayed) + +- `checkAccess(doc, { providers, peers, businesses, ssoSubjects })`: pure. Returns the checked document, + frozen. Throws `BusinessError` (exit 2) naming the key path. `ssoSubjects` is required; a missing one fails + closed. +- `covers(grant, { business, project, instance }, now, { level } = {})`. +- `accessFilePath(dir = configDir())`: `/access.json`. +- `loadAccess(file, context)`: one read, refuses a symlink, a non-regular file, another owner, or group or + other write, then runs `checkAccess`. An expired grant is not refused here. + +## Files I will change + +New: +- `packages/cli/src/access.mjs`: argument parsing for `list`, `grant`, `revoke`; the human check; the write; + the receipt; the usage text. Exports a `main`-style function the way `thread.mjs` and `project.mjs` do. +- `packages/cli/tests/access.test.mjs`: all R5 tests below. + +Edited: +- `scripts/mosaic`: add `access` to the dispatch case that sends a verb to `packages/cli/src/cli.mjs`. +- `packages/cli/src/cli.mjs`: route `access` to `access.mjs`. +- `packages/cli/README.md`: the three verbs, the `instances` limit, and the provider-removal order. + +Not touched: `packages/business/src/access.mjs` (R1 owns it), `scripts/mosaic-config.mjs` (R2), any bus +broker code (R3/R4), `~/.mosaic-dev`, `~/.config/mosaic-dev`, the units, any live `access.json`. + +## Behaviour + +- `list`: prints the users, grants and revision of `accessFilePath()`. Reads through `loadAccess`, so a bad + file refuses here too. Shows each grant's project as the id (and the name, when `project.list` resolves + it). Shows `["*"]` as "all projects, now and later". +- `grant`: `--user`, `--business`, `--projects`, `--instances`, `--level collaborator`, optional + `--expires`, `--revision N`. Builds the new document in memory, runs `checkAccess` on it, then writes. A + bad grant refuses before any write. `--project ` resolves the name through `project.list` at grant + time and writes the id (design 3.5). +- `revoke`: removes the user's grants, or one grant with `--grant ID`, same flow. +- Write: a temp file in the same directory, mode 0600, fsync, rename over `access.json`. Revision goes up by + one. The caller passes the revision it read, and a mismatch refuses with `revision-conflict` before the + rename. +- Receipt: one line appended to `/state/access-log.jsonl`. Append-only, same shape as + `activation-log.jsonl` (`packages/runs/src/state.mjs`). It records the time, the verb, the actor, the + revision before and after, and the grant ids touched. No secret or token goes in it. +- Help text states two things. First, a grant's `instances` is the real limit: a collaborator drives the + granted instance with the owner's role (design 3.5). Second, the removal order: take a user out of + `access.json` first, which acts at once. Removing a provider from `console.sso` takes effect only after a + Console restart (design 3.7). + +## Human proof + +The check runs as the human transport does now: `humanTransport` (`packages/cli/src/transport.mjs`) runs +`packages/bus/src/human-cli.mjs` against the bus socket, and `refuseInsideAgent` refuses first in any agent +ancestry. Open point, for Sage or Gizmoduck to settle before build: the design names no bus verb for the +grant check. My reading is that R5 makes one call through `humanTransport` to a read the bus already gates +as human-only, and uses its success as the proof, with no new bus verb (R5 must not widen the bus). +If that is wrong, I stop and report it. + +Tests use two stand-ins, the pattern `packages/cli/tests/helpers.mjs` already has: +- Stand-in human CLI: `fakeCli(root, body)` replaces `human-cli.mjs` as the `cli` argument of + `humanTransport`, so the answer comes from a table. Used for the caller-refusal tests and most of the rest. +- Scratch bus: `startBroker` from `packages/bus/src/runtime.mjs` on a scratch socket and scratch data root. + One integration test runs the real `HUMAN_CLI` against it, so the proof path is checked end to end. The + bus accepts only its own `human-cli.mjs` path (memory #225), so that test uses the real file, not a copy. +- Tester caller: a `tester` proof, through `testerTransport` with `TESTER_CLI`. Refused with `tester-refused` + or `tester-required` as the bus returns it. +- Agent caller: env with `MOSAIC_RUN_ID` (and the other markers in `AGENT_MARKERS`) set. Refused by + `refuseInsideAgent` (exit 3) before any transport call. + +## Test list (`packages/cli/tests/access.test.mjs`) and one mutant per refusal + +Each test uses a scratch `MOSAIC_CONFIG` dir and a scratch data root. None reads `~/.config/mosaic-dev`. + +| # | Refusal or behaviour | Test | Mutant (must be killed) | +|---|---|---|---| +| 1 | Agent caller refused | env with `MOSAIC_RUN_ID` runs `grant`; exit 3, no file change | `refuseInsideAgent` call removed from the access path | +| 2 | Tester caller refused | `tester` proof runs `grant`; `tester-refused` (exit 3), no write | tester branch lets a tester write | +| 3 | Human proof required | stand-in answers `human-required`; exit 3, no write | proof result ignored | +| 4 | Concurrent write refused | two writers read revision 3; second with `--revision 3` gets `revision-conflict`; file keeps revision 4 | revision compare removed | +| 5 | Receipt appended | after `grant`, one new line in `access-log.jsonl` with both revisions; a second write adds a second line, the first is unchanged | receipt write removed, or the write truncates the log | +| 6 | Bad grant refused before write | grant with a non-UUID project, unknown instance, level `viewer`; exit 2, file bytes unchanged | `checkAccess` runs after the rename | +| 7 | Atomic write | the write is a temp file and a rename; a failed rename leaves the old file whole | direct write to `access.json` | +| 8 | Revoke removes the grant | `revoke` removes the user; `list` no longer shows it; revision +1 | revoke leaves the grant | +| 9 | `--project ` resolves to an id | name resolves through `project.list`; the file holds the id | name written as-is | +| 10 | Real proof path | scratch `startBroker` plus real `HUMAN_CLI` with a valid proof; grant succeeds | (control: the success path must pass with no mutant) | + +The mutant batch needs Sage's slot. Single test files run freely. + +## Gate + +- Duckworth approves this manifest and the code. +- The cli suite and every `scripts/test-*.sh` green on Sage's gate. +- Each mutant above is killed. +- Rebase onto R1's landing; re-run every touched test file singly before in-review.