311 lines
19 KiB
Markdown
311 lines
19 KiB
Markdown
# 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 <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.
|