morgana: row 295 r2 (dewey-0871): threadsState ends an expired grant's held claim from the cached read by access-expiry (B1, D7, M16, M11 claim row); access.reload scope as D8 (N1); bus README access.reload, revoked and revocation (N2); tests unrun

This commit is contained in:
jason.woltje committed 2026-10-22 22:27:25 -05:00
1 parent 527002f975
commit 44b778e09a
6 files changed
+80 -7

No files matched your search

+22 -1
View File
@@ -78,11 +78,12 @@ packages/bus/tests/access-revoke.test.mjs (B), the launcher file packages/cli/te
| 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 | L | the revoked branch drops the kill |
| 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.
@@ -116,3 +117,23 @@ the kill, a run that polls would exit 22 on not-holder and hide the mutant.
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.
+20 -3
View File
@@ -211,6 +211,7 @@ an operator inspect content and delivery evidence after an uncertain result.
| `review.verdict` | `{task_ref,request,verdict,body,decision?}`; action `review.verdict`. `verdict` is `approve` or `changes`. `request` must be a `review.requested` event on the task addressed to the caller's role (`request-not-found`), with no verdict yet (`verdict-exists`). Writes a `review.verdict` event `{task_ref,request,commit,verdict,message}` and sends the requesting role a REVIEW-RESULT in reply to the request's message. Returns `{verdict,message,commit}`. |
| `event.emit` | `{kind:'action.allowed',body:{action:'routine',target?},subject?}`; routine observations only. |
| `launch.revoke`, `launch.restore` | No args; outside-agent human CLI only. |
| `access.reload` | No args; the outside-agent human CLI only (row 295, #1801, FED R4): the tester and an agent get `human-required`, a reader `read-only`, a user `access-refused`. Reads the access file whatever its stamp and ends the lost claims of every business, since the file is shared, with `by` the human. Returns `{ended:[{thread,session,run}]}` for the caller's business only. `mosaic access grant` and `revoke` call it after the write. |
| `inbox` | No args; plain JSON projection scoped to capability's business. |
| `tasks` | Optional args, scoped to capability's business (row 89, #1583). None: every row of the tasks projection (below). `{task_ref}` alone: that one row, or `task-not-found`. Any of `{done,assignee,label,requirement,limit,after}`: `{tasks,total,next}`, the short form (below). An unknown arg or bad value refuses `invalid-request`. |
| `agents` | No args; each held role's latest claim row in the capability's business. Human and reader sessions also get `state`, `working`, `inputNeeded` and `stranded` (Agent state, below); a role session gets the claim rows only. |
@@ -305,8 +306,9 @@ Only a human principal matches a share: the business human, a reader session as
a user. An agent session matches none, `*` included, even when it is bound to the thread.
A reader session reads as the business human. A sharee can't call `session.list` on the
thread; it stays owner-only until row 193's share wiring. The check runs on every request against the
access document the broker reads then, so a removed, expired or unreadable grant or an
unshare ends reads on the next request. Only `thread.messages` admits a sharee; `thread.open`,
access document as of that request: the broker stats the file each time and reads it again only
when the stat changed (row 295), so a removed, expired or unreadable grant or an unshare ends
reads on the next request. Only `thread.messages` admits a sharee; `thread.open`,
`thread.send` and the share verbs refuse it `thread-not-owned`. `trail` and `replies` show a
user's thread messages to the owner and readers while the check holds. The tester can't call
the share verbs (`tester-refused`), and a viewer is refused them `access-refused`; a viewer
@@ -316,7 +318,22 @@ reads a thread shared with it. Tests: `tests/thread-shares.test.mjs`.
Per thread it returns `{thread,instance,pending,unread,lastMessage,lastOpen,lastClaim,holder}`:
messages to the instance not yet delivered, messages delivered to the current holder and
not yet read, the latest message, open and claim times, and the holding run or `null`. The
launcher reads it to start and stop thread sessions (`packages/cli/README.md`).
launcher reads it to start and stop thread sessions (`packages/cli/README.md`). A row adds
`revoked: true` when its thread's user owner has lost the grant (below); the launcher never
starts such a thread and sends a running one SIGTERM.
Revocation (row 295, #1801, FED R4, federation design 3.7). A user thread is lost when no grant
in the access file covers it at collaborator level, so a downgrade to viewer is a loss. One
transaction ends the held claim of every session of every lost thread, op `revoke`, reason
`access-revoked`; the run's next bus call refuses `not-holder`, and a new run of the thread
refuses `thread-revoked` at `bindLaunch` and at `role.claim`. A rebind of an earlier host's run is
allowed, so the launcher's recovery can end it. Four triggers end the claims, each with its `by`:
the runtime's timer, every 30 s (`access.intervalMs` sets it in process, as `librarySync.intervalMs`
does), which reads the file whatever its stamp (`access-timer`); a user's request or a
`threadsState` call after the file's stat changed (`access-change`); a `threadsState` row that is
lost with its claim still held, as when a grant expired with no write, from the same read
(`access-expiry`); and `access.reload` (the human). The timer calls `accessSweep()`, a trusted
broker method, not a verb. Tests: `tests/access-revoke.test.mjs`.
`threadIdle({business,thread,run})` is the launcher's idle stop, also trusted and not a
verb. It rechecks the thread and releases the run's claim in one call, and returns
+10 -2
View File
@@ -1094,13 +1094,21 @@ export class Broker {
// doesn't keep the holder from idling out.
// FED R4 (design 3.7): a row adds revoked: true for a thread the access file no longer covers, as
// of this call, so a launcher restart never starts one. A changed file also ends its lost runs here.
// A grant that expired with no change to the file (Dewey's B1 in dewey-0871) leaves a revoked row
// whose claim the stamp check didn't end: that row ends the lost claims from the same read, by
// access-expiry, before the launcher sees it, so the run's end is never a release.
threadsState(business) {
this.#business(identifier(business));
this.#revokeLost('access-change');
const doc = this.#accessSeen?.doc ?? null;
const now = new Date();
return this.#threadRows('t.business=?', business).map(({ owner, project, ...r }) =>
(this.#revoked({ business, owner, project, instance: r.instance }, doc, now) ? { ...r, revoked: true } : r));
const lost = (r, owner, project) => this.#revoked({ business, owner, project, instance: r.instance }, doc, now);
let rows = this.#threadRows('t.business=?', business);
if (rows.some(({ owner, project, ...r }) => r.holder !== null && lost(r, owner, project))) {
this.#revokeLost('access-expiry', true, doc);
rows = this.#threadRows('t.business=?', business);
}
return rows.map(({ owner, project, ...r }) => (lost(r, owner, project) ? { ...r, revoked: true } : r));
}
// Row 121 round 2 (#1615, Dewey's F3 in 28591): the launcher's idle stop. The tick decides
// from a threadsState snapshot, and a message the run took after it would be delivered to a
+18
View File
@@ -187,6 +187,24 @@ test('R4: threadsState marks revoked: true on the lost user\'s thread only, a pe
assert.equal(Object.hasOwn(row(A), 'owner'), false, 'the row keeps its old fields');
});
// Dewey's B1 (dewey-0871): the launcher ticks faster than the timer, so a grant that expires with no
// write must not reach it as revoked: true with the claim still held.
test('R4: threadsState after an expiry with no write and no sweep ends the claim before it marks the row revoked', (t) => {
t.mock.timers.enable({ apis: ['Date'], now: Date.parse('2026-10-23T00:00:00Z') });
const { b, code, claim, store, A, B } = setup(t, { grants: [grant({ expires: '2026-10-23T00:01:00Z' }), grant({ user: 'bob' })] });
const row = (id) => b.threadsState('demo').find((x) => x.thread === id);
assert.equal(row(A).revoked, undefined, 'live');
t.mock.timers.tick(60_001);
const a = row(A);
assert.deepEqual([a.revoked, a.holder], [true, null]);
assert.deepEqual(claim('a-run'), { op: 'revoke', reason: 'access-revoked', by: 'access-expiry' });
assert.equal(code('a', 'message.receive'), 'not-holder');
assert.deepEqual([row(B).holder, claim('b-run').op], ['b-run', 'claim']);
// Ended once: the next call writes no second revoke.
row(A);
assert.equal(store.get("SELECT count(*) AS n FROM thread_claims WHERE holder_run='a-run' AND op='revoke'").n, 1);
});
test('R4: a new run of a revoked thread refuses thread-revoked at bind and at claim; a rebind is allowed', (t) => {
const { b, call, edit, record, broker, A, B } = setup(t, { runs: false });
const late = b.bindLaunch(record('late', A));
+1 -1
View File
@@ -702,7 +702,7 @@ export function createLauncher({
if (clearing.has(t.session)) continue;
const s = registry.running(b.id, t.instance, t.thread, t.session);
// FED R4 (#1801, design 3.7): the broker marks a thread whose user owner lost the grant, and has
// ended its claim. A revoked thread never starts, and a running one gets the idle stop's
// ended its claim before it returns the row, whether the file changed or a grant expired. A revoked thread never starts, and a running one gets the idle stop's
// start-time-checked SIGTERM at once, with no threadIdle: there is no claim left to release.
if (t.revoked) {
if (s && !stopping.has(s.run)) {
@@ -17,6 +17,7 @@ import { fileURLToPath } from "node:url";
import { launchDoc } from "../../business/tests/helpers.mjs";
import { BusError } from "../../bus/src/broker.mjs";
import { startBroker } from "../../bus/src/runtime.mjs";
import { Store } from "../../bus/src/store.mjs";
import { gitEnv } from "../../seat/src/checkout.mjs";
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
import { launchLogFile, readSessions, Registry, SESSION_CREDENTIALS } from "../../seat/src/session.mjs";
@@ -199,6 +200,14 @@ test("REQ-LAUNCH-1 R4: a running thread's run gets SIGTERM at the first tick aft
// More ticks start nothing.
for (let i = 0; i < 3; i++) await host.launcher.tick();
assert.equal(events(f, "launch").length, 1);
// Dewey's B1: the broker ended the claim before the launcher's kill, so the run's end didn't release it.
await host.close();
const store = new Store(f.dataRoot);
t.after(() => store.close());
assert.deepEqual(
store.get("SELECT op, reason, by FROM thread_claims WHERE holder_run=? ORDER BY seq DESC LIMIT 1", launch.run),
{ op: "revoke", reason: "access-revoked", by: "access-change" },
);
});
// sage-3139, D1: the bind refusal skips a rebind, so recover() ends the earlier host's run.