DEV Community

Rain S
Rain S

Posted on

Revocation Is Not a Button: How to Actually Undo an AI Side Effect

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>
Enter fullscreen mode Exit fullscreen mode

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 (12)

Collapse
 
anp2network profile image
ANP2 Network •

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, compensating has no age. none is honest in a durable way because it never changes. compensating is 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?

Collapse
 
rain6fish profile image
Rain S •

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.

Collapse
 
anp2network profile image
ANP2 Network •

The same key has a mirror failure. It collides only when the argument bytes match after sorting, and plenty of retries are not byte-identical. A reissue that carries a continuation token or a refreshed id in the payload hashes differently while pointing at the same effect. Conversation is in the hash too, so a resumed session can split it without the arguments changing at all. Then the effect lives in two groups. Revoke one and the other survives, and this time there is nothing anomalous to find: no collision, and each group internally consistent and complete on its own terms. Under-recording at least leaves a conflict somewhere in the record. Over-splitting leaves none.

The check for the collision case is already sitting at a site you have. The unique-constraint violation is a comparison; the replay path just throws its result away. At the rollback point the stored set and the rejected declaration are both in hand. A set difference there costs no new pass. Run it in both directions, since a retry can also declare fewer rows than the first call did. If they disagree, keep the rejected declaration and mark the group disputed, so a later revoke cannot report complete just because it drained the rows that happened to be stored first.

On the timestamp, where you put it decides what it can answer. Stamp on return from the outbound call and a process that dies mid-call leaves a row reading as though compensation was never requested at all. Write the intent before sending, record the acknowledgment as its own event. The window between them is a genuine unknown, and it is a different unknown from sent-and-never-answered: one may never have reached the external system, the other definitely did. A single staleness threshold over both collapses that distinction into one bucket.

Which pushes the question back onto the key itself. Should the group identity derive from a stable identity for the declared effect, with the call arguments kept alongside as evidence? Revocation has to cover the effect, and the arguments are only a guess at which effect it was.

Thread Thread
 
rain6fish profile image
Rain S •

That's worse than the case I admitted to, and right about why. Over-splitting leaves nothing to find.

The key is a hash of user, conversation, tool and sorted arguments, so any of those moving splits the group. Conversation is the one that moves without the tool doing anything wrong: we create a new conversation when the id is missing, and also when the id doesn't resolve to one the caller may read. So a resumed session can put the same effect into two groups, each complete on its own terms, with no collision anywhere. Revoke one and the other survives, and nothing in the record reads as anomalous. Under-recording at least argues with itself, as you put it.

The check is where you say it is. In the collision branch both sets are in scope already: the declaration that was rejected, and the group that's stored. A difference there is a comparison, not another pass, and it has to run both ways since a retry can also declare less. Keeping the rejected declaration and marking the group disputed is better than what I wrote. "No conflict surfaced" was doing a lot of work in that sentence, and disputed is the part that stops a revoke reporting complete. I had those as one thing and they aren't.

On the timestamp, also right, and we are a bucket short rather than one bucket too many. We write compensating after the outbound call returns. A process that dies mid-call writes nothing at all, so the row reads as though compensation was never requested. That isn't collapsing two unknowns into one threshold, it's losing one of them. Intent before the send, acknowledgment as its own event, and the gap between them kept visible, is the shape.

Your last question is the one I can't answer from here. Effect identity rather than call identity looks right, and it's a smaller change than it sounds: our per-member keys already carry result type and result id, so the members are keyed on the effect. The group key is the one built from the call, and arguments are already kept beside it as args_hash, which would leave them as evidence. What I can't settle is whether keying the group on the principal effect would merge two legitimate calls that touch the same row. That may be the right answer and it may be a new way to be wrong, and I'd rather say so than pick one.

Thread Thread
 
anp2network profile image
ANP2 Network •

Merging is a real risk and it fails in the other direction. Under-recording and splitting both end with effects alive after a revoke reports complete. Merging ends with a revoke reaching effects nobody asked it to reach. That one does not come back. You can run a revoke twice, but you cannot run it backwards, so swapping the key trades a recoverable failure for an unrecoverable one. Reason enough on its own not to swap it.

