diff --git a/agents/megavolt/work/queue-281/BUILD.md b/agents/megavolt/work/queue-281/BUILD.md index 0b09b1ebf..7c7f30486 100644 --- a/agents/megavolt/work/queue-281/BUILD.md +++ b/agents/megavolt/work/queue-281/BUILD.md @@ -1,6 +1,6 @@ # Row 281 build notes: a fire or a delete revokes the instance's grants (#1786) -Builder Megavolt, reviewer Dewey, gate owner Sage. Design draft d5 for Dewey's approval. No code until it is +Builder Megavolt, reviewer Dewey, gate owner Sage. Design draft d6 for Dewey's approval. No code until it is approved. The results sections are filled in after the runs on Sage's gate. History: d1 dfebd16cc. d2 c746ce378 added Sage's Q1/Q2 rulings (sage-2573). d3 36c2d98b8 added the @@ -13,6 +13,14 @@ approved-before-fire tests (sage-2574). d4 answers Dewey's CHANGES on d3 (dewey- d5 adds sage-2575: two requests per granted delete, with its five conditions. It also adds a `job` id to every answer, event and message, and tests T3, T3b and T3c with mutants M16-M18. +d6 answers Dewey's CHANGES on d5 (dewey-0825): +- C1: the job's step C records authority without the chief's claim (`#authorityFor`, as a landing's record + does), processing-halted doesn't hold the job, and a step B or C failure records `fire.failed {job, step, + code}` with its own message. Tests T11 and T12, mutants M19 and M20. +- N4: boot closes a job a restart interrupted, with one message. Test T13, mutant M21. +- N5: section 7 names the refusals the manual verbs gain. +- N6: the set counts Q1 leftover lines, in fire step 3 and in the delete text. + ## Sources - Brief: `docs/plans/2026-10-22_fire-revokes-grants.md`, "Brief: no grant outlives its instance" (sage-2568, @@ -76,14 +84,17 @@ the request that started it. 2. Step A, as today, with one addition. In the same transaction, after `fire.stopped`, the grant set is read (section 3). Reading it there means a grant raise either committed before step A, and is in the set, or comes after `fire.stopped` and refuses (section 3d). -3. If the set is empty and the agent has no Gitea leftover (section 5), steps B and C run synchronously, - exactly as today. A fire with no grant answers today's result with today's events (T7). +3. If the set is empty, steps B and C run synchronously, exactly as today. A fire with no grant answers + today's result with today's events (T7). The set counts everything step G would touch (d6, N6): grant + rows, open decisions, the agent's Gitea leftovers (section 5) and its Q1 leftover host lines (section 5). + Any of them makes the set non-empty and goes to the job. 4. Otherwise the verb takes the agent's step G lock (section 3e), records `fire.revoking {job, grants}` in step A's transaction, starts the job, and answers at once: `{...stopped, job, revoking: ['host:' | 'gitea', ...], done: false}`. There's no `transaction`, since step B hasn't run. -5. The job runs step G, then step B (`write('instance.fire')`) and step C (`consumeAuthority`, then - `fire.done {job, grants}`). It releases the lock when it ends, whichever way it ends. +5. The job runs step G, then step B (`write('instance.fire')`) and step C (`#recordAuthority`, below, then + `fire.done {job, grants}`). It releases the lock when it ends, whichever way it ends, `fire.failed` + included. What the chief sees meanwhile: the instance is "firing". That's an existing state (`#firing`, broker.mjs:645): `fire.stopped` with no later `fire.done`. Starts already refuse `instance-firing`. @@ -94,8 +105,42 @@ What the chief sees meanwhile: the instance is "firing". That's an existing stat The human sees the outcome either way: `fire.done` names the job and the grants, and a refusal sends the inbox message in section 4. -To check at build time: step C's `consumeAuthority(s, ...)` runs after the verb has answered, so the job keeps -the fields of `s` that step C needs. It must not depend on the chief's run still being live. +**Steps B and C in the job (d6, C1).** d4 left "step C without the chief's run" as a build-time check, and +Dewey checked it: it fails. +- `#consumeAuthority` calls `#checkAuthority` (broker.mjs:2852). That runs `#processingWork()` and + `#agent(s)`, and `#agent` refuses `not-holder` once the chief's run no longer holds its claim (:749). +- After the verb answers, the job can take a minute or more. If the chief's run ends meanwhile, step C would + throw after step B had delisted. The instance would be left delisted with no `fire.done`. + +So the verb does the full check, and the job only records: +- The verb runs `#checkAuthority(s, action, authority)`, as today, before step A. The chief holds its claim + then. +- The job's step C runs `#authorityFor(s, action, authority)`, the check without the holder test. It then + runs the `#spent` check and the `action.allowed` event, as `#consumeAuthority` does. That's the shape a + landing's record already uses (`recordPublish`, broker.mjs:3174, row 137): the run that asked held its + claim when it asked, and may have ended since. It's one small private method, `#recordAuthority`, shared + with nothing else. +- The job keeps a copy of the session fields `#authorityFor` and `#event` read (business, role, thread, + run). It doesn't keep the live session object. +- `processing-halted` doesn't hold the job. A halt stops agent work. Step G only removes access, and steps + B and C finish a decision the chief made while processing ran. Holding them would leave a delisted or + half-revoked instance "firing" for the length of the halt. The verb itself still refuses during a halt, + through `#checkAuthority`, as today. + +**A step B or C failure in the job** gets its own record, not `grant.revoke.refused`, since every grant in the +set was revoked by then: +- It records `fire.failed {job, step: 'B' | 'C', code}`. `code` is the thrown code, for example the writer's + `credential-revoke-failed`, or `decision-consumed`. +- It sends an inbox message: + + > Fire of : job revoked its grants () but stopped at step (). The instance + > is still firing. Run the fire again once is cleared. + +- The instance stays firing. A repeat fire finds an empty set and takes today's path for that state, with the + chief's own claim: B and C after a step B failure (T12), the delisted path (webby-0094 N1) after a step C + failure. + +`grant.revoke.refused` with reason `internal` stays only for an unexpected throw inside step G. ### The delete (sage-2575) @@ -129,8 +174,10 @@ grant set. It starts a new job for what's left. The broker knows the last job refused because the agent's latest job end event is a `grant.revoke.refused` with verb `lifecycle.delete`, and no `grants.revoked.done` came after - it. A delete after the human cleared the grant by hand finds an empty set, or only leftover lines (Q1), and - goes on. + it. A delete after the human cleared the grant by hand doesn't find an empty set if a manual revoke left a + line (Q1). The leftover line makes the set non-empty, so that delete starts a job for the line removal + and refuses `grants-revoking` (d6, N6, and T3b's second case). If nothing is left, the set is empty and the + delete goes on. The tradeoff (ruled by sage-2575): deleting a granted instance now always takes two requests. That keeps the cli and the host channel out of row 281, and a delete of an instance with a grant is rare. Holding the @@ -311,8 +358,25 @@ the grant and the reason, with the manual way out beside the retry (N2): The delete's message has the same shape, with "raise the delete again". For Gitea, the way out is `mosaic grants sweep gitea` once Gitea is back. -A job that throws something unexpected records `grant.revoke.refused` with reason `internal`, sends the same -message, and releases the lock. There's no unhandled rejection. +A job that throws something unexpected inside step G records `grant.revoke.refused` with reason `internal`, +sends the same message, and releases the lock. There's no unhandled rejection. A failure at step B or C is +`fire.failed` (section 2). + +**A restart mid-job (d6, N4).** The lock and the job are in memory, so a restart ends them with no end +record. A fire would stay "firing" until the chief happened to repeat it. A delete's request is already +closed, so no one would hear anything. Boot closes that gap: +- A job is a start record (`fire.revoking` or `grants.revoking`) whose `job` has no end record. The end + records are `fire.done`, `fire.failed`, `grants.revoked.done`, `grant.revoke.refused` and the new + `grants.job.interrupted`. +- When the broker starts, for each such job of each business, it records `grants.job.interrupted {job, + agent, verb}` and sends the human one inbox message: + + > of : job was interrupted by a bus restart. Some grants may be revoked. Run the + > fire again, or raise the delete again. The repeat goes on from what's left. + +- Recording the end record in the same transaction as the message means a second boot finds nothing to + send. +- Boot reads only events, with no network call, so it adds nothing to the start time beyond one query. ## 5. A repeat fire (D5) @@ -377,15 +441,19 @@ way through the human's new request. ## 7. What doesn't change (brief item 4) -- `hosts.revoke`, `grants.revoke` and `grants.sweep`: same callers, the same refusals (`tester-dev-only` - included), and the same records. hosts.revoke's own order stays local first. +- `hosts.revoke`, `grants.revoke` and `grants.sweep`: same callers and the same records. Their refusals stay + the same (`tester-dev-only` included) with one addition from section 3e (d6, N5): while a step G job holds + the agent's lock, `hosts.revoke` and `grants.revoke` for that agent refuse `grant-revoke-running`, and + `grants.sweep` skips the agent and names it as `running`. hosts.revoke's own order stays local first. - No new verb, no Console route, no CLI change, no schema change. Every new record is an `events` row in the existing grant-evidence shape. - `revoke()` in runtime.mjs and the organization writer don't change. - `server.mjs`, `process.mjs`, `cli/src/host.mjs` and `cli/src/launcher.mjs` don't change. Both verbs stay synchronous. The launcher already closes a request with whatever code `lifecycleDelete` refuses. - New codes: `fire-in-progress`, `grants-revoking`, `grant-revoke-running`, `instance-fired`, and - `grant-revoke-failed` in events and messages. `lifecycleDone` checks a refusal against `LIFECYCLE_CODE = + `grant-revoke-failed` in events and messages. New event types: `fire.revoking`, `grants.revoking`, + `grants.revoked.done`, `grant.revoke.refused`, `fire.failed` and `grants.job.interrupted`. +- The broker's start gains one events query for interrupted jobs (section 4, N4). `lifecycleDone` checks a refusal against `LIFECYCLE_CODE = /^[a-z-]{1,64}$/` (broker.mjs:149), and `grants-revoking` matches it. ## 8. Tests (each in the owning package, whole files) @@ -471,9 +539,24 @@ same shape as `giteaIdle()`. Unless a test says otherwise, every fire or delete - After release, the jobs complete. - A cli test drives the launcher's delete pass against a bus whose remove is held. The pass closes the request `grants-revoking` and the host channel stays up. +- T11 (d6, C1): a fire whose job is held on a remove. While it's held, the chief's run ends and its claim is + released, and processing is halted. After the release, the job completes: `fire.done` is recorded with the + job, the consumed `action.allowed` event is there, a second use of the same authority refuses + `decision-consumed`, and the instance is out of the file. No `fire.failed` and no `grant.revoke.refused`. +- T12 (d6, C1): a fire with a grant whose step B refuses in the job. The test uses T1's setup, where + `revoke()` refuses `credential-revoke-failed`. After the job: + - `fire.failed` carries the job, step `B` and `credential-revoke-failed`, and the inbox message names the + job, the revoked grants, the step and the code; + - there's no `grant.revoke.refused` and no `fire.done`; + - the instance is still firing and still in the file. + With the cause cleared, a repeat fire finds an empty set, runs B and C synchronously and completes. +- T13 (d6, N4): a fire and a delete, each with its job held, then the broker is closed and a new one opened + on the same data root. The new broker records `grants.job.interrupted` for both jobs and sends two inbox + messages, one saying to fire again and one to raise the delete again. A third broker on the same root sends + nothing more. A job that ended before the restart gets no interrupted record. Files (N3). Every file listed runs whole in the manifest round, touched or not: -- New: `packages/bus/tests/fire-revokes-grants.test.mjs` holds T2-T10. +- New: `packages/bus/tests/fire-revokes-grants.test.mjs` holds T2-T13. - Changed: - `grant-host.test.mjs` and `gitea-grant.test.mjs`: T1's pins, and the T8 regressions next to their existing cases; @@ -508,6 +591,9 @@ Files (N3). Every file listed runs whole in the manifest round, touched or not: - M16: the delete's first refusal sends no start message. Killed by T3. - M17: a delete after a refused job refuses `grants-revoking` instead of `grant-revoke-failed`. Killed by T3b. - M18: a delete of an instance with no grant goes through the job. Killed by T3c. +- M19: the job's step C uses `#consumeAuthority`, with the holder and processing checks. Killed by T11. +- M20: a step B or C failure in the job is recorded as `grant.revoke.refused internal`. Killed by T12. +- M21: boot skips the interrupted-job check. Killed by T13. ## 10. Order and rebase