Files

17 KiB

Row 295: FED R4 revocation (#1801)

Brief: docs/plans/2026-10-23_mvp-readiness-rows.md, "Brief: FED R4, revocation". Design: agents/gizmoduck/work/federation/DESIGN.md at b93f098f0 (r4.11), sections 3.7 and 7. Base: origin/refactor 253346e3c (rows 206, 271 and 275 are in it). Branch morgana/row295. Round 2 base: 80793d824 (row 294 landed as d3059c2c3), branch morgana/row295-r2 (sage-3256). Reviewer Dewey, gate owner Sage.

Row 193's viewer level landed with row 275 (3410df7c3), so it is on this base. The downgrade test and its mutant build here, not on a 193 candidate (Q177 not needed).

Plan

Broker (packages/bus/src/broker.mjs)

  1. #revoked(business, t): true when t.owner is user:<id> and no grant in one read of the access file covers the thread at collaborator level (covers(g, {business, project: t.project, instance: t.instance}, now, {level: 'collaborator'})). A read that throws, a null document or no access function covers nothing, as #covered does (fail closed). A thread the business human or the tester owns is never revoked.
  2. #revokeLost(business, by): one read of the file, then one transaction that ends, with #claimEnd(..., 'revoke', null, 'access-revoked', by), the held claim of every session of every user-owned thread #revoked names. Returns the ended runs. The claim is ended, so the run's next bus call refuses not-holder (F10) and a later role.claim for it refuses run-revoked.
  3. Triggers (design 3.7):
    • a trusted method accessSweep(), not a socket verb, that runs #revokeLost for each business with by: 'access-timer'. The runtime calls it on a 30 s setInterval (unref'd, cleared in close()), only when access is configured.
    • access.reload, human-only (#human(s), so not the tester and not a reader), runs #revokeLost for s.business with by: s.human and returns { ended: [runs] }.
    • every user request (s.user), any user's: #revokeLost(by: 'access-change') before the verb, once per stamp change (sage-3139 Q1, D2). threadsState sweeps the same way (D3).
  4. threadsState rows gain revoked: true|false, from one access read per call. #threadRows selects t.owner, t.project for it. threadIdle ignores the field.
  5. Launch check: bindLaunch refuses thread-revoked for a new run (!old) of a revoked thread, the way !old && !harnessOn refuses invalid-harness, and role.claim refuses thread-revoked too. A rebind (old, recover() at launcher.mjs:1174) is not refused, so recover() can still end the earlier host's run. bindLaunch comes after spawn and a refusal there SIGKILLs the child (launcher.mjs:608, :620), which covers a start racing the revocation; the claim check covers a run bound just before it.

Runtime (packages/bus/src/runtime.mjs)

  1. With access set: setInterval(() => broker.accessSweep(), access.intervalMs ?? 30_000), unref'd, cleared in close() and on a failed start. A sweep that throws logs one line to stderr and the next tick tries again. access.intervalMs follows librarySync.intervalMs (C2-03a): in-process only.

Launcher (packages/cli/src/launcher.mjs)

  1. threadPass: a revoked row never starts. A revoked row with a running session, not already stopping, gets stopping.add, a thread-revoked record event and the idle stop's start-time-checked SIGTERM at once, with no threadIdle call (the claim is already ended).
  2. A bindLaunch refusal thread-revoked is a start refusal, logged once per code as today.

CLI (packages/cli/src/access.mjs)

  1. grant and revoke call access.reload after the write (project.list stays the human proof, since grant needs its rows for project names; D6 for user remove). A refused reload after a good write: stdout still reports the write, stderr says access.reload refused: <code>; the bus's timer applies the change within 30 s, exit 0, because the file is written and a retry would hit revision-conflict (sage-3139 Q2).
  2. ACCESS_HELP keeps the provider-removal order; a test pins the sentence.

Tests (each with a mutant)

Timers: t.mock.timers drives setInterval and Date; no test sleeps 30 s. The bus file is packages/bus/tests/access-revoke.test.mjs (B), the launcher file packages/cli/tests/thread-revoke.test.mjs (L), and the CLI tests are appended to packages/cli/tests/access.test.mjs (C).

# Test File Mutant
M1 timer sweep after a hand edit ends only alice's run (by access-timer, reason access-revoked); its next call refuses not-holder; bob's and the human's runs stay B #revokeLost ends every user thread (drop the #revoked filter)
M2 an expiry with no write and no request ends the run at the next sweep; bob's request on the same stamp doesn't B accessSweep not forced (#revokeLost('access-timer'))
M3 a downgrade to viewer is a loss B level 'viewer' in #revoked
M4 bob's request after a hand edit ends alice's run, and nothing ends before it (sage-3139 Q1) B the per-request #revokeLost call removed
M5 one read per stamp change, none while unchanged B #accessDoc ignores the stamp
M6 access.reload ends at once for the human; tester, reader, agent refused human-required/read-only, user access-refused B drop #human(s) in access.reload
M7 threadsState marks revoked: true on the lost thread only, its pending message kept B threadsState never adds revoked
M8 bindLaunch and role.claim refuse thread-revoked; another broker's rebind is allowed B drop the bindLaunch check
M9 the runtime timer ends the run at 30 000 ms and not at 29 999; intervalMs is checked; close stops it B the timer callback never calls accessSweep
M10 pending message + launcher restart starts no run, logs no launch and no refused start L threadPass ignores revoked
M11 a running revoked thread's run gets SIGTERM, ends stopped exit 0, with no thread-idle; its last claim row is revoke/access-revoked/access-change, not a release (dewey-0871 B1) L the revoked branch drops the kill
M12 recover() rebinds an earlier host's run on a revoked thread and ends it host-lost (sage-3139 D1) L the bindLaunch check without !old
M13 grant and revoke call access.reload after the write; list and a refused write don't C drop the reload call
M14 a refused reload: exit 0, stdout as before, stderr names the code and the 30 s timer (sage-3139 Q2) C reload rethrows
M15 help keeps the provider-removal order with the reload sentence after it C drop the help sentence
M16 an expiry with no write and no sweep: threadsState ends the claim (revoke, access-revoked, by access-expiry) before it returns the row revoked with holder null; one revoke row only (dewey-0871 B1) B drop threadsState's #revokeLost('access-expiry', true, doc) call

M11 relies on the runner's poll interval (set to 600 s in L), so only the signal ends the run: without the kill, a run that polls would exit 22 on not-holder and hide the mutant.

Sage's rulings (sage-3139)

  • Q1 resolved, yes: a request from any user counts. The cost rule: a request only stats the file; #revokeLost runs only on a stat change and reuses the read R3's check makes. Tests M4 and M5.
  • Q2 resolved, exit 0: stderr names the refusal code and the 30 s timer, stdout reports the write as today. Help text next to the provider-removal order. Tests M14 and M15.
  • D1 accepted with a test (M12).
  • Base 253346e3c; rebase before the gate if broker.mjs or launcher.mjs change on refactor.

Decisions

  • D1: bindLaunch's revoked check skips a rebind. recover() binds the earlier host's run and then ends it; a refusal there would leave the run unended in the broker (launcher.mjs:1173-1179).
  • D2, the stamp: the runtime passes accessStamp, an lstat key (dev, ino, size, mtimeNs, ctimeNs, mode, uid; a failed stat is unreadable:<code>). #accessDoc reuses the last read while the stamp is unchanged, and the sweep runs once per stamp (#accessSwept). The timer and access.reload force a read, so an expiry with no write is caught by the timer. With no stamp function (the bus tests' old fixtures), every read reads, as R3 did, so user-principal.test's reads+2 pin holds: a request reads once and hands the document to both the sweep and the grants.
  • D3: threadsState also sweeps on a stamp change, so the launcher's tick sees the claim already ended when it gets revoked: true. revoked is added only when true, so end-launch.test's deepEqual of a threadsState row still holds.
  • D4: #threadLost (bindLaunch, role.claim) only reads. role.claim runs inside the verb's transaction and store.transaction refuses nesting, so the claim end waits for the next sweep.
  • D5: a session in a clear is skipped in threadPass before the revoked branch (clearPass owns it). Its successor's start goes through bindLaunch, which refuses thread-revoked, so no run starts. A drained run of a revoked thread already had its claim ended by the sweep.
  • D6: user remove doesn't call access.reload: it refuses while the user holds a grant, so it never removes coverage. The brief names grant and revoke.
  • D7 (Dewey's B1, dewey-0871): an expiry changes no stamp, so the stamp check skips the sweep while #revoked with the current time already marks the row. threadsState therefore ends the lost claims itself when a lost row still has a holder, forced, from the cached document (no read, so sage-3139's cost rule holds), by: 'access-expiry', and re-reads the rows. The launcher (2 s tick) then never sees revoked: true with a held claim, and the run's end is never a release. The request path is unchanged.
  • D8 (Dewey's N1): access.reload ends lost claims in every business, since the access file is shared, and writes the calling business's human as by on each. It returns only the caller's business's ended runs, so one business's human learns nothing of another's threads.

Review round 1 (dewey-0871, CHANGES on 527002f97)

  • B1 fixed: D7, test M16, M11 assertion. N1: D8. N2: packages/bus/README.md gets the access.reload row, revoked in threadsState, a revocation paragraph (timer, accessSweep, access.intervalMs, the four triggers) and the stamp-cached wording.
  • N3, at the rebase on 294 (c866a8a69): 294's accessFor passes access to the live boot, so the timer and request sweep go live with both rows; fix 294's "reads the file on every user request" comment to stamp-cached, and check its ACCESS_HELP edit against M15's ordering assertion.
  • N4: the touched whole files (user-principal, viewer, thread-shares) run whole in the bus suite; a first-run red goes per mos-1272.

Rebase onto 80793d824 (sage-3256)

  • Row 294 landed (d3059c2c3). Of 295's manifest only packages/cli/src/access.mjs drifted; the rebase applied with no conflict and keeps 294's lines (the federation.peers help and comment, accessContextFor's peers). The reload sentence still sits right after "so do that second.", so M15's order assertion holds; 294 changed only lines after it.
  • N3: packages/cli/src/config.mjs accessFor's comment now says the broker reads on a user request that finds the stat changed and on the 30 s timer. config.mjs joins the manifest (comment only).
  • 294 put access on the live boot, so the timer and the request sweep run in its new tests: packages/cli/tests/access-boot.test.mjs and packages/webui/tests/granted-user.test.mjs run whole at the gate with the touched files. stack-release.test's copy list carries access.mjs, which adds no import here.

Gate on 07802dc57 (sage-3318)

  • First run, merge b42c591f8 (tree 0626929e5): access-revoke.test went 4 pass, 6 fail. These were first-run reds in the row's new tests, placed per mos-1272 in 9f29e1b2f (sage-3326, Dewey dewey-0905). Five claim-row compares failed because store rows have a null prototype, so they're now spread; thread-revoke:207 had the same compare and got the same fix. The demo business switches on the pi harness, which startBroker's harnessOn needs. In thread-revoke, Alice binds at her first call, because a second host's bind after the hand edit refuses access-revoked.
  • Second run, merge 38591cf96 (tree f719c0dc5): 12 whole files green. suite-bus stopped on 2 old-test reds in packages/bus/tests/message-filter.test.mjs. ":117 trail on a decision ..." and ":165 governance pin ..." each threw thread-revoked from bindLaunch's new check. Those fixtures write a user:alice thread with no access function, and with no document no grant covers it. Mos ruled option (a) in mos-1436: old-test setup only.

Assertion-preservation record (mos-1436 conditions 7-9)

  • message-filter.test.mjs gains aliceAccess(ref). It returns the access function, whose single grant is user alice, business demo, projects [ref.project], instances ['assistant'] (the thread's instance), collaborator level. That's the narrowest grant (Dewey's F1, sage-3333; f652dfa5b had projects ['*']). The broker has no accessStamp here, so #accessDoc calls the function again on each read.

  • setup(t, { access } = {}) passes access to new Broker. Only the trail test (:117 at 9f29e1b2f, :126 now) passes it; every other setup(t) gives access: undefined, which the constructor's default turns into null, as before. setup() already returned project.

  • The trail test and the governance pin (:176) each add const ref = {}; before setup or fixture, and set ref.project = project; once the project exists. The trail test also destructures project from setup(). The governance pin passes access: aliceAccess(ref) to fixture().

  • Nothing reads access before ref.project is set. The broker reads it on a user's request (request()), in threadsState, in #covered, and in bindLaunch/role.claim on a thread. setup() binds only the human, a reader and the tester and makes the project. fixture() binds coder and reviewer runs with no thread. Neither binds or claims as alice, and neither calls threadsState.

  • No other line of either test changes. The bindLaunch lines (:119, :169 at 9f29e1b2f; :130, :182 now) and every assertion are byte-identical. The removed lines are only function setup(t) {, the two setup/fixture calls and the two helper lines below.

  • spec-fixtures.mjs (11 importers) gains one optional parameter with a null default, which goes to new Broker unchanged. No other caller changes.

    -export async function fixture(t, { adapter = true, roles = {}, arbiters = { technical: 'reviewer', delivery: 'reviewer' } } = {}) {
    +export async function fixture(t, { adapter = true, roles = {}, arbiters = { technical: 'reviewer', delivery: 'reviewer' }, access = null } = {}) {
    -  const broker = new Broker({ store, businesses: { demo: business }, project: adapter ? {
    +  const broker = new Broker({ store, businesses: { demo: business }, access, project: adapter ? {
    
  • Condition 4: M8 is the mutant. It deletes the broker.mjs:1371 thread-revoked check in bindLaunch, and its kill test is access-revoke's bind/claim refusal test. The red-first run on 38591cf96 (both tests red), the green run and M8 run on Sage's CLEAR, followed by a full gate restart on the new merge with a new manifest.

Third run: merge d68b4300d (sage-3338)

  • Candidate f5256e677 merged onto 07802dc57: d68b4300d, tree a86592ffc. The manifest has 12 paths (sha256 e94c9aeb9821ad1b) and checked clean. The full :1371 line (M8's anchor) appears once; a bare fail('thread-revoked') appears twice (:1371 and role.claim).
  • Red first on 38591cf96: message-filter.test went 4 pass, 2 fail, both thread-revoked. Green on d68b4300d: 6 pass, 0 fail. M8 turned "R4: a new run of a revoked thread refuses thread-revoked at bind and at claim; a rebind is allowed" red.
  • Whole files, all green: bus access-revoke 10, message-filter 6, user-principal 22, viewer 7, thread-shares 16, end-launch 7, session-clear 19; cli thread-revoke 3, access 34, access-boot 4, thread-launch 11, session-clear 5; webui granted-user 19.
  • Suites: bus 886/0, business 231/0, cli 924/0 (631 s), harness 151/0.
  • Scripts: each scripts/test-*.sh ran under timeout -k 10 300 (mos-1275). The longest run on record was foundation at 39 s on the row 277 gate, so 300 s is over 3x that. Every script exited 0; the longest here was foundation at 36 s. test-queue skipped 1 (the gate worktree isn't the queue's canonical root) and test-task skipped 2 (live provider cases, row 244). Nothing else skipped.
  • M1-M16 each turned their named test red. M11 failed on an assertion, not a timeout. The run finished at 09:57:39Z with the worktree restored at d68b4300d.

N items

  • N1 (morgana-0167, sage-3318): a revoked run that calls the bus after its claim ends and before the launcher's SIGTERM gets not-holder and exits 22, not stopped (the same shape as #1809). 302's launcher-released path doesn't cover it. Sage decides after 295 lands whether this becomes a row.