Files

31 KiB

Row 294 build notes (Launchpad): FED R6, Console granted-user sign-in (#1800)

Brief: docs/plans/2026-10-23_mvp-readiness-rows.md, "Brief: FED R6, Console granted-user sign-in". Design: agents/gizmoduck/work/federation/DESIGN.md at b93f098f0 (r4.11, sha256 1bcee8df…c8e6), sections 3.1, 3.4, 3.7 and the R6 row of section 7. Assigned in sage-3136. Reviewer Duckworth.

Base and chain

Branch launchpad/row294 on origin/refactor 253346e3c (270+271, schema 3n in packages/bus, and 571). Parents done: 192 (#412 phase 1), R1 (201), R2, R3 (274). Row 208 and R7 (296) wait on this row.

Plan

What the Console does today

  • sso.mjs callback: a sub that is not in console.sso.providers[].subjects refuses subject-unknown. A session record is {provider, iss, sub, name, ...} and every signed-in request runs as the business human.
  • serve.mjs: every bus route, chat route and write uses objects built once in cli.mjs over the human transport (busCaller over HUMAN_CLI).
  • accessContextFor (packages/cli/src/access.mjs:62) returns peers: [].

Build items

  1. Granted-user sign-in (sso.mjs). createSso takes an optional users hook: users.find({provider, sub}) returns a user id with a live grant, or null. The callback asks it only after the owner subjects miss, so the owner path is unchanged. A user session record carries user: <id> and name: <id>. A miss on both refuses subject-unknown, as today (the log line names the provider, iss and sub; no new code is needed for the refusal).
  2. The access hook (new packages/webui/src/access.mjs). consoleAccess({ load, business }):
    • load is () => loadAccess(accessFilePath(configDir()), accessContextFor(system, env)), R1's loader, called on every use, never cached.
    • find({provider, sub}): the user whose subjects hold that pair, with at least one grant in the Console's business that covers at level viewer (the bus's own liveness test, #userGrants). A loader throw finds nobody (fail closed).
    • check(session): the same test for the session's user and its own (provider, sub). A user whose subject was removed, whose grants were all removed or expired, or whose file now refuses fails it.
    • business resolves as busCaller does: --business, else the live host state.
  3. Per-request re-read (sso.mjs session()). For a user session, users.check(session) runs after the expiry check, before any route. A failure deletes the session record and logs sso refused code=user-revoked provider=<id> user="<id>". The request then goes the signed-out way: 401 (sign-in required) for /api/ and writes, or the sign-in redirect for a page GET. The cookie is not cleared on that response; it names a session that no longer exists, so it signs nothing in, and the next sign-in replaces it.
  4. A user-cli transport per session (new user-bus.mjs, cli.mjs). userCall({ socket, business, user }) spawns USER_CLI (packages/bus/src/user-cli.mjs) as a direct child of the Console with MOSAIC_BUS_USER_NONCE set, so user-cli does not re-exec and the broker's parent check (principal.mjs: parent argv[1] is webui/src/cli.mjs, argv[2] serve) sees the Console. The request on stdin is {business, user, verb, args}. It never sets MOSAIC_BUS_CLI_NONCE (it strips one from the inherited env) and never runs HUMAN_CLI. startServer takes userOptions(session), a factory that builds the whole bus-backed option set (consoleBus, new console-bus.mjs) over that session's user transport, per request.
  5. Owner-only controls hidden (serve.mjs and a user page). A user session reaches only:
    • the chat routes (/api/chat/*), over its own user transport, and
    • GET /api/config, /healthz, /auth/*, and the user page's static files. Every other path answers 403 owner-only before any bus call. Behind that gate, a user request never holds a human-transport object: the handler swaps every bus-backed option (bus, chat, send and each write, processing, project files) for the same object built over that session's user transport, and nulls the ones with no bus behind them (reads, the board proxy), so with the gate removed an owner route still reaches the broker only as the user, or answers not-configured. GET / serves a separate page, user.html with user.js, that renders only projects, threads and messages (no board, inbox, agents, settings, editor, processing or Sign-in-as-owner controls). The owner page's assets are not served to a user session. The gate is one function, userRoute(path, method), so the M1 mutant removes one call.
  6. The notice. user.html holds row 193's sentence word for word in the thread view: "The business owner sees your thread's agent's decisions, inbox entries, claims, tasks and project files, but not your messages."
  7. Hub label and peer links. /api/config returns federation: {self, peers: [{label, origin}]} from system.federation when set, for owner and user sessions alike. Both pages show "This Console: " and one plain <a href="<origin>">label</a> per peer, built with textContent and setAttribute('href'); no script, no fetch to a peer, no proxy (the hub proxy route is R8's).
  8. F8 peers wiring. accessContextFor returns peers: (system.federation?.peers ?? []).map((p) => p.id), and the ACCESS_USAGE text and the function's comment drop "peer homes are not read yet".
  9. Bus boot wiring (packages/cli/src/config.mjs). bootConfig adds access: { file: accessFilePath(configDir(env)), context: accessContextFor(system, env) } when the file exists (lstat). No file: no access key, so no user binds, as before R6. A context that throws: a warning and no access. A file the loader refuses at boot (sage-3153): one warning line, which the host writes to stderr as mosaic-bus: warning: access file refused at boot (<code>), no granted user binds until it is fixed: <path>. The access stays wired, so the broker refuses each user bind and the owner's bus boots. R1's loader has no refusal codes, and its messages can quote the file (a JSON parse error, a got "<value>"), so the line never carries the message. The code comes from the loader's exit code instead: 4 (the file could not be opened as a regular file, including a symbolic link) is access-unreadable, and anything else is access-invalid. config.mjs now imports access.mjs, which imports config.mjs (loadSystem). The cycle is safe because neither module touches the other's exports at module top level, only inside functions (sage-3153).

Choices and departures (for Sage and Duckworth)

  • C1. A separate user page, not the owner SPA with sections hidden. "Hidden" here means never sent: the owner page's markup and scripts are not served to a user session. Hiding sections in app.js would ship the owner controls and rely on CSS. Tradeoff: a second small page to keep.

  • C2. The Console's gate is an allowlist of user routes, so a new owner route added later is owner-only by default.

  • C2b. No human transport behind a user session. Without the swap, M1 would send /api/bus/message through the owner's send over human-cli, and the bus would accept it as the owner. The swap is what makes "the bus refusal stands on its own" true.

  • C3. Liveness is the bus's test (covers at viewer, in the Console's business), so the Console and the broker agree on who is live. The broker still decides every verb, project, instance and thread (design 3.1 D4).

  • C4. The bus's access boot option is wired here (sage-3142, Q2; accepted, sage-3153). Build item 9 above. Sage's ruling: with the file, users bind; without it, none; with a bad file, it "refuses as R1's loader does"; a boot never invents a grant. My reading of "refuses": the broker's access is R1's loader, read on every user request, so a bad file refuses every user bind (access-revoked) and the owner's bus still boots. Refusing the whole boot would take the owner's bus down over a user file. Tests B3 pin this reading. No live unit or config changes.

  • C5. Tests use an in-process Broker behind the user transport seam. The broker's user principal needs the Console as the user-cli's parent process, which a node:test process is not. So the webui tests inject a userOptions factory over a real Broker with bindUser and an access function, and a separate transport test checks the spawn: the USER_CLI path, the user nonce variable, no human nonce, and the {business, user, verb, args} request. R7 runs the real Console process end to end.

  • C6. The chat's plugins read goes over the owner transport (accepted, sage-3153). createChat asks the bus plugins first (createChannelCaller), and plugins is not a user verb, so a user chat would refuse before its first read. createChat({ call, owner }) sends owner exactly plugins with {}, and every other verb goes over call. consoleBus passes owner through, and cli.mjs gives it ownerCall. createChannelCaller (plugins/src/channel.mjs:16-20) uses the answer only to gate the webui channel, server-side. Sage's conditions, each pinned by G9:

    • the plugins answer never appears in a user's response;
    • in a user's chat the owner transport carries only plugins with {};
    • every chat verb goes over the user transport. Mutant M8 routes one more chat verb over the owner transport, and G9 kills it. A new broker user verb would have been a policy change, so it is out.
  • C7. The user transport is a new file, user-bus.mjs, not a mode in bus.mjs. bus/tests/channel-proof.test.mjs pins the sha256 of webui/src/bus.mjs (with human-cli.mjs, human.mjs and cli/src/transport.mjs). Changing bus.mjs would mean an old-test change through Mos. user-bus.mjs repeats the spawn and framing code (about 40 lines). bus.mjs is byte-identical to the base. Tradeoff: two copies of the spawn plumbing. Accepted (sage-3153). Follow-up (not this row): the next row that re-pins the channel-proof hash folds userCall back into bus.mjs and drops the copy.

  • C8. console-bus.mjs. The bus-backed option set moved out of cli.mjs into consoleBus(call, {send, plugins}), so the owner and each user session build the same set the same way. The agent verb lists and agentCaller moved with it; the tester socket still builds its own.

  • C9. 401 and 403 from the broker. chat-http's STATUS maps access-refused to 403 and access-revoked to 401, so a broker refusal reaches the browser as the right status.

Exception: the owner transport in a user's chat (C6; mos-1394, sage-3157)

Duckworth rules on this directly. The same text goes in the manifest notes.

In a granted user's chat the Console uses the owner's transport for one call: plugins with {}, the channel gate read. Nothing else goes over it. Its answer stays in the Console and is never sent to the user. No value from the user's session or request reaches the call. Mos ruled in mos-1394 that this is not an access-rule change, because the Console reads its own channel gate, and that it holds only while nothing from the user reaches the call. Every chat verb goes over the user's own transport. Where: createChat's owner (chat.mjs) and userOptionsFor (console-bus.mjs), wired with ownerCall in cli.mjs. Pinned by G9 and mutants M8, M10 and M11. If this reads as owner authority used for the user, it goes back to Mos.

Rulings (sage-3153)

  1. Boot wiring is accepted: a bad file refuses every user bind and the owner's bus boots. Added: one stderr line at boot naming the path and a code, never the contents (item 9, B3, M9). The mutual import is fine with no top-level use (item 9).
  2. C6 is accepted under conditions: the plugins answer stays server-side, the owner transport carries only plugins with {}, one G test (G9) records both transports, and a mutant (M8) routing one more chat verb over the owner is killed.
  3. C7 is accepted, with the follow-up note under C7.
  4. Owned files are accepted. packages/cli/src/access.mjs overlaps Morgana's row 295 (527002f97), and merge-tree is clean. 294 lands first and 295 rebases after it. packages/cli/tests/ access.test.mjs is now unchanged: the peer test moved to access-boot.test.mjs, so the only overlap is access.mjs.

Ruling sage-3157 (mos-1394): the boot line is accepted as built, with no codes from R1. For C6, G9 asserts the owner call is exactly plugins with exactly {}, and that no value from the session or the request reaches it. Mutants M10 (session value) and M11 (request value) are killed by G9. The exception is written above and goes in the manifest notes. The per-session builder moved into console-bus.mjs (userOptionsFor), so the wiring G9 runs is the same code cli.mjs uses, and a session value added there is a mutant G9 can kill.

Owned files

  • cli: src/config.mjs, src/access.mjs, tests/access-boot.test.mjs (new)
  • webui src: sso.mjs, access.mjs (new), console-bus.mjs (new), user-bus.mjs (new), chat.mjs, chat-http.mjs, cli.mjs, serve.mjs
  • webui public: user.html (new), user.js (new), federation.js (new), index.html, app.js
  • webui tests: granted-user.test.mjs (new)
  • this BUILD.md

No old test changes. Hash pins checked: git grep over 'packages//tests/' for each touched path with a 64-hex pin found only channel-proof's bus.mjs pin, which C7 avoids.

Tests

New file packages/webui/tests/granted-user.test.mjs, on Sage's fake OP (tests/helpers/fake-oidc.mjs) and an in-process Broker with an access function, like bus/tests/user-principal.test.mjs:

  • G1 a granted user signs in: session cookie set, /api/config says user, GET / is the user page, the chat lists reach the broker as user:<id>.
  • G2 an ungranted sub is refused: a user in the file with no grant, a grant for another business, an expired grant, and a sub in no file; each is subject-unknown, no cookie, nothing bound.
  • G3 revoked mid-session: sign in, then remove the grant, remove the subject, expire the grant, or make the loader refuse. The next chat read is 401 with a user-revoked log line and no transport call. Restoring the grant does not bring the session back, and GET / redirects to sign-in.
  • G4 owner verbs: for each owner route (board reads under /api/bus/*, settings, names, instances, routes, agents, processing, project files, /api/bus/message, /api/board), a user session gets 403 owner-only and the transport ran zero times.
  • G5 the bus refusal stands alone: the same session's user transport, called directly with each owner verb, gets access-refused from the broker. The only write is the broker's own refusal evidence: exactly one action.refused event per verb, the count taken from the verb list's length (22 today), each body exactly { code: 'access-refused' }, and every other table's row count unchanged (round 2; run 1 placed the round 1 "events unchanged" pin as a test defect, sage-3194). The broker's #refused (bus broker.mjs:4423) writes the code alone and no verb, so the test can't match each event to its verb; pinning the whole body means a field added there shows up here.
  • G9 the C6 seam (sage-3153, sage-3157), with the test's userOptions built by userOptionsFor as cli.mjs builds them: a user session runs the chat's project list, thread create, list, open, send and messages. The owner transport (a recording ownerCall) saw only ['plugins', {}]. Each chat verb went over the user transport, and plugins never did. A marker in the owner's answer appears in no response. Every owner call is exactly ['plugins', {}], and none of alice, alice-sub, the thread name, the message body, the project id or the thread id appears in what the owner transport received.
  • G6 the notice: the user page's notice element text equals the sentence exactly.
  • G7 hub label and peer links render from config: /api/config carries self and the peers' labels and origins; the user and owner pages show "This Console: hub" and an <a> per peer with the configured href and label (browser test, sso-browser.test.mjs style).
  • G8 the transport: userCall over a recording unix socket in a scratch dir. While the child waits for the answer, the test reads /proc//cmdline (argv[1] is USER_CLI), environ (MOSAIC_BUS_USER_NONCE set; MOSAIC_BUS_CLI_NONCE absent though the parent env passed one) and stat (ppid is the test process, so user-cli did not re-exec). The request line is {verb, args, user: {business, user, nonce, pid}}, which user-cli builds from {business, user, verb, args}.

packages/cli/tests/access-boot.test.mjs (sage-3142), over bootConfig and startBroker in a scratch root, the boot config through a JSON round trip as over the host IPC:

  • B1 with access.json: access.file and context.peers set; alice (home a configured peer) binds; bob gets access-revoked.
  • B2 without the file: no access key; alice gets access-revoked.
  • B3 with text that is not JSON, a home that is not a configured peer, and a symbolic link: the bus boots and alice gets access-revoked. The warnings are exactly one line, with access-invalid, access-invalid and access-unreadable and the path, and none quotes the file (a marker string in the file is absent from the line).

access-boot.test.mjs also holds the F8 peer test: a grant whose user's home is a configured peer id validates; an unconfigured peer id refuses home must be a configured peer.

Old tests: the sso.test.mjs and serve tests stay as they are. If one needs a change, it goes to Mos through Sage first (mos-1272).

Mutants

# Change Killed by Expected
M1 remove the userRoute gate call in serve.mjs G4 (:239, first route answers 403 access-refused, not owner-only) G4 red at :239 on its first route; G5 green in the same run
M2 skip access.check(session) per request G3 red
M3 change one word of the notice in user.html G6 red
M4 peers: [] back in accessContextFor the F8 peer test and B1 (access-boot.test.mjs) red
M5 sign-in skips the grant test (any listed user signs in) G2 red
M6 userCall sets MOSAIC_BUS_CLI_NONCE or runs HUMAN_CLI G8 red
M7 bootConfig drops access B1 red
M8 chat.mjs routes thread.list over owner too G9 red
M9 accessFor drops the boot refusal line B3 red
M10 userOptionsFor passes owner: (v, a) => owner(v, { ...a, sub: session.sub }) G9 red
M11 chat.mjs keeps the last request's args in channel() and passes them to owner('plugins', …) G9 (thread create's {project, name} reaches owner) red

M1 is the brief's "owner verb still refused by the bus": with the Console gate gone, the owner verb reaches the broker over the user transport and the broker refuses it (access-refused, 403). G4 kills M1 at :239 on its first route, because it pins the Console's own code owner-only. The count and "nothing written" asserts at :242-244 come after the loop, so under M1 they never run; the run records G4's failure with the actual value (access-refused). The bus refusing on its own is shown by G5, green in the same M1 run. (Corrected per Duckworth C1, duckworth-0249b; the earlier row said G4's "refused, nothing written" stayed green, which cannot be observed under M1.)

Run limits

Each run is timeout -k 10 LIMIT. TMPDIR comes from mktemp -d /mnt/storage/scratch/tmp/gXXXXXX. A timeout with no failed test goes to Sage first. A timeout in the shell phase goes to Mos unplaced (mos-1275). Under sage-3187, every LIMIT is at least 3x the longest recorded run, set before any run. The order is the webui, cli and bus suites whole, then each scripts/test-*.sh, then the mutants.

Run Longest recorded Receipt LIMIT
webui suite 57 s gizmoduck queue-264 BUILD.md:474 (row 278 run 3) 600 s
cli suite 682 s gizmoduck queue-270 BUILD.md:660 (7328da8ca) 2100 s
bus suite 25 s gizmoduck queue-270 BUILD.md:658 900 s
scripts/test-auth.sh 1.9 s 3b91cfdaf:agents/gander/work/queue-253/run5-gate/SUMMARY.json 126 s
scripts/test-conductor.sh 8.1 s same 145 s
scripts/test-config.sh 3.2 s same 130 s
scripts/test-discord.sh 8.8 s same 147 s
scripts/test-extension-package.sh 3.0 s same 130 s
scripts/test-foundation.sh 49.4 s same 269 s
scripts/test-land-work.sh 16.9 s same 171 s
scripts/test-queue.sh 35.2 s same 226 s
scripts/test-release.sh 12.2 s same 157 s
scripts/test-task.sh 36.8 s same 231 s
each mutant probe (its killing file alone) none: granted-user.test.mjs and access-boot.test.mjs are new the first green run in the suites sets the record 300 s

The script limits are Gander's SHELL-LIMITS values (3x the longest recorded run, plus 120 s), as recorded in agents/gander/work/queue-276/BUILD.md. If a new file's suite time is over 100 s, its mutant LIMIT goes to 3x that time before the probes run.

Review

Round 1, manifest 0b3f9c9d…94f3 (ca710eabd): Duckworth APPROVE (static), duckworth-0249b, note agents/duckworth/work/row294/ROUND1-ca710eabd.md on duckworth/reviews 5b7089ea1. The owner-transport exception is accepted and does not go to Mos.

  • C1: the M1 row and note above are corrected. The M1 run records G4's :239 failure value and G5.
  • C2: the gate runs on Sage's CLEAR.
  • N1 (no test reaches the C2b swap): no M12 in this row. Adding one needs a new test and a new manifest round after approval; the brief does not require it. Left as a follow-up for Sage to place: a test that a user session's options carry no owner caller besides plugins, with an M12 where serve.mjs keeps the owner's send for a user session.
  • N2, N3: accepted as they stand; no change.
  • N4: the user-bus.mjs fold-back (C7) is proposed to Sage as a queue row, for the next channel-proof re-pin.

Round 2 (Mos mos-1412 through Sage sage-3195; for Duckworth): two test changes after run 1, no product change. The manifest adds packages/webui/tests/browser.test.mjs (18 paths).

  • Red 1, old test, Mos approved (a): browser.test.mjs:22 pins index.html's script list. It gains '/federation.js' at index 2, where 294's index.html:7 puts it, ahead of brand.js. The :21 comment adds row 294 and federation.js. No other assertion in the file changes. Old and new lines are in ASSERTION-PRESERVATION.json. (b), loading the federation view another way, was rejected.
  • Red 2, new test G5: the pin is the one in the G5 note above. Mos's suggestion is taken: the count is refusals.length, not a literal. The verb check is not possible, because the event names no verb; the test pins the whole body instead.

Round 3 (sage-3212; goes to Mos as the row's second red, sage-3211, before any push or run): one changed line, in new test G5. Its baseline was SELECT max(seq) n FROM events. The G5 fixture has an empty events table, so that is NULL and seq > NULL matches no row: run 2 read 0 events added where run 1's count(*) had shown 22. The line is now SELECT coalesce(max(seq), 0) n FROM events. Nothing else in G5 and no product file changes. The manifest stays 18 paths; only granted-user.test.mjs's hash moves. My round 2 check missed the empty-table case.

Round 4 (Mos mos-1419 through Sage sage-3238, after run 3's third red): two test files, no product file. The manifest adds packages/cli/tests/stack-release.test.mjs (19 paths).

  • stack-release.test.mjs, an old test: the rehearsal fixture copies a fixed list of config.mjs's imports into each scratch copy. 294's config.mjs imports access.mjs, which imports transport.mjs (transport.mjs imports only errors.mjs). The :1991 list gains both, with a row 294 comment at :1987. No assertion changes. The real copy carries all of packages/cli/src (stack-copy.sh COPY_PATHS), so the product was not affected.
  • access-boot.test.mjs, new test B1-B3: the shared helpers.mjs business sets tracker.baseUrl without tracker.project, so bootConfig warns about it before any access line (trackerFor runs before accessFor in config.mjs). A TRACKER constant holds that line. B1 and B2 pin [TRACKER]. B3 pins [TRACKER, <the access refusal line>], and the secret-marker check reads every warning joined. boot() still records every warning. Mos chose a full pin over a filter: it also catches a second tracker line, a reordering, and config.mjs:102's context warning. I proposed a startsWith('access file') filter; Sage found it would hide that :102 warning.
  • My miss: B1-B3 were new and had not run before the gate.

Runs

Run 1, 2026-10-23 04:57Z (sage-3187 CLEAR), head 685ff053b, manifest 0b3f9c9d…94f3

Before the run, the branch was rebased from 253346e3c onto c902a767d. git range-diff -s showed all 6 commits "=", and the stable patch-ids matched. The head was pushed as launchpad/row294-on-c902a767d 024499fc2. TMPDIR was under /mnt/storage/scratch/tmp. npm ci --ignore-scripts ran at the root only.

Step Result Time Limit
webui suite 365 tests: 362 pass, 2 fail, 1 skip 55.8 s 600 s

The driver stopped at the first red, so the cli, bus, scripts and mutants did not run. The slot was returned (launchpad-0060).

  • browser.test.mjs:22, an old test, pins index.html's script list. 294 adds /federation.js at index 2. It went to Mos through Sage (sage-3192); I made no change before the ruling.
  • G5:262, a new test, failed 22 !== 0: the broker writes one action.refused per refusal. This was placed as a test defect under mos-1272 and accepted by Sage (sage-3194). It is fixed in round 2.
  • The skip was the editor rebuild check (editor-provenance.test.mjs:162), which needs esbuild 0.28.2 from packages/webui's own lockfile. An environment fix: the next run also runs npm ci --ignore-scripts in packages/webui (sage-3194).
  • The last landed whole-webui gate skipped the same test: row 264a's run, webui 345 pass of 346 with 1 skipped, "the esbuild 0.28.1 editor rebuild" (gizmoduck queue-264 BUILD.md:516-518, DEFERRED 264a N2). Row 270's gate ran only webui pinned-copy.test. The fresh 294 gate still runs npm ci in packages/webui first, so the test should run here. If it skips again, the run names it and says why.

Run 2, 2026-10-23 05:33Z (sage-3199 CLEAR), head 66c181717, manifest 7ec7b1ea…315b

Duckworth's round 2 approve was recorded first (duckworth-0253, rev 3455, comment 31029). Drivers and logs are in run2/. TMPDIR was under /mnt/storage/scratch/tmp.

Step Result Time Limit
npm ci --ignore-scripts, root ok, esbuild 0.28.1 2.2 s 900 s
npm ci --ignore-scripts, packages/webui ok, esbuild 0.28.2 1.2 s 900 s
webui suite 365 tests: 364 pass, 1 fail, 0 skipped 58.2 s 600 s

The driver stopped at the first red. The slot was returned (launchpad-0070), and I made no edit.

  • The editor rebuild check ran and passed, and browser.test.mjs passed with the round 2 pin.
  • G5:269 failed 0 !== 22, "one event per refused verb". Every verb was still refused with access-refused. Placed as a test defect (the NULL baseline, round 3 above); as the row's second red it went to Mos through Sage (sage-3211) before the fix runs.

Run 3, 2026-10-23 05:55Z (sage-3231 CLEAR), head f26584089, manifest cb1648f3…78de

Duckworth's round 3 approve was recorded first (duckworth-0255b, rev 3462, c31045). The drivers, summary, webui log and the cli failure section are in run3/. TMPDIR was under /mnt/storage/scratch/tmp.

Step Result Time Limit
npm ci --ignore-scripts, root ok 2.2 s 900 s
npm ci --ignore-scripts, packages/webui ok 2.2 s 900 s
webui suite 365 tests: 365 pass, 0 fail, 0 skipped 58.2 s 600 s
cli suite 909 tests: 889 pass, 20 fail, 0 skipped 541.5 s 2100 s

The driver stopped at the cli suite, so the bus suite, the scripts and the mutants did not run. No failure was compactions-launch, so the :229 rerun rule did not apply. The slot was returned (launchpad-0076), and I made no edit before Mos's ruling (round 4 above).

  • stack-release.test.mjs, 17 failures: R1-R7, R9-R12, R14, R15, row 233's --backup last test, and row196's rehearsal test at :2586 with its socket copy and legacy copy subtests. Each rehearsal failed at scratch-config: "Cannot find module …/packages/cli/src/access.mjs imported from …/packages/cli/src/config.mjs". My launchpad-0076 put the two subtests under row 233; they are row196's.
  • access-boot.test.mjs B1, B2 and B3: warnings also held the fixture's tracker line.

Run 4, 2026-10-23 06:20Z (sage-3244 CLEAR, sage-3248 go), head 8616b9a4f, manifest a1ff5d67…9b99

Duckworth's round 4 approve was recorded first (duckworth-0257b, rev 3469, c31053). The run started at 06:20:53Z, on duckworth-0257b's relay of sage-3247; sage-3248 arrived seconds later with the same go. TMPDIR was /mnt/storage/scratch/tmp/gd3JQ8r. Receipts are in run4/: summary.txt, the drivers, suite-totals.txt (the three suites' node --test totals; the whole logs stay in scratch), every scripts/test-*.sh log, and each mutant's diff and TAP output with mutants.json.

Step Result Time Limit
npm ci --ignore-scripts, root ok 1.9 s 900 s
npm ci --ignore-scripts, packages/webui ok 0.5 s 900 s
webui suite 365 tests: 365 pass, 0 fail, 0 skipped 57.9 s 600 s
cli suite 909 tests: 909 pass, 0 fail, 0 skipped 600.1 s 2100 s
bus suite 873 tests: 873 pass, 0 fail, 0 skipped 24.4 s 900 s
scripts/test-auth.sh ok, 15 passed 0.8 s 126 s
scripts/test-conductor.sh ok 6.8 s 145 s
scripts/test-config.sh ok 2.5 s 130 s
scripts/test-discord.sh ok 7.3 s 147 s
scripts/test-extension-package.sh ok 1.2 s 130 s
scripts/test-foundation.sh ok 39.1 s 269 s
scripts/test-land-work.sh ok 11.8 s 171 s
scripts/test-queue.sh ok 20.4 s 226 s
scripts/test-release.sh ok 2.7 s 157 s
scripts/test-task.sh ok, 91 passed; the two live provider cases skip by design (row 244), no Docker group skipped 13.9 s 231 s

No step was rerun, so the :229 rule was not used. The worktree was clean after the scripts and after every mutant restore.

Mutants, each its killing file alone under timeout -k 10 300, all killed (MUTANTS rc=0, 06:34:22Z):

# Seconds Red
M1 1.9 G4 only; G5 ok in the same run
M2 2.1 G3
M3 1.8 G6, and G7 (the page text changed under it)
M4 0.5 the F8 peer test, B1
M5 2.1 G2, G3
M6 2.0 G8
M7 0.3 B1, B3
M8 2.1 G1, G9
M9 0.4 B3
M10 2.0 G9
M11 2.0 G9

M1 record (Duckworth C1): G4's first failure is its first route, GET /api/bus/inbox: 502 {"error":"access-refused","message":"access-refused"}, expected 403 (run4/M1.tap). The failing assertion is granted-user.test.mjs:241, the loop's status check. The Mutants table above says :239; round 2's tables helper moved it down two lines. With the Console gate gone, the broker refuses the owner verb itself, and the Console answers 502 with the broker's code. G5, the bus refusing every owner verb on its own, is ok 5 in the same TAP.