# Row 203, FED R2: the federation key in the system config (#1701) Owner Quackerjack, reviewer Webby, gate owner Sage. Brief: docs/plans/2026-10-20_federation-r1-r2-r5.md, "Brief: FED R2". Design: DESIGN.md section 3.1 (D1), section 4 (the key shape), section 7 row R2, at gizmoduck/federation-design b93f098f0. Base: aca16404c, rebased onto f2735bb6a (origin/refactor, shared object store). Rebased onto 253's tip only if 253 lands first, under a new branch name. ## Commits - 0eb4b6f1f, rebased to e704a18ae: the loader, the first FED R2 tests, and these notes. - Second commit on top of e704a18ae (sage-2998): B1 and B2 below, the fixture fix, and the notes rewrite. Its sha goes in the status line once committed. ## Shape ```json "federation": { "self": "hub", "peers": [{ "id": "factory", "origin": "https://mosaic.woltje.com", "label": "Software factory" }] } ``` - Optional. Absent: the config loads and prints what it printed before (no `federation` in the output). - `self` and each `peers[].id` match `^[a-z][a-z0-9-]{0,31}$`. A peer id may not equal `self` or another peer's id. - `peers[].origin` must be an https origin exactly as `new URL(origin).origin` returns it: no path, no query, no userinfo, no trailing slash. - `peers[].label` is a non-empty string of at most 80 characters. - Labels and URLs only. Nothing here grants trust to a peer (section 3.1 D1, section 3.2 W1). ## Readers of the key Two readers read the raw key. Nothing else reads it (`git grep "federation"` at e704a18ae): 1. The loader, `scripts/mosaic-config.mjs` (`validate`, the `federation` branch). This is the only reader of the validated value. Its output is read by the CLI and business code, which read named fields only. The output is checked in `scripts/test-config.sh`. 2. The stack-release probe, `config_checks` in `scripts/stack-release.sh` (B1, below). It reads the config file itself, before a copy is activated or rolled back to, and asks whether the target copy's loader refuses a key the config holds. Stale wording, NOT changed in this row (Sage rulings sage-2808 and sage-2809): `packages/cli/src/access.mjs` is out of row 203. Megavolt's row 207 changes that file. Follow-up, to be carried to DEFERRED by Sage: - access.mjs lines 26-27 (`ACCESS_HELP` comment): "Peer homes are empty until the federation key lands (row 203)". Still partly true after this row, since the access context does not read the key. - access.mjs line 36 (inside the `ACCESS_HELP` string, user-visible help): "Peer homes are not read yet (row 203 adds the federation key)". The same claim, in help text. No test in `packages/cli/tests` pins it (`git grep` for "row 203" and "Peer homes" finds nothing in tests). - access.mjs line 44 comment: "peers is empty until row 203". - `packages/cli/README.md` line 1107: "until row 203 adds the". Outside the manifest; flagged in the same follow-up. Behavior after this row: `accessContextFor` still returns `peers: []` (access.mjs line 58). Row 203 adds the config key only, so grants naming a peer home still refuse. Wiring the key into the access context is not in the FED R2 brief. ## B1: the stack-release probe (sage-2998, in scope) Webby's early look (webby-0189): a config holding `federation` and no `console` passed `config_checks`, because the probe looked only for `console`. A rollback or activate to a pre-203 copy would then pass its checks, and that copy's loader refuses `unsupported configuration key: "federation"` on the next read. Fix (scripts/stack-release.sh, `config_checks`): - The probe's node check now reads `console` or `federation` (`Object.hasOwn(d,"console")||Object.hasOwn(d,"federation")`). - The refusal keeps the check name `config-console-key`, so existing callers and tests do not change. The message now reads "holds a console or federation key". This is a wording change to an existing refusal, and `KEY_MSG` in `packages/cli/tests/stack-release.test.mjs` moves with it. - The new test is `row 203: a config whose federation key the target loader refuses ...` in `packages/cli/tests/stack-release.test.mjs`, after the row 200 console test. Target B's loader has no federation key in its list, so it refuses the key. Activate to B and rollback to B are both refused, and a rollback succeeds once the key is removed. Fixture (packages/cli/tests/stack-release-fixture.mjs, line 300): `LOADER_OLD` builds B's loader by a string replace on the `rejectUnknownKeys` list. Adding `federation` to that list in this row left the replace with no match, and the fixture's `assert.notEqual(LOADER_OLD, LOADER)` would fail at load. The replace now targets `"execution", "console", "federation"]`, so B's loader refuses both keys. This regression came from my e704a18ae, and the fix is in this commit. ## B2: a case and a mutant for each refusal (sage-2998: "one mutant per refusal") Webby's list is covered below. Each refusal has a case in `scripts/test-config.sh` (FED R2 block) and a mutant. M5 is killed by the self case; M13 covers the duplicate half of the id check that M5 did not reach. ## Refusals and their tests `scripts/test-config.sh`, FED R2 block: | Case | Refusal (exit 2) | Mutant (killed by) | |---|---|---| | valid key loads and is in the output | none | base | | unknown top-level sub-key | `unsupported federation key: "extra"` | M1 | | unknown peer sub-key | `unsupported federation.peers[0] key: "trust"` | M2 | | origin over http | `federation.peers[0].origin must be an https origin ...` | M3 | | origin with a path | same | M4 | | origin with userinfo | same | M4 | | origin with a trailing slash | same | M4 | | peer id equal to self | `federation.peers[0].id ... is already named` | M5 | | duplicate peer id | `federation.peers[1].id "factory" is already named` | M13 | | peer label empty | `federation.peers[0].label must be a non-empty string ...` | M6 | | peer label over 80 characters | `federation.peers[0].label must be ...` | M12 | | federation not an object | `"federation" must be a JSON object` | M7 | | self does not match the pattern | `federation.self must match ...` | M8 | | peers not an array | `federation.peers must be an array` | M9 | | peer not an object | `federation.peers[0] must be a JSON object` | M10 | | peer id does not match the pattern | `federation.peers[0].id must match ...` | M11 | | self missing | `federation.self must match ...` | M15 | | peer id missing | `federation.peers[0].id must match ...` | M16 | | peer label missing | `federation.peers[0].label must be ...` | M17 | | no federation key | loads, no federation output | base | | B1: target loader refuses federation | `config-console-key` refusal (stack-release.test) | M14 | Mutant plan (run on Sage's gate only, one edit each, a named test must fail). Before the gate each edit string must occur exactly once in its file. I checked all of M1 to M17 with grep -cF: each is 1. The gate's mutant runner checks the count again before it applies each edit: - M1: `rejectUnknownKeys(value, ["self", "peers"], "federation");` removed. Killed by "federation unknown sub-key". - M2: `rejectUnknownKeys(peer, ["id", "origin", "label"], where);` removed. Killed by "federation peer unknown sub-key". - M3: `url.protocol !== "https:" ||` removed. Killed by "origin over http". - M4: `|| url.origin !== value` removed. Killed by "origin with a path", "origin with userinfo" and "origin with a trailing slash". - M5: `if (ids.has(peer.id)) {` changed to `if (false) {`. Killed by "peer id equal to self". - M6: `peer.label.trim() === "" ||` removed. Killed by "peer label empty". - M7: `if (!isPlainObject(value)) {` changed to `if (false) {`. Killed by "federation not an object". - M8: `|| !FEDERATION_ID_PATTERN.test(value.self)` removed. Killed by "self does not match the pattern". - M9: `if (!Array.isArray(value.peers)) {` changed to `if (false) {`. Killed by "peers not an array". - M10: `if (!isPlainObject(peer)) {` changed to `if (false) {`. Killed by "peer not an object". - M11: `|| !FEDERATION_ID_PATTERN.test(peer.id)` removed. Killed by "peer id does not match the pattern". - M12: ` || peer.label.length > FEDERATION_LABEL_MAX` removed. Killed by "peer label over 80 characters". - M13: ` ids.add(peer.id);` removed. Killed by "duplicate peer id". - M14 (stack-release.sh): `||Object.hasOwn(d,"federation")` removed. Killed by the row 203 federation test in `stack-release.test.mjs`. - M15: `typeof value.self !== "string" || ` removed. Killed by "federation self missing". Without the typeof half, an absent self passes the pattern (`/^[a-z]…$/.test(undefined)` is true). - M16: `typeof peer.id !== "string" || ` removed. Killed by "federation peer id missing". Same reason. - M17: `typeof peer.label !== "string" || ` removed. Killed by "federation peer label missing". Without it, a missing label throws a TypeError at `.trim()` (exit 1), not the exit 2 refusal. ## Boundaries - No config is written. `~/.config/mosaic-dev/config.json` is not touched. Tests use `$SANDBOX` only. - No change to `packages/cli/src`, `scripts/bootstrap.sh`, or any reader beyond the stack-release probe. ## Manifest (sha256 of committed blobs; the list is the review scope) - scripts/mosaic-config.mjs - scripts/test-config.sh - scripts/stack-release.sh (B1) - packages/cli/tests/stack-release.test.mjs (B1 test, KEY_MSG) - packages/cli/tests/stack-release-fixture.mjs (B's loader fixture; without this, the suite fails to load) - agents/quackerjack/work/queue-203/BUILD.md ## Slot estimate (for Sage's gap) - test-config.sh: about 5 min, padded. - The cli suite: about 5 min, padded. - stack-release.test.mjs: up to 1800 s, per Sage. It copies the loader, so it runs whole at the gate (Webby N3). - Mutants M1–M13 and M15–M17 each re-run the FED block of test-config.sh. M14 re-runs stack-release.test.mjs. The per-mutant cost is not measured: this run has not been timed. ## Status - e704a18ae: the loader, first tests and notes, rebased onto f2735bb6a. - 8d34a9e94: B1, B2, the fixture fix and the notes rewrite (Mos approved the two old-test edits, mos-1346). - Third commit on top: B3 cases M15–M17 and these notes (sha in the message to Sage and Webby). - NOT YET RUN: no node during Gander's slot. The test-config.sh, cli and stack-release gates run when Sage announces a CLEAR. `bash -n` passes on both shell scripts. - Not pushed, not claimed. Per invariant 8 the gate runs before any commit is called green. ## Review notes (Webby, webby-0189, answered here) - B1: fixed as above (scope per sage-2998). The probe's check name `config-console-key` is kept on purpose (Webby N5, Sage): renaming it would change callers and tests for no gain in this row. The message says "console or federation key"; the name is the only old word left. - B3 (webby-0191): missing self, missing peer id, and missing peer label each have a case and a mutant (M15–M17). The typeof halves are the only thing refusing an absent value, since the pattern test coerces undefined. - B2: a case and a mutant for each uncovered refusal, plus the trailing-slash case under M4 (N4) and the duplicate-peer case (M13). - N1: this file is rewritten with the current base and status. - N2: consumers checked by `git grep "mosaic-config"`. The loader's output is read by `packages/cli/src/config.mjs`, `packages/business/src/*.mjs`, `packages/webui/src/sso-config.mjs`, `packages/seat/src/seat.mjs`, `packages/discord/src/binding.mjs`, `packages/conversation/src/guard.mjs`, and the shell scripts that call `validate` (bootstrap.sh, common.sh, instance.sh, land-work.sh, stack-copy.sh, stack-release.sh, conductor-apply.sh). Checked by `git grep "federation"` over packages and scripts: no reader outside the loader and the stack-release probe mentions `federation`. I did NOT read each call site for which fields it uses; that is still open for Webby's static read. Not run. - N3: the cli stack-release tests copy the loader; they run whole at the gate. Listed in the manifest and the slot estimate. - N4: trailing-slash case added (see table; M4). ## Gate on the rebased tip (Sage sage-3055, sage-3057; claim rev 3361) Brief pin: docs/plans/2026-10-20_federation-r1-r2-r5.md re-pinned to 8adfb261f (sage-203-repin). "Brief: FED R2" is byte-identical between b624ddd49 and 8adfb261f (Sage's diff). Rebase: onto origin/refactor c7b754d7b (row 207 landed at c4f77373f on that tip). The three commits are now 93a491d73 (loader), 284d55cf2 (stack-release probe and fixture), 7c543fd4a (B3 cases). Blob check against the pre-rebase candidate d5428c1a8 (`git rev-parse :`), all six identical: | blob | path | |---|---| | 4c26453a0 | scripts/mosaic-config.mjs | | 06b9d8ea2 | scripts/test-config.sh | | 92bd6f7d0 | scripts/stack-release.sh | | beb8da252 | packages/cli/tests/stack-release.test.mjs | | f080bf2f5 | packages/cli/tests/stack-release-fixture.mjs | | 6f29dbbde | agents/quackerjack/work/queue-203/BUILD.md (pre-gate-commit blob) | Timeouts (Mos mos-1275: `timeout -k 10 ` per script, at least 3x its longest run; written here before the first run). Basis: row 202's gate on this machine (scratch g202gatelBKGE0): cli suite 620.3 s (duration_ms 620297.68, EXIT=0); the ten scripts together finished in about 2.5 min, so no single script was measured on its own. Figures are set above 3x of those, and the stack-release.test run is set from Sage's up-to-1800 s estimate. They are estimates, not measured. A timeout hit is a red and goes to Mos first. Mos rules on these figures. | target | timeout S (seconds) | basis | |---|---|---| | scripts/test-auth.sh | 900 | 3x of a row 202 script, padded | | scripts/test-conductor.sh | 900 | same | | scripts/test-config.sh | 900 | same; also each mutant run of the FED block | | scripts/test-discord.sh | 900 | same | | scripts/test-extension-package.sh | 900 | same | | scripts/test-foundation.sh | 900 | same | | scripts/test-land-work.sh | 900 | same | | scripts/test-queue.sh | 900 | same | | scripts/test-release.sh | 900 | same | | scripts/test-task.sh | 900 | same | | packages/cli suite | 2400 | 3x of 620 s is 1861 s | | stack-release.test.mjs (whole) | 5400 | 3x of Sage's 1800 s estimate | Run command shape: `timeout -k 10 `. Queue verbs are never wrapped in a timeout. Mutant kill rule: each of M1-M17 must fail on an assertion, not on a crash. The mutant run records the failing test name. Skips are named in the gate record. ## Gate results on 7309260ae (23:00-23:16Z) - stack-release.test.mjs, whole: 219 pass, 0 fail, 0 skipped, 583.5 s (timeout 5400 s). - test-config.sh: selftest 51 pass, 0 fail, EXIT 0, 2.4 s (timeout 900 s). - Mutants, one run each, file restored from git after each. Each run of M1-M13 and M15-M17 is 2.4 s (the first run is the timing: 2.4 s, so the 900 s timeout is far above it). | mutant | result | killed by (FAIL line in test-config.sh, or stack-release.test) | |---|---|---| | M1 | killed | federation unknown sub-key | | M2 | killed | federation peer unknown sub-key | | M3 | killed | federation peer origin over http | | M4 | killed | federation peer origin with a path (also userinfo, trailing slash) | | M5 | killed | federation peer id equal to self (also duplicate peer id) | | M6 | killed | federation peer label empty | | M7 | killed | federation not an object | | M8 | killed | federation self pattern | | M9 | killed | federation peers not an array | | M10 | killed | federation peer not an object | | M11 | killed | federation peer id pattern | | M12 | killed | federation peer label over 80 characters | | M13 | killed | duplicate peer id | | M14 | killed | row 203 subtest of T57 in stack-release.test.mjs (parent T57 also fails) | | M15 | killed | federation self missing | | M16 | killed | federation peer id missing | | M17 | killed | federation peer label missing | Correction (new entry; the kill-text column in the mutant plan above is kept as written): the plan named the kill labels for M8 and M11 as "self does not match the pattern" and "peer id does not match the pattern". Those labels are not in the test file. The assertions that fail are "federation self pattern" and "federation peer id pattern". Both mutants are killed; only the label text in the plan was wrong. M14 note: `node --test --test-name-pattern="row 203"` runs zero inner tests, because the row 203 case is a t.test subtest of T57 and the filter skips the parent. The first automated M14 run reported SURVIVED for that reason (rc 0 in 0.9 s, no FAIL line). It was re-run with `--test-name-pattern="T57|row 203"`: the row 203 subtest fails (rc 1), so M14 is killed. The automated result is the wrong one and is not the gate record. ## cli suite (23:20-23:37Z) - First run (timeout 2400 s, 627.6 s): 895 tests, 816 pass, 77 fail, 2 cancelled, EXIT 1. Cause: this worktree had no node_modules, so scripts/case2-persona-import.mjs (and the other suites that import it) could not resolve package 'yaml' (declared at 2.9.0 in package.json). Not a federation failure. Not in the gate record. - Fix in the environment only: node_modules symlinked to /mnt/storage/src/mosaic-stack/node_modules (the row 202 recipe). The symlink is excluded through .git/info/exclude and is not committed. - Rerun (timeout 2400 s, 621.9 s): 895 tests, 895 pass, 0 fail, 0 cancelled, 0 skipped, EXIT 0. ## Gate summary (23:00-23:40Z, rebased tip c7b754d7b, code tree 7309260ae) | gate item | result | timeout S | measured | |---|---|---|---| | stack-release.test.mjs, whole | 219 pass, 0 fail, 0 skipped | 5400 | 583.5 s | | test-config.sh | 51 pass, 0 fail | 900 | 2.4 s | | mutants M1-M17 (named assertion) | 17 of 17 killed | 900 (per run) | 2.4 s per config run; M14 by row 203 subtest | | cli suite (npm test) | 895 pass, 0 fail, 0 skipped | 2400 | 621.9 s | | test-auth.sh | 15 pass, 0 fail | 900 | 1 s | | test-conductor.sh | 17 pass, 0 fail | 900 | 6 s | | test-config.sh | 51 pass, 0 fail | 900 | 2 s | | test-discord.sh | 81 pass, 0 fail | 900 | 8 s | | test-extension-package.sh | 18 pass, 0 fail | 900 | 1 s | | test-foundation.sh | 44 pass, 0 fail | 900 | 41 s | | test-land-work.sh | 86 pass, 0 fail | 900 | 12 s | | test-queue.sh | 28 pass, 0 fail, 1 skip | 900 | 21 s | | test-release.sh | 14 pass, 0 fail | 900 | 2 s | | test-task.sh | 91 pass, 0 fail, 2 skip | 900 | 14 s | Named skips: - test-queue.sh: `queue verify and render --check` skipped. This checkout is not the queue's canonical root (/mnt/storage/src/mosaic-stack). The check runs on the canonical checkout only. - test-task.sh: two live-provider cases skipped by design (row 244: no provider call in the gate). Timeouts: the ten per-script figures above were written before the first run and were not changed. Every script finished far inside its timeout. No timeout was hit, and none was placed around a queue verb. Environment: the first cli run failed for a missing node_modules in the worktree; see the cli section above. The rerun is the gate record.