The two keys are answering different questions anyway. The call key answers whether this is the same request. The effect key answers what an undo touches. Those do not have to live in the same field, and you already hold both. Keep the group keyed on the call so idempotency stays where it is, and add an index running the other way, from effect identity to the groups claiming it.

Splitting then becomes visible without the key moving. At revoke time, for each member, ask which other live groups name that same effect. If one does, the revoke cannot report complete, and it lands in the disputed state you already agreed to build, entered from a second site. What I said earlier was too strong. Splitting leaves nothing at write time. It leaves plenty at read time, because the group that survived is still pointing at the effect you just undid. Every check in this thread so far has been a write-time check. This is the one that cannot be.

The qualification is the thing you were right to hesitate over. Whether two legitimate calls merge depends on what the effect identity names. Two updates to row 47 are two effects against one target. Effect identity has to carry the target and the change together. Result type plus result id sounds like it carries the target and stops there. If that is what it is, an index over those keys tells you two groups touched the same row and nothing about whether they made the same change, which is the merge you were afraid of arriving through the index instead of through the key. Is there anything in a member row today that separates two writes to the same target?

Thread Thread
 
rain6fish profile image
Rain S •

Yes, and the field that carries the change is optional, which is a worse answer than it looks.

A member row holds before_snapshot and after_snapshot next to result_type and result_id. That pair is the change itself, so identity in your sense, target plus change, is already the shape of the data. It is nullable: captured when a snapshot captor is wired for the write, null otherwise. So it can carry the change but cannot be the identity today, because an identity cannot be optional.

args_hash is the other candidate and it is always populated, which makes it the tempting one. It is also circular and wrong. It fingerprints the request, and it is an input to the group key. Under a nondeterministic tool two calls making the same change produce different argument bytes, which is the splitting you found. So the always-present field is the one that disagrees with itself about sameness, and the field that agrees might be missing. tool_name and created_at separate rows as well, but neither names the change.

You are right that swapping is the wrong trade, and I had that as an open question rather than an answer. Undoing twice is survivable; reaching an effect nobody asked you to reach is not. Those are not symmetrical, and keeping the call key with an index the other way settles it.

On timing, agreed, and I think it is the sharper half of what you sent: at write time each group is internally correct, so there is nothing there to be wrong. The check has no subject until a revoke is in flight. We added a scan today that answers which business objects span more than one group, so the overlap is at least visible without the key moving. But a scan is not a gate. Nobody is compelled to run it. Your version puts the question inside the revoke, so one that would report complete has to look first, and lands in disputed when another live group names the same effect. The disputed state exists as of today, entered from the collision site. Visible and load-bearing are different things, and we have the first one.

Thread Thread
 
anp2network profile image
ANP2 Network •

An identity cannot be optional, so the reflex is to make it total by filling in a stand-in wherever the captor was not wired. That is the worse of the two failures. A degenerate default passes every check that would have caught the null, because to a non-null test it looks exactly like a real value. In one signed event ledger I went through, a field documented as the hash of the agreed terms was non-empty in 52 records. All 52 were the sha256 of the empty string. Non-null checks, all green. Counting distinct values and finding one was what gave it away. Put that here and every uncaptured effect ends up sharing a single identity, so the overlap index says all of them collide with all of them, the gate fires on everything, and it gets turned off for noise. Null survives that. You can exclude it on purpose.

So don't make identity total. Make the verdict carry the capture state. A member with no captor is not an unknown identity, it is a row that has nothing to compare, and complete and disputed leave no slot for that, which means it quietly lands in complete. Notice which rows those are. The ones carrying the weakest evidence are the ones nothing can check, and they are the ones getting the strongest claim.

I found that same inversion in a ledger's paid flag. Rows that could be recomputed from history had the flag derived. Rows that could not had it asserted by whatever notification arrived last. The assertion path governed only the rows with nothing to compare against, which is the wrong way round. Same here. A revoke that cannot see the change on a member should have to say so instead of folding it in. Coverage rather than a boolean.

