Files

19 KiB
Raw Permalink Blame History

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

"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 <commit>:<path>), 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 <S> 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 <S> <cmd>. 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.