Files

22 KiB

Row 252 build notes: Console inbox Approve and Deny for grant requests

Seat: Scrooge. Reviewer: Webby. Issue #1753, Vikunja #435 (J2). Brief: docs/plans/2026-10-21_console-inbox-approve.md (blob b41676d06). Design: Gander's DESIGN.md at d218e14bd, approved by Webby at 5d8f0f4b5 (webby-0082) with manifest checks N1-N5. Base: aca16404c on branch scrooge/row252. Rebase onto the row 253 tip under a new branch name once 253 lands.

Status

  • Code, tests and docs are written. No test has been run yet, because Gander holds the gate slot for row 253 run 5 and Sage ruled no node until a CLEAR.
  • Everything in the Results section is empty until that run. Nothing below claims a pass.

Q188

No agent creates a grant. Approve and Deny are human-only Console actions.

  • Only a console cap reaches decision.preview.console and decision.resolve.console. A console cap reaches nothing else (console-decide J1, Q188).
  • The socket refuses a console proof for any other verb before the proof check (server.mjs).
  • A console cap binds only through authenticateConsole, which needs the console proof (section 2): the same-tree console-cli.mjs wrapper, its own nonce, a direct same-tree serve parent, and a configured owner subject from the spawn environment.
  • recordEvent keeps human.input reserved, so no agent writes the record that lets a console close start the executor (console-decide recordEvent keeps human.input reserved).
  • A via console close without its matching human.input approves nothing (console-decide J14).

Manifest checks N1-N5

  • N1. Every console.sso subject is an owner of the business. Name is display only.
    • cli/src/config.mjs carries {provider, iss, sub} per subject into the boot as consoleOwners. No sso means no key.
    • bus/src/runtime.mjs validates the list (array, at most 512, exactly three non-empty strings each) and passes it to verifyPrincipal.
    • Tests: cli config.test Row 252 (Webby N1) and principal.test.
  • N2. human.input is written in the same transaction, before #approvedGrant. The executor starts once, and a deny starts none.
    • Tests: console-decide N2, J13 checks the order by seq and that the grant runs once. J15 checks that a deny starts nothing.
  • N3. The close notice comes from from_run 'human-console', and public/bus.js shows "via console". landing and #protectedEnd stay CLI-only.
    • Tests: console-decide N2, J13 checks the notice. decide-browser J17 checks the close-elsewhere line.
  • N4. decision.resolve is not a Console verb.
    • CONSOLE_VERBS is exactly the two console verbs. The webui serve BUS_VERBS read list has no decision verb, and webui cli.mjs has no decision. string. consoleCall refuses anything outside CONSOLE_VERBS.
    • Tests: console-decide N4, and decide-sso N4, which hits /api/bus/decision.* for 404 and covers the cli.mjs scan and the consoleCall refusals.
  • N5. readProcess keeps the five MOSAIC_BUS_CONSOLE_* keys in the env allowlist (bus/src/human.mjs). The human proof and its spawn are unchanged.

Design choices and tradeoffs

  • Webui imports from the broker. webui/src/bus.mjs imports CONSOLE_VERBS from bus/src/broker.mjs, which loads the broker module into the Console at import.
    • I chose one source for the verb list over a second copy that could drift.
    • If Webby prefers the copy, the alternative is a literal list plus a test pinning it equal to CONSOLE_VERBS.
  • The Console transport shares humanCall's spawn. consoleCall and humanCall share it as transport(), so webui/src/bus.mjs changed. The busCaller business default keeps || (the old falsy semantics), not ??.
  • Confirmations are held in memory in the serve process. Their lifetime is 2 minutes, they are single use, and they bind the session, decision, choice and digest. The cap is a limit that never drops a live confirmation.
    • A restart loses all held confirmations. The user clicks again and gets a new preview. Nothing is written until the resolve.
  • No retry anywhere. A resolve whose bus reply is lost answers outcome-unknown, the confirmation is spent, and the page says "Nothing was sent again."

Old tests changed (for Mos, per mos-1272)

