The first two pieces were about stopping things — put the boundary on the execution path, then give every tool an execution policy by risk.
But some of it can't be stopped. The policy allowed it, the confirmation passed, the write completed. The AI has already written to the database. Now the question changes:
Revocation.
It sounds like a simple action: add a button, click it, show "revoked". In practice, revocation is probably the easiest link in the whole governance chain to fake.
1. Revocation is the last link in the chain
Three pieces, three positions:
- Before execution: set the boundary (the prompt isn't one; the runtime is)
- During execution: set the policy (reads automatic, writes confirmed, high risk blocked)
- After execution: be able to take it back — this piece
The first two were about stopping what shouldn't happen. This one is about what to do once it already has.
There's a question that's easy to skip: to revoke an AI write, how far do you have to go before it counts?
Before answering it, here's what we can't do, up front. The reason is blunt — an article that only lists strengths gives a reader nothing to reply to.
2. What we can't do
Async side effects have no intermediate state modelled.
This whole mechanism assumes a tool call lands in the database synchronously. If AI writes ever go async — dropped into a queue, sitting in an "in flight" state — you need a state machine to say "still in flight, can't revoke yet". That layer doesn't exist. AI writes here aren't async today, so it hasn't surfaced, but there's nowhere in the code reserved for it.
The terminal state of external compensation lives with the target, and we don't reconcile it.
The request goes out, the other side never answers, and the state hangs. (Section 5 gets to what it's called.) Today we hang honestly — the UI says the outcome is in the target system — rather than faking completion. Converging it would take polling or a callback. Not built.
Cross-group batches are independent per group.
In one batch revoke, each compensation group succeeds or fails on its own; one failing doesn't affect the others. The isolation is a feature. The cost is that there's no "sweep up what already succeeded" semantics. To back out, you revoke again yourself.
Batch revoke has no pagination and no cap.
It pulls every side effect for that conversation or run in one go and works through them. Simple semantics — one pass, one summary. The cost is unbounded size: a run producing several hundred side effects becomes a long request.
(An abandoned idea, while we're here. A cap of 500 per batch with a truncation marker. It got half-built before the flaw showed up — after truncating, re-running returns the same first 500, forever. Rolled back. Doing it properly means cursor pagination, which is a different piece of work.)
Those four come down to one thing: how mature revocation is doesn't depend on whether there's a button. It depends on whether the contract is written honestly. What follows is the design — what you can revoke should be verifiable, what you can't should be said plainly.
3. The dangerous failure isn't "can't revoke" — it's "think you revoked"
A case.
One AI call creates a project and splits out 10 subtasks. One business action. Eleven rows.
If the revoke only deletes the project, the 10 subtasks are still sitting there — and the UI already says "revoked". The user reads those words and moves on. Nobody looks twice.
That's worse than not revoking at all. Without revocation the user knows the data is still there, and will restrict the AI or clean up by hand. A fake revoke convinces them it's clean. The problem sinks.
Batches are the same shape. One run writes 5 rows; the revoke succeeds on 3 and fails on 2. Without a per-item summary there's no way to know whether it came back clean.
So the first question isn't how to revoke. It's defining the scope — which rows does one business action actually correspond to?
4. Revocation needs tiers, and it needs to be honest
Not every side effect can be revoked. Force them all down one path and you get fake successes everywhere. So revocation starts with tiers:
| Class | Meaning | Revoke action | Where the state lands |
|---|---|---|---|
none |
Not revocable / the target system has no compensation endpoint | Refuse | No revoke state written |
local_compensate |
A local entity that can be soft-deleted | Soft-delete it | Target soft-deleted → restorable from trash; state revoked
|
governed_external |
Written into an external system | Call the other side's compensation endpoint | A 2xx only proves "requested" → state compensating (terminal state lives in the target) |
One thing that's easy to skip: declaring something revocable is not the same as being revocable.
The test is whether the tool, under the class it declares, has a verifiable revocation path. Does it have an entry point, does that entry point actually take effect, and how far does it get. All of that should be checkable.
The none tier is the counter-intuitive one. An operation that can't be undone should say so, instead of offering a fake revoke button.
An email that went out. An SMS. A completed payment. Anything physical. Those don't come back. Giving them a "revoke" entry point that reports success is worse than giving none at all. So they either get blocked at the risk-tier stage (R5), or honestly marked none, with the UI saying "not revocable / no external compensation".
5. External systems: "requested", never "revoked"
Once the data is in someone else's system, revoking involves that system.
The convention looks roughly like this: sign a delegated identity (audience-scoped to the target), then call the compensation endpoint they provide:
KeelBase → DELETE {baseUrl}{revokePath}{resultId}
Authorization: Bearer <delegated JWT>
The endpoint returns 2xx. What does that mean?
Only that the compensation request went out. Not that they undid anything.
So the state can't be revoked. It can only be compensating, because the terminal state is on their side. That has to be honest at both layers:
- API: 2xx →
compensating, never impersonating a terminal state - UI: say plainly that the outcome is in the target system, rather than rendering a completed check
This is where products dress things up. Rendering "compensation requested" as "revoked", because the former looks unfinished. But every downstream decision the user makes on that fake state is wrong.
6. When one call writes many rows: compensation groups
Back to the project + 10 subtasks.
The fix is to have the tool declare what it wrote: a composite write tool returns a list of side effects, and by convention the first entry is the principal object — the project.
Everything from that one call then belongs to a single compensation group. The group key is the call's idempotency base key, which has a useful property: a retry lands in the same group by construction, with no extra id to generate.
With groups, the rule is simple: revoke any member, and the whole group is compensated.
The local members of a group go into one database transaction — all of them go, or none. That matters, because "half revoked" is the worst intermediate state here: project deleted, subtasks still present, exactly the fake revoke from §3. The transaction makes it impossible.
Batch revoke has a matching requirement: it must fold by compensation group. Otherwise a three-member group gets compensated three times — the first genuinely, the next two churning on top of it.
One more boundary: for a tool that doesn't declare its side effects, the system's record is exactly as wide as the declaration. If the tool really wrote 10 rows but declared 1, the system knows about 1. That's honest — the recorded scope is the declared scope — but it's a contract defect on the tool's side, not the system's.
7. The revocation itself has to be recorded
Revoking isn't making something disappear. It's taking it back in a way you can point to.
When compensation completes it writes an operation-audit row with action = COMPENSATE, onto the hash chain. The important part: targetId holds the root business object's id — the project — not one of the eleven effect rows. That's what lets you look it up by business object: how exactly was this customer or project revoked, by whom, and when.
Ownership, while we're here: a user can only revoke side effects they triggered; an admin can revoke anyone's. Both paths leave a record. Revocation permission is itself a boundary.
8. Revocable is not the same as restorable
The two get conflated. They're different things.
A local revoke is a soft delete — the row gets a deletion marker, disappears from normal views, and can still be restored from trash.
- Revoke: take back something the AI did
- Restore: change your mind about the revoke
Taking something back and putting it back are two semantics with two entry points. And the restored state should be authoritative server-side — don't let the frontend infer it, or it drifts on the next refresh.
Closing
Which comes down to: revocation isn't a button, it's a contract.
An honest revocation contract answers at least four questions. Which data does this business action cover (scope). Which class is this tool in (capability). What state is it in once the request goes out (honesty). And was the revocation itself recorded (provability).
If you're building agents, four things worth testing on your own system. When one tool call writes several rows, does the revoke leave some behind. For irreversible operations, is there a revoke entry point that lies. For external compensation, does the state say "revoked" or "requested". And can you find out afterwards who performed the revocation.
Once a system can honestly label both "didn't come back clean" and "can't be revoked", it's something people can actually trust.
Drafted with an AI assistant. The system, the positions and the mistakes are mine.
Top comments (2)
The group key being the idempotency base key makes retries converge on one identifier. Membership is a separate question. If the tool is nondeterministic and a retry writes twelve rows where the first call wrote eleven, the extra row lands under the same group key, and whether it gets recorded depends on whether you keep the first declaration or union in the later one. Keeping the first is the defensible idempotency answer. It also reproduces §3 precisely: revoke the group, one row survives, the UI says revoked. What is interesting is that §6's boundary stops holding there. The tool declared its effects honestly on both calls, so the recording gap traces back to your own idempotency rule rather than to a contract defect on the tool's side.
Separately,
compensatinghas no age.noneis honest in a durable way because it never changes.compensatingis honest at the instant the request left, and silent after that, so with no reconciliation the resting state of a failed external compensation is "still in progress" indefinitely. Per row the UI stays truthful. The aggregate does not. Ask what is currently unresolved and an entry stuck since last month reads identically to one from five minutes ago. Your four closing tests all probe the moment of writing. A fifth could probe staleness, and stamping the request time then flagging anything past a threshold costs far less than polling or callbacks.On the first point: do you keep the first declaration and surface a retry mismatch as a conflict, or merge newly declared members into the group before a revoke is allowed to report complete?
You're right on the retry, and right in a way that breaks §6 rather than sitting inside it.
We keep the first declaration, and we do it silently. The group key is a hash of user, conversation, tool and sorted arguments, so a real retry lands on the same key and the second declaration collides on insert. The insert is one transaction for the whole group, so it rolls back and we replay what's already stored. Nothing compares the two declarations. If the retry writes twelve rows after the first call wrote eleven, the twelfth is dropped and nobody is told the retry disagreed.
§6 says any recording gap traces to a contract defect on the tool's side. That holds when the tool under-declares. It doesn't hold here, and I hadn't considered a tool that declares honestly twice and disagrees with itself. Your version of the consequence is right too, down to the shape: revoke the group, one row survives, the UI says revoked. §3's failure, produced by §6's fix.
So the answer to your question is the bad one. Keep-first, no conflict surfaced, which is the first of your two. The second is the one I'd take. Keeping the first declaration is correct for keeping the write idempotent; it isn't enough for a revoke claim, because a revoke that reports complete has to be able to say the retry declared more than it holds. Neither is in the code yet.
The age point is also correct, and there's nothing to check. The row carries a created_at and a revoke_status, and no stamp for when the compensation request left. So none is durable because it's a classification, while compensating is a photograph of one instant with no expiry. Row by row it stays honest. Ask the aggregate what's unresolved and an entry stuck since last month reads the same as one from five minutes ago.
Stamping the request time plus a threshold is cheaper than anything I had. Polling or callbacks buy reconciliation; a timestamp buys the question "is this stale", which is most of what you'd want from it. The four in the closing section all probe the moment of writing, as you say. I'd add the fifth.