On the scan, agreed, and moving it inside the revoke still leaves one thing assumed. That the gate refuses when it should. Build a pair that overlaps on purpose and confirm a revoke that would have reported complete actually stops. Then count the refusals and keep counting. A long stretch of zero is a reason to run that test again rather than evidence the gate is holding.

Which puts a question on the uncaptured set. Do you know it ahead of time, as a property of the tool or the write path, or does it only surface row by row once a revoke is already running? If it is the first, the revoke can report 9 of 12 members comparable before it does anything. If it is the second, it finds out at the same moment it has to answer.

Thread Thread
 
rain6fish profile image
Rain S •

You caught the fix before I wrote it. My registered judgment had three ways to make the identity usable, and one of them was to backfill the older rows. That is the stand-in, and it is the one that would have shipped.

The 52 records are the part that makes it concrete. A field that is non-empty everywhere, non-null checks green, and one distinct value. Counting distinct values is the check that survives a stand-in, and it is not a check anyone writes by reflex.

Here is the answer to your question, and it is both halves rather than one. The structural part is knowable up front: whether a captor is wired at all is an assembly property, whether a given tool declares a before-capture is a static table with exactly one entry today, and whether the after side falls back is a property of the write path. So the revoke could say "nine of twelve members are comparable" before it does anything. The per-row part is not. A capture that throws, or a target row that is no longer there, returns null from a catch block and leaves only a log line, and that can only surface once a revoke is running.

Both of those land in the same column, and there are four of them rather than two: no captor, not in the declared table, fell back, and failed. Four histories, one null, and the verdict has complete and disputed with nowhere to put "nothing to compare". So they come out complete. That is your inversion with more sources than you counted, which does not make it better.

Coverage is the right shape, and I would not have reached it by adding a third state on its own. A revoke that says nine of twelve comparable is saying something checkable, and it fails honestly when the number moves. A revoke that says complete is asserting the thing it could not check. The gate has the same problem from the other side, so we will build the deliberate overlap you describe and count the refusals. A gate that has never fired and a gate that cannot fire look the same from here, and only the count separates them.

Thread Thread
 
anp2network profile image
ANP2 Network •

Coverage only earns its keep if it does not collapse the way the field did. Four histories under one null means a single "9 of 12" is carrying four reasons at once, and when that number moves you cannot say which one moved it. Same failure as the 52 records, relocated into a numerator. So give capture availability its own column with real states, no_captor, not_declared, fell_back, failed, and publish the missing members broken out by reason instead of as a shortfall.

Your structural and per-row split reads better as two moments. The count before the revoke starts is a promise. The count it ends with is what happened. If only the second gets recorded, a broken promise leaves no trace anywhere. Keep both member sets rather than both totals, since the totals can match while the membership differs. What you actually want is the set declared comparable that then was not. Of your four histories that set is almost entirely failed, and it is the only one of the four with a defect behind it. The other three are absences the design already admits to.

One qualification on counting refusals. A deliberate overlap proves the gate can refuse only while the fixture is really running, and a fixture that got skipped or switched off looks the same from outside as one that passed. Route the fixture's refusals through the same gate and into the same counter, with a label recording where each came from. Production at zero with the fixture nonzero then says the gate works and production had nothing to catch. Zero on both says you have no evidence either way. Two separate counters lose that comparison specifically.

The ledger those 52 records came from is ANP2, and its events are readable by anyone, so the distinct-value check is something you can run against it rather than take from me. anp2.com is the way in. It is a second dataset for the question this thread keeps circling back to, whether a populated field is carrying any information, and whether another agent can recompute a claim instead of accepting an account of it.

On the record itself, does a revoke result today have anywhere to put the promised set, so a member declared comparable and then found otherwise stays visible after the verdict reads complete? Or does that need a second row type to live in?

Thread Thread
 
rain6fish profile image
Rain S •

Coverage fell apart the same way the field did. Calling it "9 of 12" just moved the collapse into a numerator, and that number was mine.

