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)
#revoked(business, t): true whent.ownerisuser:<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 noaccessfunction covers nothing, as#covereddoes (fail closed). A thread the business human or the tester owns is never revoked.#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#revokednames. Returns the ended runs. The claim is ended, so the run's next bus call refusesnot-holder(F10) and a laterrole.claimfor it refusesrun-revoked.- Triggers (design 3.7):
- a trusted method
accessSweep(), not a socket verb, that runs#revokeLostfor each business withby: 'access-timer'. The runtime calls it on a 30 ssetInterval(unref'd, cleared in close()), only whenaccessis configured. access.reload, human-only (#human(s), so not the tester and not a reader), runs#revokeLostfors.businesswithby: s.humanand 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).
- a trusted method
threadsStaterows gainrevoked: true|false, from one access read per call.#threadRowsselectst.owner, t.projectfor it.threadIdleignores the field.- Launch check:
bindLaunchrefusesthread-revokedfor a new run (!old) of a revoked thread, the way!old && !harnessOnrefusesinvalid-harness, androle.claimrefusesthread-revokedtoo. 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)
- With
accessset: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.intervalMsfollowslibrarySync.intervalMs(C2-03a): in-process only.
Launcher (packages/cli/src/launcher.mjs)
- threadPass: a
revokedrow never starts. A revoked row with a running session, not already stopping, getsstopping.add, athread-revokedrecord event and the idle stop's start-time-checked SIGTERM at once, with nothreadIdlecall (the claim is already ended). - A bindLaunch refusal
thread-revokedis a start refusal, logged once per code as today.
CLI (packages/cli/src/access.mjs)
- grant and revoke call
access.reloadafter the write (project.list stays the human proof, since grant needs its rows for project names; D6 foruser remove). A refused reload after a good write: stdout still reports the write, stderr saysaccess.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). - 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;
#revokeLostruns 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 isunreadable:<code>).#accessDocreuses 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.revokedis 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 removedoesn'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
#revokedwith 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 seesrevoked: truewith 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
byon 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,
revokedin 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 passesaccessto 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
accesson 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 in9f29e1b2f(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 auser:alicethread 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;f652dfa5bhad projects['*']). The broker has no accessStamp here, so #accessDoc calls the function again on each read. -
setup(t, { access } = {})passesaccesstonew Broker. Only the trail test (:117 at9f29e1b2f, :126 now) passes it; every othersetup(t)givesaccess: undefined, which the constructor's default turns into null, as before. setup() already returnedproject. -
The trail test and the governance pin (:176) each add
const ref = {};before setup or fixture, and setref.project = project;once the project exists. The trail test also destructuresprojectfrom setup(). The governance pin passesaccess: aliceAccess(ref)tofixture(). -
Nothing reads access before
ref.projectis 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 Brokerunchanged. 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
f5256e677merged onto07802dc57: d68b4300d, treea86592ffc. The manifest has 12 paths (sha256 e94c9aeb9821ad1b) and checked clean. The full :1371 line (M8's anchor) appears once; a barefail('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.