Mos ruled both changes allowed in mos-1321 (17:12:28Z), each with one condition.

  • shell.test condition: reword the comment that opened "No Resolve control", since it is false once the row lands. It now says row 252's Approve and Deny is the one decision control, that its labels come from ACTIONS, and that the doesNotMatch keeps any literal label out of the markup. Webby checks the wording in review.

  • channel-proof condition: a header comment is not enough, because human.mjs changes what readProcess keeps. In the review, Webby reads the human.mjs and webui bus.mjs diffs and states two things:

    • the human proof's checks are unchanged
    • only the console verifier reads the five MOSAIC_BUS_CONSOLE_* keys, and each case is covered by a named test in console-decide or decide-sso
  • Second row to land (252 or 239): it recomputes the webui bus.mjs hash, adds its own header line, and its reviewer re-reads bus.mjs from the base to the combined tip.

  • webui/tests/shell.test.mjs, write-boundary test. Allowed under mos-1321, with the comment reworded.

    • The writes list gains the new fetch(url, ...) in decidePost. It is first in file order (bus.js:362).
    • The method: count goes from 7 to 8.
    • A new assertion pins decidePost's callers to url plus the two literal /api/decisions/${enc(a.id)}/... routes.
    • No existing entry was loosened.
  • bus/tests/channel-proof.test.mjs, sha256 re-pins with a header comment:

    • bus/src/human.mjs (N5 env keys) to bb13c146…c885.
    • webui/src/bus.mjs (consoleCall and transport()) to 701f009e…5272.
    • Any later edit to either file needs a re-pin.
  • bus/tests/principal.test.mjs, webui/tests/tester-listener.test.mjs and cli/tests/config.test.mjs have new tests appended only.

Overlap with row 239 (Honker, b0c28366f, sage-2835)

  • Row 239 also re-pins channel-proof.test.mjs:
    • packages/webui/src/bus.mjs, to 1eba9774…e5cf. That is the pin both rows change.
    • packages/cli/src/transport.mjs, which is 239 only.
    • packages/bus/src/human.mjs is a 252-only pin.
  • The two rows also share source files: bus/src/broker.mjs, bus/src/runtime.mjs, webui/src/bus.mjs and webui/src/serve.mjs.
  • Whichever row lands second rebases onto the first and recomputes the webui bus.mjs pin from the merged file. Both header comments are kept. It then reruns every touched test file whole.
  • Mos needs to see that re-pin (re-pins need Mos).

Mutant plan (design section 6, J1-J17)

Each mutant is one edit to the named source. Run the killing test, then restore and compare the sha256 against the pre-mutant hash with git status clean apart from the build. Kill rules are written before the runs.

J Mutant Source Expected kill
J1 eligibility from the request, #consoleResolution returns non-null for any decision broker.mjs console-decide J1, J2 (ineligible)
J2 hardcode approve = yes, deny = no broker.mjs console-decide J2 (go/stop, deny listed first)
J3 owner from name or header, skip the owner match in verifyPrincipal principal.mjs principal.test console requires…
J4 skip the serve-parent walk principal.mjs principal.test console requires…
J5 accept the human nonce namespace principal.mjs principal.test console requires…
J6 reuse the session from the confirmation, skip the live session check at resolve decision-http.mjs decision-http session that ends, decide-sso listener gates (new session)
J7 keep the console cap bound after the call (skip release for kind console) server.mjs open: no new test pins release for a console proof. Before the run, check whether an existing server test kills it. If not, add one and name it in the report.
J8 skip the same-origin check decision-http.mjs decision-http gates, decide-sso listener gates
J9 resolve without a confirmation token decision-http.mjs decision-http a confirmation then a resolve, bad body
J10 drop the choice, decision or session binding decision-http.mjs decision-http binds the session, decision and choice
J11 skip the digest, closed or business check broker.mjs console-decide J11, N2, J13 (second answer closed)
J12 do not spend the confirmation decision-http.mjs decision-http a second resolve refuses, confirmations… one use
J13 write via cli, or omit the subject in human.input broker.mjs console-decide N2, J13
J14 #approvedGrant accepts via console without the human.input broker.mjs console-decide J14
J15 start the executor on any resolved choice broker.mjs console-decide J15 and J2 (stop starts nothing)
J16 decide true without sso or send serve.mjs, public/bus.js decide-sso J16, decide-browser J16
J17 retry on outcome-unknown or auto-submit after preview public/bus.js decide-browser J17 (post count stays at 7)

Manifest

