row 281 design d4: step G detached from the verbs, open decisions and a fired-agent raise check, one step G per agent (dewey-0823, #1786)

This commit is contained in:
jason.woltje committed 2026-10-22 05:04:49 -05:00
1 parent 36c2d98b81
commit 41983e2659
1 file changed
+269 -75
+269 -75
View File
@@ -1,9 +1,15 @@
# Row 281 build notes: a fire or a delete revokes the instance's grants (#1786)
Builder Megavolt, reviewer Dewey, gate owner Sage. Design draft d3 for Dewey's approval. d2 added Sage's
Q1/Q2 rulings (sage-2573) to d1 (dfebd16cc). d3 adds the approved-before-fire tests T6/T6b/T6c (sage-2574). No code until it is
Builder Megavolt, reviewer Dewey, gate owner Sage. Design draft d4 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
approved-before-fire tests (sage-2574). d4 answers Dewey's CHANGES on d3 (dewey-0823):
- B1: the set takes every grant decision with no outcome, and a raise refuses a fired or firing agent.
- B2: step G runs detached from the verb, so neither caller waits on the network.
- B3: one step G per agent, with named refusals for everything that could race it.
- N1-N3 are folded in.
## Sources
- Brief: `docs/plans/2026-10-22_fire-revokes-grants.md`, "Brief: no grant outlives its instance" (sage-2568,
@@ -37,51 +43,102 @@ and on #1786). The details:
grant decision for that agent that hasn't run yet still passes `#hostDefinition` and `#giteaRole`, so it can
still apply after the fire. A delete removes the roles entry, so its executor refuses `unknown-role` or
`gitea-not-declared`.
- **A raise after the fire** (dewey-0823 B1). `#raiseGrant` (broker.mjs:1529) checks only that the caller is
the chief, and the grant bindings check only the roles entry. So the chief can raise a new grant for a fired
agent, and if the human approves it, it applies.
## 2. Where the grant revokes sit (D1)
## 2. Where the grant revokes sit (D1, rewritten in d4 for B2)
The grant revokes run in the broker, awaited, inside `#chiefLifecycle`, after step A and before step B:
d3 awaited step G inside the verb. Dewey's B2 showed that neither caller can wait that long:
1. `write('instance.fireCheck')`: the writer's checks against the file, as today. A refusal here revokes
nothing.
2. Step A: the runs stop and the open requests are cancelled, as today.
3. **New, step G:** the grant revokes (section 3). A refusal stops the fire here.
4. Step B: `write('instance.fire')`. The writer's checks, then `beforeWrite`, which is `revoke()` for the
credentials, then the write. With the grant files gone after step G, `revoke()`'s store-directory check
passes.
5. Step C: as today.
- **Delete.** The launcher's `host.op` waits `REQUEST_TIMEOUT_MS = 10000` (cli/src/host.mjs:31). On a timeout,
`breakChannel` stops the whole bus host. One SSH remove can take 30 s (host-grant.mjs:231, :311), and each
Gitea call 15 s (`CALL_MS`, gitea-grant.mjs:38).
- **Fire.** server.mjs answers a verb that isn't on its async list synchronously (:154), and the async list
gives 120 s (:150). The chief's runner client times out at 30 s (harness/src/runner.mjs:449). Two hosts
plus the Gitea calls can pass both limits.
Why not in `beforeWrite` or `revoke()`: both are synchronous and run inside the organization writer, after
its checks. A host `remove` is an SSH call and a Gitea revoke is HTTP, so they can't run there. The business
package also has no reach into the broker's grant tables, and shouldn't get any.
So step G never runs inside a verb. Both verbs stay synchronous and answer at once. Step G runs as tracked
broker work after the answer, as row 236's grant executors do (`#track`, `setImmediate`). No caller waits on
the network, so `server.mjs`, `process.mjs`, `cli/src/host.mjs` and the launcher don't change.
Why after step A: a fire is the decision to stop the agent. If step G came first, a host that can't be reached
would keep the fired agent running. After step A, a refused step G leaves the same state as a failed step B
today: the instance is "firing" (stopped, still listed) until a repeat. Row 194 already handles that state.
### The fire
The repeat fire's own path (webby-0094 N1: an instance already delisted still has its tokens revoked, with no
write) also runs step G. Step G sits in the broker before `write('instance.fire')`, so it runs on both paths.
1. `write('instance.fireCheck')`, as today.
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).
4. Otherwise the verb takes the agent's step G lock (section 3e), records `fire.revoking {grants}` in step A's
transaction, starts the tracked job, and answers at once:
`{...stopped, revoking: ['host:<id>' | 'gitea', ...], done: false}`. There's no `transaction`, since
step B hasn't run.
5. The tracked job runs step G, then step B (`write('instance.fire')`) and step C (`consumeAuthority`,
`fire.done` with `grants`). It releases the lock when it ends, whichever way it ends.
The delete: `lifecycleDelete` (broker.mjs:3293) runs step G after its running and starting checks and before
`instances.set delete`. It becomes async. Its one caller, the launcher's `host.op` (launcher.mjs:845), already
awaits the reply, and `process.mjs` already returns async ops (`mayLaunch`). A refused step G answers the
launcher with the refusal. `remove()` returns `{refusal: e.code}`, and the launcher closes the request through
`lifecycleDone` with that code, as it does today for a writer refusal. The request is then closed, so the
"repeat" for a delete is the human's new delete request. Grants revoked before the refusal stay revoked, and
the new request's step G goes on from there (Q2, ruled by sage-2573 below). Also to check at build time: the launcher's `host.op` reply timeout against an SSH remove and the Gitea calls. If it's
shorter than they can take, the design needs a change before code.
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`. A repeat fire while the job
runs refuses `fire-in-progress`, a new code. A repeat after it finishes takes the delisted path (webby-0094
N1) and answers done. A repeat after a refused step G runs step G again (section 5). The human sees the outcome
either way: `fire.done` names 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.
### The delete
`lifecycleDelete` (broker.mjs:3293) stays synchronous. After its running and starting checks, it reads the
grant set.
- An empty set: `instances.set delete` as today.
- A non-empty set: it takes the lock, starts the same tracked step G with verb `lifecycle.delete`, and refuses
`grants-revoking` at once. The launcher closes the request with that code, well inside its 10 s. That's
sage-2573's Q2 path: a closed request, and the human raises the delete again.
- When the job ends, the human gets an inbox message. Either "<agent>'s grants are revoked: raise the delete
again", or section 4's refusal.
- A delete raised while the job runs refuses `grants-revoking` again, without starting a second job. The
delete raised after the job finds an empty set and goes as today.
The tradeoff: deleting a granted instance now always takes two requests. The alternatives were to hold the
launcher's request open past its 10 s, which would need a host channel change and a strand path, or to make
the launcher poll. Two requests changes no other package. **For Sage:** this extends Q2 from "a refused
delete is raised again" to "every delete of a granted instance is raised again". Say if that's not
acceptable.
While a delete's step G runs, the instance is still listed and not firing, so it could be started. That's
harmless: its grants are being revoked, and the repeat delete refuses `instance-running` as today. No new
start check.
### Why not in `beforeWrite` or `revoke()`
Both are synchronous and run inside the organization writer, after its checks. A host `remove` is SSH and a
Gitea revoke is HTTP, so they can't run there. The business package also has no reach into the broker's grant
tables, and shouldn't get any.
### Why after step A
A fire is the decision to stop the agent. If step G came first, a host that can't be reached would keep the
fired agent running. After step A, a refused step G leaves the same state as a failed step B today: the
instance is "firing" (stopped, still listed) until a repeat. Row 194 already handles that state. The repeat
fire's delisted path (webby-0094 N1) also runs step G, since the set is read before step B on both paths.
## 3. Step G: what it revokes, and how
The set, for (business, agent), read in one store transaction at the start of step G:
The set, for (business, agent), is read in one store transaction: step A's for a fire, the delete's checks
for a delete (section 2). It holds:
- each `host_grants` row of the agent;
- each host named by an open host grant decision for the agent (approved, with no `grant.applied` or
`grant.failed`, and no `grant.revoked` of (agent, host) after it);
- each host named by an open host grant decision for the agent;
- the Gitea grant if the agent has a `gitea_grants` row, a `gitea_users` row, or an open Gitea grant decision.
An empty set means step G does nothing and records nothing. A fire of an instance with no grant is then
exactly today's fire, with no new events (brief tests, fourth bullet).
An open decision (d4, B1) is any grant decision raised for the agent with no outcome yet. That is: not denied,
not expired, no `grant.applied` or `grant.failed`, and no `grant.revoked` of the same (agent, host), or of the
agent's Gitea, recorded after its raise. A raised decision the human hasn't decided is open. d3 required
"approved", which left that one out.
An empty set means no step G and no new records. A fire of an instance with no grant is then exactly today's
fire, with no new events (brief tests, fourth bullet).
### 3a. A host grant: the host line first (D2)
@@ -129,6 +186,17 @@ gone (section 5's reconcile). Two changes keep the fire closed:
gone, and only the Gitea side is left. That is the case the brief's host rule guards against, and it is
kept visible here, not hidden.
**A Gitea executor in flight** (d3 T6c, and B3 seen from the executor's side). Step G's Gitea part goes in
this order:
1. Record `grant.revoked {granted: false}` for each open Gitea decision (3c).
2. Await this agent's tracked Gitea executors and remote undos, if any are running. Each one ends `revoked` at
its next `#giteaRevokedSince` check, or it committed `applied` before step 1.
3. Read the `gitea_grants` row again and run the steps above.
`#giteaCloseRevoke` with `fire: true` lists the user's tokens and deletes every one. The list comes after
step 2, so it includes any token an in-flight executor created. `#giteaRuns` is a single Set today. It
becomes keyed by (business, agent) so step G can await one agent's executors alone.
### 3c. Open decisions
A host named only by an open decision has no record. Step 3a still runs `remove` (a no-op) and records
@@ -143,37 +211,100 @@ whenever it runs. The fired agent keeps its roles entry, so `#hostDefinition` an
this check is the only thing that stops the decision. T6 and T6b test it. A delete also removes the roles
entry, so the executor refuses there as well.
The same holds for a decision raised but not yet approved (B1). It's in the set, so it gets its
`grant.revoked {granted: false}`. If the human approves it later, its executor ends `revoked` (T6d). The
decision stays approvable in the inbox. Approving it does nothing but end it, which `grant.failed` reports as
it does for any revoked grant.
### 3d. A raise for a fired agent (d4, B1)
`#hostGrantBinding` and `#giteaGrantBinding` both run inside the raise transaction (broker.mjs:1539 and
:1548; the host raise's keyscan is awaited before that transaction). Both gain one check. The raise refuses
`instance-fired` if the agent is:
- firing (`#firing`);
- fired, by the writer's own rule (instance-settings.mjs, hire): a roles entry, not in `launch.instances`,
and not `launch.by`.
A rehire lists the name again, so a rehired agent can be granted again.
Because the check is in the transaction that records the decision, there's no gap. A raise that commits
before step A's transaction is in the set. One that commits after it refuses. A raise for an agent whose
delete's step G is running refuses `grant-revoke-running` (3e).
No executor recheck is added. A raise can't commit after `fire.stopped`, and every decision raised before it
is in the set and gets its `grant.revoked`.
### 3e. One step G per agent (d4, B3)
The broker keeps `#revoking`, a map from (business, agent) to the verb whose step G is running. The verb sets
it synchronously, in the same turn as the set read, before it answers. The job clears it in a `finally`.
While it's set:
| Caller | Answer |
|---|---|
| `instance.fire` | refuses `fire-in-progress` |
| `lifecycleDelete` | refuses `grants-revoking`, no second job |
| `hosts.revoke` / `grants.revoke` for the agent | refuse `grant-revoke-running` |
| grant raise for the agent | refuses `grant-revoke-running` |
| `grants.sweep` | skips this agent's entries and names it as `running` in its result |
Two fires started together (the chief retrying after a client timeout): the first takes the lock before it
answers, and the second refuses. The map is in memory. After a broker restart the job is gone and so is the
lock. The instance is still firing, or the delete still has a non-empty set, and the repeat starts a fresh
step G (section 5).
A manual revoke can't race step G, since it refuses while the lock is held. Step G's transactions still
check what they read: a `host_grants` row already gone at 3a step 3 records `grant.revoked {granted: false}`
and goes on, as hosts.revoke does today for a revoke with no record.
## 4. A refusal (D4)
Step G stops at the first grant that can't be revoked. Grants already revoked in this step G stay revoked and
recorded. The rest stay as they were. The fire refuses `grant-revoke-failed`, a new code. The organization
file is unchanged, since step B never ran, and the credentials are unrevoked, since `revoke()` never ran.
recorded, and the rest stay as they were. The job doesn't run step B: the organization file is unchanged, and
the credentials are unrevoked, since `revoke()` never ran. A fire stays "firing". A delete's request was
already closed with `grants-revoking` (section 2).
`BusError` carries only a code, so the names go in one grant-evidence event recorded before the throw:
`grant.revoke.refused {agent, grant: 'host:<id>' | 'gitea', reason, verb: 'instance.fire' | 'lifecycle.delete',
by}`. Its `reason` is one of `host-unknown`, `host-remove-failed`, `gitea-admin-unavailable` and
`gitea-left:<ids>`. A message lands in the human's inbox, as `grant.failed` does today (broker.mjs:1139):
"Fire of <agent> stopped: its grant on <host> couldn't be revoked (<reason>). Run the fire again once <host>
is reachable." The refusal record has the verb and args, as every refusal does.
The verb has already answered, so the refusal is a record, not a thrown code:
- One grant-evidence event, `grant.revoke.refused {agent, grant: 'host:<id>' | 'gitea', reason, verb:
'instance.fire' | 'lifecycle.delete', by}`.
- `reason` is one of `host-unknown`, `host-remove-failed`, `gitea-admin-unavailable` and `gitea-left:<ids>`.
- `grant-revoke-failed` is the code in the event and in the inbox message.
A message lands in the human's inbox, as `grant.failed` does today (broker.mjs:1139). It names the instance,
the grant and the reason, with the manual way out beside the retry (N2):
> Fire of <agent> stopped: its grant on <host> couldn't be revoked (<reason>). Run the fire again once <host>
> is reachable. If <host> won't come back, run `mosaic hosts revoke <agent> <host>` first: the grant record
> goes, the line is named as left, and the next fire completes and names that line (section 5).
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.
## 5. A repeat fire (D5)
A repeat runs the whole fire again: the checks, step A (nothing left to stop), step G, B and C.
A repeat runs the whole fire again: the checks, step A (nothing left to stop), the set read, then step G, B
and C in the job. A repeat while the job runs refuses `fire-in-progress` (3e). A repeat delete works the same
way through the human's new request.
- A host grant already revoked has no row and no open decision, so it isn't in the set. It's done.
- A host grant that refused still has its row, so it's retried from 3a step 1.
- Gitea: if the set is empty but the agent has an entry in `#giteaLeft`, or an open Gitea revoke, step G runs
`#sweepGitea` for the business first and then checks `#giteaLeft` again. Any entry still there for this
agent refuses `gitea-left`. The sweep is the human's verb, and running it here records `grant.sweep` and
`grant.swept` with `by` set to the firing chief. That's an internal caller of an existing method, not a new
verb. An entry left by an earlier manual `mosaic grants revoke` of the same agent blocks the fire the same
way. That's intended: no grant outlives its instance, whichever verb started its revoke.
- Gitea (d4, N1): if the agent has entries in `#giteaLeft` or `#giteaOpenRevokes`, step G retries only those.
It calls `#giteaCloseRevoke` for each, as `#sweepGitea` does per entry. It doesn't run the business-wide
sweep, which would retry every agent's entries and settle every open rotation under the chief's name. Any
entry still left for this agent refuses `gitea-left`. An entry left by an earlier manual
`mosaic grants revoke` of the same agent blocks the fire the same way. That's intended: no grant outlives
its instance, whichever verb started its revoke. These entries count toward a non-empty set (section 2,
fire step 3), so they go to the job and never run inside the verb.
- A host line left by an earlier manual `hosts.revoke` doesn't block the fire (Q1 below). These are the
agent's `grant.revoke.closed` records with `hostLineLeft` and no later line removal for that tag. For each
one whose host record still exists, step G sends `remove <user> <tag>` once. On success it records
`grant.revoke.closed {revoke: <the old revoke id>, hostLine: 'removed', fire: true}`, so a later fire doesn't
find it again. On failure it records nothing new and the fire goes on. The host and the tag go in
`grantsLeft` (section 6).
`grantsLeft` (section 6). These leftover lines also make the set non-empty, so the retry runs in the job,
inside B2's answer (dewey-0823 accepted the single retry on that condition).
## Rulings
@@ -191,10 +322,13 @@ A repeat runs the whole fire again: the checks, step A (nothing left to stop), s
## 6. The result and the event (D6)
The fire's result and the delete's done record gain `grants: [{grant: 'host:<id>' | 'gitea', revoke,
granted}]` beside `credentials`. Any leftover from 5's last bullet goes in `grantsLeft`. `fire.done` (step
C) carries the same `grants` list, so the event the human sees names them. A fire with an empty step G
carries neither key, so its shape is exactly today's.
- **A fire with a non-empty set** answers at once with `revoking` and `done: false` (section 2).
- **The job's outcome** is recorded, not returned. `fire.done` (step C) carries `grants: [{grant:
'host:<id>' | 'gitea', revoke, granted}]`, plus `grantsLeft` for any leftover from section 5's last bullet.
So the event the human sees names them.
- **A delete's job** records `grants.revoked.done {agent, verb: 'lifecycle.delete', grants, grantsLeft}` and
sends the inbox message in section 2.
- **A fire with an empty set** carries none of these keys, so its shape is exactly today's.
## 7. What doesn't change (brief item 4)
@@ -203,56 +337,116 @@ carries neither key, so its shape is exactly today's.
- 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 =
/^[a-z-]{1,64}$/` (broker.mjs:149), and `grants-revoking` matches it.
## 8. Tests (each in the owning package, whole files)
Bus, with the fake installer and the fake Gitea the row 235 and 236 tests use:
The bus tests use the fake installer and the fake Gitea from rows 235 and 236. They wait on the job with a new
trusted test API, `grantsIdle()`, which resolves once every step G job and Gitea executor has ended. It's the
same shape as `giteaIdle()`. Unless a test says otherwise, every fire or delete below is awaited that way.
- T1 (pins section 1 before the change): a fire of an instance with a host grant refuses
`credential-revoke-failed` today. A delete leaves the `host_grants` row. Then: what the next start does with
a record whose key file is gone.
- T2: a fire with a host grant on two hosts and a Gitea grant revokes all three. Each gets `grant.revoked` and
`grant.revoke.closed` as the manual verbs record them, `fire: true`, the line gone on both fake hosts, every
token deleted and login prohibited at the fake Gitea. Afterwards that agent's `host.run` and Gitea use both
refuse.
- T3: the same through the delete (`lifecycleTake` then `lifecycleDelete`).
- T3b (Q2): a delete refused at step G closes its request with `grant-revoke-failed`. The file and the
instance are unchanged, and `grant.revoke.refused` names the instance, the grant and the reason. A new
delete request then completes and skips the grants the first one revoked.
- T2: a fire with a host grant on two hosts and a Gitea grant. The verb answers `revoking` with all three and
`done: false`. After the job:
- all three are revoked, each with `grant.revoked` and `grant.revoke.closed` as the manual verbs record
them, plus `fire: true`;
- the line is gone on both fake hosts, every token is deleted, and login is prohibited at the fake Gitea;
- `fire.done` names all three;
- that agent's `host.run` and Gitea use both refuse.
- T3: the same through the delete (`lifecycleTake` then `lifecycleDelete`). The first delete refuses
`grants-revoking` and starts the job. The launcher's pass closes the request with that code. After the job
the human's inbox says to raise the delete again. A second request completes as today.
- T3b (Q2): a delete whose job refuses (host B unreachable). The first request closes `grants-revoking`. The
file and the instance are unchanged, and `grant.revoke.refused` and the inbox message name the instance,
`host:B` and the reason. With B back, a new request starts a job that skips the grants already revoked, and
the request after that completes.
- T4: host B unreachable in a fire. The job records `grant.revoke.refused` naming the agent, `host:B` and
`host-remove-failed`, and the inbox message names `mosaic hosts revoke` (N2). Afterwards:
- the file is unchanged and the instance is still firing;
- host A is revoked and recorded, and host B's row and key file are still there;
- the Gitea grant is untouched, since it comes after B in the order;
- the credentials are unrevoked.
A repeat with B reachable completes and revokes only B and Gitea.
- T4b (Q1): an earlier manual `hosts.revoke` left a line on host C. A fire with C reachable removes it and
records the close. With C unreachable, the fire completes and names C and the tag in `grantsLeft` and in
`fire.done`.
- T4: host B unreachable. The fire refuses `grant-revoke-failed`, and `grant.revoke.refused` names the agent,
`host:B` and `host-remove-failed`. The file is unchanged. Host A is revoked and recorded, host B's row and key
file are still there, the Gitea grant is untouched (it comes after B in the order), and the credentials are
unrevoked. A repeat with B reachable completes and revokes only B and Gitea.
- T5: Gitea's admin token can't be checked. The fire refuses with the Gitea grant intact.
- T5b: a token DELETE fails. The fire refuses `gitea-left`. A repeat with Gitea back sweeps and completes.
- T5: Gitea's admin token can't be checked. The job refuses with the Gitea grant intact.
- T5b: a token DELETE fails. The job refuses `gitea-left`. A repeat with Gitea back retries only this agent's
entries and completes. Another agent's left entry is still left afterwards (N1).
- T6 (sage-2574): a host grant decision approved before the fire, executed after it. The fire records
`grant.revoked {granted: false}` for (agent, host). The executor runs after `fire.done` and ends the decision
`revoked`: no `host_grants` row, no key file, no `add` sent to the host.
- T6b (sage-2574): the same for a Gitea grant decision. The executor ends it `revoked` before any Gitea call:
no `gitea_grants` row, no token file, and the fake Gitea saw no token create.
- T6c: a host or Gitea executor already in flight when step G starts. The host side aborts its claim (step
3a.1) and ends `revoked`. The Gitea side ends `revoked` at its step 8 check. Build-time check: a token the
in-flight executor created before that check must be deleted, or named in `serviceLeft`, and not left live.
- T7: an instance with no grant fires exactly as today. Same result keys, and the same events, counted.
- T6c: a host or Gitea executor already in flight when step G starts.
- The host side aborts its claim (3a step 1) and ends `revoked`.
- The Gitea side: step G awaits the executor (3b), and then the fake Gitea holds no token for the user. That
includes one the executor created mid-flight.
- T6d (B1): a host decision and a Gitea decision, raised before the fire and still undecided. The fire puts
both in the set. The human approves both after `fire.done`, and both executors end `revoked`, with nothing
applied.
- T6e (B1): a host raise and a Gitea raise refuse `instance-fired`:
- while the instance is firing;
- after `fire.done`.
After a rehire of the same name, a raise goes through. While a delete's job runs, a raise refuses
`grant-revoke-running`.
- T7: an instance with no grant fires exactly as today, synchronously. Same result keys, no `revoking`, and
the same events, counted.
- T8: hosts.revoke and grants.revoke keep their records, their order and their tester refusals.
- T9 (B3): with the job held on a remove:
- a second fire refuses `fire-in-progress`;
- a delete of the same agent refuses `grants-revoking` and starts no second job;
- `hosts.revoke` and `grants.revoke` for the agent refuse `grant-revoke-running`;
- `grants.sweep` names the agent as `running`.
After a release, one set of `grant.revoked` records exists per grant.
- T10 (B2): the fake installer holds a remove and the fake Gitea holds a DELETE, neither answering until
released.
- `instance.fire` and `lifecycleDelete` both answer before either is released. The test checks this with a
timer, not a wall-clock bound.
- Meanwhile the broker answers another verb from another session.
- 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.
Business: none expected, since the writer doesn't change. Cli: the launcher's delete pass with a refused step
G closes the request with `grant-revoke-failed`, if the launcher's tests stub `lifecycleDelete`.
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.
- Changed:
- `grant-host.test.mjs` and `gitea-grant.test.mjs`: T1's pins, and the T8 regressions next to their
existing cases;
- `packages/cli/tests/lifecycle.test.mjs`: the T10 launcher pass;
- `fake-gitea-api.mjs` and `host-fixtures.mjs`, if T10's holds need a hook there.
- Run whole because they call `instance.fire` or `lifecycleDelete` at 47a35bf43:
- bus: `chief-of-staff`, `lifecycle-revoke`, `lifecycle-verbs`, `reload`;
- cli: `lifecycle`, `instances`;
- harness: `tools`.
- Also run: `grant-executor.test.mjs` (the executors' `#giteaRuns` change) and `gitea-grant-docker.test.mjs`
(brief gate).
- `server.mjs`, `process.mjs`, `host.mjs` and `launcher.mjs` are unchanged, so no test changes there for this
row.
## 9. Mutants
- M1: step G skips the host revokes. Killed by T2.
- M2: step G skips the Gitea revoke. Killed by T2.
- M3: a refused grant revoke is ignored and the fire goes on to step B. Killed by T4.
- M3: a refused grant revoke is ignored and the job goes on to step B. Killed by T4.
- M4: `lifecycleDelete` skips step G. Killed by T3.
- M5: the fire's host revoke goes local first (`lineFirst: false`). Killed by T4, because B's row is gone.
- M6: a Gitea revoke with something in `serviceLeft` counts as done. Killed by T5b.
- M7: open decisions left out of the set. Killed by T6 and T6b.
- M8: step G skips the leftover-line retry. Killed by T4b.
- M9: a failed leftover-line retry refuses the fire. Killed by T4b.
- M10: the set takes only approved decisions (d3's rule). Killed by T6d.
- M11: the raise bindings skip the fired check. Killed by T6e.
- M12: no `#revoking` lock. Killed by T9.
- M13: the verb awaits the job before it answers. Killed by T10.
- M14: step G's Gitea part doesn't await the in-flight executor. Killed by T6c.
- M15: step G's Gitea retry runs the business-wide `#sweepGitea`. Killed by T5b.
## 10. Order and rebase