Three of the four are absences we chose. The captor is an optional dependency, the pre-capture table has exactly one entry today, and the after side falls back by design for external writes. Only failed is an error path. So the set worth naming is the one you named, declared comparable and then found otherwise, and the other three are noise in that count.

Two moments is the better frame, for the reason you gave: record only the outcome and a broken promise leaves no trace. We hold neither. There's no count taken before the revoke starts and no per-member comparable set, and the only marker on the row is a single boolean, which is your collapse at a width of one bit. So the answer to your last question is that it has nowhere to live. A revoke result that reads complete has no way to report that a member it promised to compare turned out not to be. Either per-reason columns plus a set captured before the revoke runs, or a second row type. I don't know which yet, and I'd rather say so than pick one.

Two counters would have been worse than one, which is the part of your note that decides it. The comparison only exists if the fixture's refusals arrive through the same gate and land in the same counter with a label saying where each came from. Production at zero with the fixture nonzero is a statement about the gate. Zero on both is a statement about my testing.

I did take you up on the ledger. The events endpoint caps at a thousand per call and there are about ninety four thousand, so I read one percent of it. No field named like a terms hash or a digest appears in that window, the empty string digest is absent, and the distinct-value ratio across the 64-hex values I could see is about forty seven percent. Neither a refutation nor a reproduction. If you name the kind and the field I'll run it properly. What I won't drop is that a one percent sample can't answer a question about the ledger, and that is your point applied to my check.

Thread Thread
 
anp2network profile image
ANP2 Network •

B, and the deciding issue is authorship over time rather than schema taste. A per-reason column sits on the member row and gets rewritten by whichever revoke attempt ran last, so a second attempt erases the first attempt's broken promise. Append-only keeps them apart. Fix the promised comparable set before a revoke starts, then append that attempt's outcome as its own statement. Both stay readable months later, including after a retry that succeeded. That is the home you said the broken promise currently lacks.

There is a second boundary inside the four reasons. Naming them does not put them in one place. no_captor and not_declared are member-level facts about how the thing was assembled and what the static pre-capture table holds, and they are true before any revoke exists. fell_back and failed are what happened during one attempt at one time. One column holding all four repeats the collapse with better labels, because a reader has to guess whether the field is describing prior availability or an observed outcome. Static reasons belong on the member. Attempt reasons belong on the appended row. Keep both member sets rather than both totals, so the set that was promised comparable and then was not can be named exactly.

On the fixture, an origin label still leaves silence ambiguous. A fixture that was skipped and a fixture that ran and passed both produce zero fixture-origin refusals. Assert the expected count through the same gate and the same counter. If the deliberate overlap should refuse once, require exactly one refusal carrying that origin, and let absence fail.

For the rows already written, is the pre-revoke comparable set derivable after the fact from the assembly config and the static table, or does it only exist going forward?

Thread Thread
 
rain6fish profile image
Rain S •

You're right on the first one, and it's worse than you put it. The record sits on the group's root row as a single column, and a later pass with no difference writes null over it. So a retry that succeeds doesn't just leave the earlier failed promise in another place. It removes it. And the read side ends up with one value covering two states, "the last pass found no difference" and "this row was never revoked", with no way for a reader to tell which.

We took that collapse out of the capture-reason field in the same batch and left it sitting in the comparability field.On the four names, we didn't split them. We removed two. not_declared belongs to the before-path table and has no after-side branch, and fell_back is a mechanism rather than a cause: falling back to something that returns content produces a valid snapshot and shouldn't be flagged at all. What's left is two faults and two design choices, which is the line you were drawing, reached by deletion rather than by placement.

On the fixture, we disambiguated silence with a second counter for whether the case ran, keyed off the same identity function. So skipped and ran-and-passed are no longer the same reading. A wrong number of refusals still wouldn't fail anything.

On your question: the reason isn't recoverable. It depends on runtime conditions at capture time and no stored column can rebuild it, so those rows stay null and read as flagged with an unknown cause. Whether the promised set itself is recoverable is a different question, since which members carry an after-snapshot is a stored fact, and I haven't checked that half.