Source:

  • packages/bus/src/broker.mjs
  • packages/bus/src/client.mjs
  • packages/bus/src/console-cli.mjs (new)
  • packages/bus/src/human.mjs
  • packages/bus/src/principal.mjs
  • packages/bus/src/runtime.mjs
  • packages/bus/src/server.mjs
  • packages/cli/src/config.mjs
  • packages/webui/src/bus.mjs
  • packages/webui/src/cli.mjs
  • packages/webui/src/decision-http.mjs (new)
  • packages/webui/src/public/bus.js
  • packages/webui/src/serve.mjs

Tests:

  • packages/bus/tests/channel-proof.test.mjs (re-pins)
  • packages/bus/tests/console-decide.test.mjs (new)
  • packages/bus/tests/principal.test.mjs
  • packages/cli/tests/config.test.mjs
  • packages/webui/tests/decide-browser.test.mjs (new)
  • packages/webui/tests/decide-sso.test.mjs (new)
  • packages/webui/tests/decision-http.test.mjs (new)
  • packages/webui/tests/shell.test.mjs (old test changed, see above)
  • packages/webui/tests/tester-listener.test.mjs

Docs:

  • packages/webui/README.md
  • agents/scrooge/work/queue-252/BUILD.md (this file)

Results

Not run. At the first CLEAR:

  1. Run each touched test file whole, with TMPDIR under /mnt/storage/scratch/tmp.
  2. Run the bus, webui and cli suites.
  3. Run the mutants above, then the drift check against refactor.

Rebase onto fe4ee8c7b (sage-3482, 2026-10-23)

The old base aca16404c had drifted. Since then refactor changed 8 of the row's 24 paths, across 15 commits. I made a new branch, scrooge/row252-fe4ee8c7b, so scrooge/row252 22cc74645 is untouched and nothing was force-pushed. I rebased with local git, -c core.hooksPath=/dev/null, and no stash.

Commit map:

Conflicts, all in 4bd273bce, and how each was resolved:

  1. packages/bus/src/broker.mjs, verb lists. On the base, row 206 added the share verbs to USER_VERBS, and row 275 added VIEWER_VERBS, userLevel and BOARD_VERBS and removed the USER_LEVEL const. I kept the base's lists and dropped USER_LEVEL, because nothing on the base reads it. CONSOLE_VERBS goes in after USER_VERBS with its comment unchanged. The request() gate (!!s.console !== CONSOLE_VERBS.includes(verb)) auto-merged after the FED R4 access sweep and the user check. Its order relative to the user gate is the same as on the old base.
  2. packages/bus/src/runtime.mjs, startBroker. FED R4 (#1801) added access.intervalMs to the access check. I kept the consoleOwners validation and the frozen owners first, as in the row, and then the base's access check with intervalMs.
  3. packages/cli/src/config.mjs. FED R6 (#1800) added accessFor and config.access. I kept both: accessFor and then consoleOwners, and in bootConfig config.access and then config.consoleOwners. process.mjs passes the boot config to startBroker whole, so both keys reach it.
  4. packages/webui/src/cli.mjs. FED R6 moved the owner's bus options into consoleBus() (console-bus.mjs) and added userCaller and userOptions. I kept the base's form, added consoleCall to the bus.mjs import, and added decisionWrite to the declarations. I kept the row's if (ssoOn && sendOn) decisionWrite = consoleCall(...) after the consoleBus() call, and pass decisionWrite to startServer next to userOptions and federation.
  5. packages/webui/src/serve.mjs. FED R6 added granted-user sessions: userRoute gates them, and userOptions rebinds the options per request.
    • The signature now has both decisionWrite and userOptions/federation.
    • The session hunk keeps the base's signer and granted-user block. ssoSession is set only for a session with no user, which is the owner's. A granted user is refused at userRoute before the decision route anyway, because /api/decisions/* is not on userRoute's list. Under the new base this keeps the design section 2 rule that only the owner answers a decision.
    • /api/config keeps the base's user and federation keys. decide gains !granted, matching how the base gates edit, so a granted user's config never offers Approve and Deny.
    • The decisionWrite startup check and createConfirmations() auto-merged unchanged.
  6. packages/bus/tests/channel-proof.test.mjs, webui bus.mjs pin. Row 301 (#1808, mos-1449) moved the user transport into bus.mjs and re-pinned it to 39fcb27c. The row's consoleCall auto-merged on top. I kept row 301's comment and re-pinned to the merged blob's sha256, 604092cb706b152d458f299ff1071f6cd488774629012f96b672f7e9a16c0b2c. The human.mjs pin (bb13c146) auto-merged and matches the blob at the new tip. The row's existing header comment (Webby N5) still describes why bus.mjs is re-pinned. humanCall and transport() are unchanged.

Auto-merged without conflict: webui bus.mjs (consoleCall next to row 301's userCaller), webui README.md, bus client.mjs, human.mjs, principal.mjs, server.mjs and public/bus.js.

Two new-base reads I checked:

  • granted-user.test.mjs reads /api/config key by key and has no deepEqual on the whole object, so the extra decide key doesn't break it.
  • No other test pins one of the row's source files by sha256, apart from channel-proof.

Not run: no test, node or syntax check during this step. Sage's next CLEAR covers the push, the whole webui, bus and cli suites, every scripts/test-*.sh, and J1-J17. Plugins must show 0 failures (mos-1456/1462). J7 still needs a killer test. The serve.mjs !granted and ssoSession lines are new since the build, so they get mutants too. I'll add them to the J list at the run.

Runs (sage-3488 CLEAR, 2026-10-23)

Gate tree: detached worktree of 3e6de9807 with the canonical node_modules linked, so MOSAIC_REQUIRE_EDITOR_BUILD=1 is set (mos-1453). TMPDIR comes from mktemp under /mnt/storage/scratch/tmp. Each step runs under timeout -k 10 (mos-1275):

Step Limit (s)
pkg bus, cli, plugins, webui, business (node --test tests/*.test.mjs) 1800 each
test-auth / conductor / config / discord / extension-package 126 / 145 / 130 / 147 / 130
test-foundation / land-work / queue / release / task 269 / 171 / 226 / 157 / 231
mutant single file 300, chat-browser 600

The script limits are 3x the longest runs recorded in queue-253 (receipt 3b91cfdaf).

First-run reds placed (mos-1272)

  • pkg-bus at 3e6de9807: 902 pass, 1 fail. The failure is N2, J13 in console-decide.test.mjs:180, a new test (0 human.input rows found, 1 expected). The cause is in the test, not the product. events() spread the parsed body after the row's kind, so an answer body's own kind: 'answer' overwrote 'human.input' and the filter could never match. Three more filters on the same field had the same defect, :250 and :290 (count of human.input must be 0) and :252 (an action.refused exists). The two zero counts passed vacuously. Fix: the row's kind is also kept as event, set after the body, and all four filters read event. The deepEqual at :181 now names event: 'human.input'. The file then ran whole at 11 pass, 0 fail.
  • J7 (open in the plan): no existing test bound a console proof over the socket. The new test J7: a console proof over the socket binds a cap for its one call and the reply releases it in console-decide.test.mjs serves a real socket with authenticateConsole, makes one console call that answers and one that refuses (decision-not-found), and checks that both caps are released (unauthenticated). J7 is applied as boundCap = kind === 'console' ? null : transient; in server.mjs, which skips every release path for a console cap (finish, the finally, and socket close). It ran at 11 pass, 1 fail, failing only the new J7 test. KILLED. The restored server.mjs matched its pre-mutant sha256.
  • decide-sso.test.mjs (new) ran 2 pass, 2 fail before any mutant:
    • J16: its { send: null } case reached setup, and startServer refused it, because a decision transport without --send never starts (serve.mjs guard). That case now asserts the refusal, and the { decisionWrite: null } case still checks the read-only routes and decide: false.
    • N4: a POST to /api/bus/decision.* gets row 74's preview refusal (403 read-only, for every write without a route of its own) before any verb lookup. POST now asserts that refusal and GET still asserts 404. Either way the transport sees no call.
    • The file then ran whole at 4 pass, 0 fail.
  • Old-test reds, sent to Sage before any fix (status 2):
    • cli access-boot.test.mjs R6 B1-B3 come from this row. The fixture's SSO provider has no issuer, so consoleOwners maps iss: undefined and startBroker refuses the owner list (invalid-host-request). The file is 4/0 on fe4ee8c7b.
    • cli compactions-launch.test.mjs REQ-THREAD-2 T3, 5 tests, each "timed out waiting". They also fail 0/5 on a fe4ee8c7b tree, so they are not this row's.
    • Correction (sage-3492): the compactions-launch reds were the environment, not a base red. The gate TMPDIR (<gate>/tmp) was long enough to break the launcher/bwrap paths. Alone in a short mktemp -d /mnt/storage/scratch/tmp/scrXXXXXX TMPDIR the file ran 5 pass, 0 fail, 0 skipped, and Sage's own gate ran it green. The file is unchanged.

Rulings and further placements (sage-3492 to sage-3496)

  • access-boot (Mos GO mos-1464, relayed in sage-3494): access-boot.test.mjs:17's authentik provider gains issuer: "https://idp.test", and nothing else changes. numstat against fe4ee8c7b reads 1 1. It is committed alone (b316e0a55). access.test.mjs:273 uses the same fixture but never reaches bootConfig, so it is untouched. The file ran 4 pass, 0 fail.
  • decide-browser.test.mjs (new) ran 0 pass, 3 fail before any mutant, then 1 pass, 2 fail after the first fix:
    • setup's UPDATE decisions SET question=? hit migration 3b's decisions_no_update trigger, because decisions is append-only. The test now writes its own open credential.mint grant decision, with the hostile question in the INSERT. It is the same two rows host-fixtures' grantDecision writes, and host-fixtures.mjs is unchanged.
    • page() visiting the URL it was already on was a same-document hash change, not a load. So J17's "closed elsewhere" step never re-read the bus, and the redirect test's second visit never redrew after signing out. page() now loads about:blank first, as editor-browser.test.mjs:216 does.
    • The file then ran whole at 3 pass, 0 fail (9e1601a20).
  • decide-sso J16 (sage-3492): the startServer refusal stays. The one config that starts without send also lacks a decision transport, since startServer refuses a transport without --send. That config, { decisionWrite: null, send: null }, is now in the J16 loop and gets decide: false and the read-only routes. Webby rules in the manifest round.
  • J19 killer (new test in decide-sso.test.mjs): an access document with one granted user (alice). The listener has --send, a decision transport and a user transport. The owner's config says decide: true. Alice's says user: true, send: true and decide: false. Her POSTs to both decision steps get 403 owner-only, and the transport sees no call.
  • J18 is equivalent alone, because a granted session never reaches decisionHTTP. At serve.mjs:694 a session with user !== undefined meets userRoute before the decision branch. userRoute admits only /api/chat* and GETs of /api/config and the user files, never /api/decisions/*, so the 403 owner-only comes before ssoSession is read. The session.user === undefined guard is a second layer behind that. The J19 test's owner-only POSTs pin the first layer.
  • J11c is equivalent. s.human !== b.human at broker.mjs:5604 can't hold for a console cap:
    • bindConsole (broker.mjs:1459) refuses record.human !== b.human with human-required.
    • Its cap carries human: b.human for its own business, and #cap freezes the session (:1094).
    • At resolve, b is this.#business(s.business) (:5324), the same business.
    • reloadBusiness refuses a document whose human differs (:517). So for any session with s.console set, the two humans are equal. Webby rules on it in the manifest round.
  • Mutants, part 2, on the tip test files (decide-sso 5/0, decide-browser 3/0 as the baseline). Every restore matched its pre-mutant sha256.
    • J16a: KILLED by decide-sso J16.
    • J16b: KILLED by decide-browser J16.
    • J17a: KILLED by decide-browser J17 and the redirect test.
    • J17b: KILLED by decide-browser J17.
    • J19: KILLED by decide-sso J19. granted-user stays 19/0 under it.
    • J18: SURVIVED decide-sso 5/0 and granted-user 19/0. It is equivalent alone, for the reason above.
  • Old webui stubs (Mos GO mos-1465, relayed in sage-3497): library-cli.test.mjs:17 and thread-claims.test.mjs:58 build a loader-hook stub of bus.mjs with no consoleCall export, and cli.mjs now imports it. Each busSource gains export const consoleCall=()=>{throw Error('consoleCall outside --sso')}; in the neighbours' throwing form, and nothing else changes. numstat against fe4ee8c7b is 1 1 for each. Alone they ran 1/0 and 4/0. No mos-1316 item for compactions-launch: the whole cli rerun is the evidence.