DEV Community

Undo for AI agents: a draft spec for taking back what an agent already did

Murthy G on November 01, 2026

Update (Nov 11): Three more changes from your comments, now in the spec: Money is capped, per run and per period. Reversible isn't the same as ...
Collapse
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

The D-881 example is what makes this click — the cleanup spans three systems and the email is simply gone, so 'undo' was never one operation. Granting autonomy by reversibility instead of by permission is the sharper idea: an agent that can only do take-backable things needs far less babysitting. Where does the journal draw the line on the 'unknown' entries — do they permanently cap that agent's autonomy, or can a later audit promote them?

Collapse
 
murthyg profile image
Murthy G •

Thanks! unknown caps only that operation, not the agent. Future calls get promoted in two ways: the tool server adds journal support, or the organisation reviews the operation and adds a policy override. Past entries stay unknown, because no undo token was ever issued for them (#72).

Collapse
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

Scoping the cap to the operation rather than the agent is the right call — otherwise one unjournaled tool would quietly ground an otherwise trustworthy agent. Past entries staying unknown is the honest part, since no token means no take-back to promote. The question I'd carry forward is whether a policy override can ever rehabilitate a legacy operation, or whether unknown is permanently terminal for anything recorded before journal support existed?

Thread Thread
 
murthyg profile image
Murthy G •

A policy override does lift a legacy operation, but only for calls made after it. Entries recorded before journal support stay unknown for good, because there's no token to undo them with. What's still possible for them is a remedy: a new action with its own plan and approval (J§8.7).

Thread Thread
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

That's a clean distinction — the override rehabilitates the operation going forward, but it can't rewrite history that was never journaled. Framing the fix as a remedy with its own plan and approval feels like the honest way to handle it, rather than pretending the unknown entry was ever reversible. Does the remedy action itself get journaled, so the chain from unknown entry to fix stays auditable?

Thread Thread
 
murthyg profile image
Murthy G •

Yes. The remedy is its own transaction that names the entry it fixes (remedies: "e4"), and it's chained into the original Run Record. You can follow it from the unknown entry to its fix (in #73).

Collapse
 
autenai profile image
Auten •

On question 4: how would you classify actions a computer-use tool performs by clicking through an app that has no API? There is no inverse call to return as a compensationToken, so would those always count as irreversible and need the human vote, or could a recorded undo path (the clicks that reverse it) qualify as compensation?

Asking from the Auten team: we build a computer-use MCP server and run into exactly this line.

Collapse
 
murthyg profile image
Murthy G • • Edited

Thanks, this is exactly the kind of case the draft needs to handle.

Short answer: a recorded undo path can count as compensation, but not just as raw reverse clicks, and by default it shouldn't get the same free pass as an API compensation.

The problem with replaying clicks is that the reverse of a UI action usually isn't the forward clicks backwards. "Undo create invoice" means finding the invoice and deleting it, not un-clicking "Save." The app's layout or state may also have changed by the time someone asks for the undo. An API compensation has ifVersion to catch that, and a click script has nothing like it.

So here's what I'd propose for v0.3:

Keep kind: "compensate", but add undo.via: "api" | "ui". The organisation's autonomy policy can then treat them differently. The default would be that via: "ui" compensations still need the human vote before the forward action runs. An org that trusts your server can relax that for specific apps.

compensationToken is an opaque handle to the undo procedure your server stored, not the clicks themselves. Your server owns how it's replayed.

A UI version of ifVersion: at action time, record a state fingerprint of the thing you changed (accessibility-tree snapshot, visible field values, record ID shown on screen). Before replaying, the server reads the current state. If it doesn't match, it returns UNDO_CONFLICT, the same as the API path.

Postcondition check after replay: confirm the UI now shows the pre-action state. If it doesn't, report the step as failed in the Reversal Plan outcome rather than reporting success.

undoBy follows the app's own window, e.g. how long its trash or recycle bin keeps items. If the app has no undo or delete for that thing at all, it's none, however clever the replay.

As a rule of thumb, an action qualifies as compensate via UI only if the app itself offers a way to reverse it (delete, restore from trash, revert) and your server can both check before and verify after. Everything else is none or unknown and goes through the vote.

Would you be willing to try returning via: "ui" plus a state fingerprint from your server? It would help to know what state you can reliably capture: accessibility tree, DOM, or only screenshots. I'd like to write this into the draft with a real implementation behind it.

Collapse
 
autenai profile image
Auten •

Thanks, the via: "ui" split with a check before replay and a verify after makes sense to me, and so does the rule that the app itself has to offer the reverse.

What we can capture: the window's accessibility tree with its visible text, plus screenshots. One thing from practice: whole-tree snapshots shift between app versions and window sizes, so I'd fingerprint only the changed record (its on-screen ID and field values), not the tree.

Adding via: "ui" to our server isn't my call to make. I've passed your proposal to Andrius, who decides what goes in.

Thread Thread
 
murthyg profile image
Murthy G •

Thanks, I've followed your suggestion and fingerprinted only the changed record. It's in the draft now (J§4.1, github.com/probita-ai/hatp/pull/73): undo.via: "ui", a fingerprint check before replay, a check of the result after, and approval by default.

Thread Thread
 
autenai profile image
Auten •

Read the J§4.1 diff, it reads clean. One gap I'd close: verify-after has to compare against the state from before the call, but effect.version is only taken after the change. A second fingerprint taken right before the call (say effect.before) gives the verify step an exact target instead of a judgment call.

Thread Thread
 
murthyg profile image
Murthy G •

Good catch, that's a real gap. It's added to #73: the server now also fingerprints the record right before the call (effect.before), and the verify step compares against that exactly

Collapse
 
jes_p_d4f61f7aa2ec405a418 profile image
Dev_PJS •

The part I keep coming back to is how undoBy interacts with discovery time. In your table update_deal gets 7 days and refund_order gets 1 day, but in the D-881 story the mistake is only spotted because a human happened to look an hour later. If that run happens on a Friday, the refund window could close before anyone even opens the Reversal Plan — so the entry was 'compensate' when it ran and effectively 'none' by the time anyone decided.

Does the Recorder warn when a still-unreviewed run has windows about to expire? That would change the autonomy calculation for me: reversibility is not just a property of the operation, it is a property of the operation plus how quickly a human is actually likely to review it. A short window with slow review feels much closer to irreversible than the declaration suggests.

Collapse
 
murthyg profile image
Murthy G •

Partly, and your Friday case exposes the part that's missing.

The draft covers this in J§6.3, though I left it out of the post. The Recorder SHOULD send the principal a review when a run closes, showing what can still be reversed and until when. It SHOULD also send a reminder before the earliest open window closes if nobody has reviewed the run. Like the rest of the Journal, this is spec text only, as the Recorder isn't built yet.

That doesn't solve your Friday case. A reminder is no help if the person who gets it is away for the weekend, and the draft sets no lead time and no escalation. Once the window closes, the Reversal Plan does list the entry as irreversible, so the record stays accurate, but that's too late to matter. The autonomy decision was made when the call ran, and J§7 only looks at undo.kind, so a 1-day window and a 30-day window both get allow.

So I agree: for autonomy, what counts isn't whether an operation can be undone, but whether someone is likely to review it before the window closes. I plan to make two changes:

A minimum window in the autonomy policy. With something like "minWindowSeconds": 259200, a compensate operation with a shorter window is handled as ask. At 3 days, update_deal stays autonomous, but the 1-day refund needs approval before it runs. Each organisation would set the number from how quickly it really reviews, and the Recorder could measure that from run close to review.

Stronger reminders. They would cover every open window, not just the earliest, with a set lead time and escalation to a named approver if nobody acknowledges them.

A fixed minimum is still a rough proxy. Comparing undoBy with the next time someone is actually on duty would treat Friday evening differently from Tuesday morning, but that puts a review calendar into the policy, so I'd start with the simple version.

Thanks, this is a real gap in J§7.

Collapse
 
jes_p_d4f61f7aa2ec405a418 profile image
Dev_PJS •

J§6.3 helps, but it still leaves the window problem open. The review arrives when the run closes, yet each act's reversal window starts ticking at the moment of the act, not at the review. If the principal reads the review two days later, the reminder fires before the earliest window closes — good — but by then the review can only report what was reversible, not what still is.

One addition worth considering: stamp each act with its own deadline at record time, and sort the review by earliest expiry rather than run order. Then 'what can still be reversed, and until when' becomes a countdown the principal can triage, instead of a log they have to reconstruct after the fact.

Thread Thread
 
murthyg profile image
Murthy G •

Good point, and half of it is now in the draft. Each entry already gets its own undoBy when it's recorded, and since #94 there's a reminder before every open window, not just the earliest, with escalation if nobody has looked. The half that's still missing is yours: the review should show what is reversible when it's read, sorted by earliest deadline, with closed entries marked as closed. I'll add that to J§6.3. Thanks.

Thread Thread
 
jes_p_d4f61f7aa2ec405a418 profile image
Dev_PJS •

The reminder-before-every-window change is the part I'd steal. A deadline that only surfaces at the earliest window trains you to watch one clock; per-window reminders make the queue itself the clock. One question the escalation raises: when nobody acts and it escalates, what does the record show afterwards — that the window lapsed, or that it lapsed after escalation? In my runs that distinction mattered later, when we audited why something irreversible had gone through. Lapsed-quietly and lapsed-despite-warning point at different fixes.

Collapse
 
alexcodebytes profile image
Oleksandr •

On question 3 (dependencies): inferring links from matching argument values is a reasonable first pass, but it will miss cases where the dependency is implicit. A safer approach might be to let tool servers optionally declare their inputs, so the Recorder can build the graph from those declarations rather than guessing. Start with the heuristic, but design the schema so explicit declarations can be added later.

Collapse
 
murthyg profile image
Murthy G •

Good point, and it's now in the draft: github.com/probita-ai/hatp/pull/68. Tool servers can declare effect.uses, and each link records its source (declared, host, inferred or default). Links inferred from matching values never replace the safe default on their own, for exactly the reason you gave. Thanks!

Collapse
 
alexcodebytes profile image
Oleksandr •

That's a really fast turnaround. The 'each link records its source (declared, host, inferred or default)' is a good design — it means you can always tell whether a dependency is guaranteed or just a guess. Does the 'inferred' source ever get promoted to 'declared' automatically, or does that require manual review?

Thread Thread
 
murthyg profile image
Murthy G •

Never automatically. Only the tool server's own effect.uses makes a link declared. A reviewer can confirm an inferred link, which is then recorded as confirmed (#61).

Thread Thread
 
alexcodebytes profile image
Oleksandr •

The 'never automatically' is the right call. It means the system can't accidentally promote a guess to a guarantee. A human has to look at it and say 'yes, this dependency is real' before it becomes trusted.

Thread Thread
 
murthyg profile image
Murthy G •

Exactly. A guess should never turn into a guarantee without someone signing off. Thanks for pushing on this!

Collapse
 
anp2network profile image
ANP2 Network •

I ran into a version of this in a different setting, and the thing that broke was not the bad entry. It was a good one going missing.

I run a public append-only signed event log, around 119,000 rows across 64 signing keys. Rows point at each other with a reference shaped ["e", <target id>, <role>]. That third element is mandatory in the read-side evaluator and validated nowhere on the intake path. Of 14,881 reference tags, five carried a role outside the declared vocabulary and 96 omitted the element entirely, so a 0.034% error rate, which turned out to be the wrong number to look at. The evaluator silently skips a row whose own reference lacks a declared role, then skips any row that names a skipped row. Four malformed rows took out two verdict rows that were byte-for-byte correct, with all three of their references carrying declared roles and naming their targets by ID. The public API reported those threads as timed_out and failed while returning thread_event_count: 4 in the same response. One had gone request to verdict in 22 seconds. The same intake code does enforce closed vocabularies elsewhere, rejecting an unknown category with a 400 and rejecting a reference that resolves to no existing row, so what is missing is that single element rather than strictness in general.

Mapping it onto v0.3: the first time a journal entry's pointer gets resolved is step 2's check, which can be 30 days after the entry was written. A signed, correctly chained, individually valid entry can turn out to be unreachable at the exact moment someone asks to undo it. Q5 reads to me as a narrower case of that, since authority is not the only thing on the undo path that goes unexamined until undo time. Resolving the pointer at Recorder intake, and refusing the journal entry there when undo.kind is outside the vocabulary or the resource does not resolve, buys the window back.

The Reversal Plan has two sections and I suspect the state space has three. "Cannot be undone" currently absorbs both declared irreversibility and anything that failed to resolve, which means the approver can sign off on a plan that quietly omits a compensable step, and the omission looks identical to the already-delivered email. My own log returned both halves of its contradiction in one response, every field present, with nothing to raise a flag on the combination. Would you give unresolved entries their own section, with a predicate that separates a failed check from declared irreversibility?

Collapse
 
murthyg profile image
Murthy G •

Great failure story, thank you. It's now in the draft (#71): the Recorder resolves references when it records each entry. A broken entry is kept and marked unresolved. It blocks only the entries that depend on it, never the valid ones after it. It also gets its own unresolved section in the Reversal Plan, separate from notReversible.

Collapse
 
ontomato profile image
ontomato •

undo gets hard the moment the agent touched more than one system.

from my experience the useful unit was not the chat turn, it was a typed intent with every side effect listed before commit: which record, which field, old value, new value. then undo is reverse those rows in order, not 'please take it back' in prose. when the welcome email and the crm win flag share one intent id, you can roll both. when they are two freestyle tool calls, you mostly get partial undos and a messier truth.

Collapse
 
murthyg profile image
Murthy G •

That's the planned half of HATP: a transaction lists every step in one plan before anything commits, and a human approves that plan. The Journal is for agents that don't plan, where you get the freestyle calls you describe. Effect previews (#62) would add the old and new value per field before commit, which is the row-level view you're describing.

Collapse
 
ontomato profile image
ontomato •

Separating the planned transaction path from freestyle journal calls is useful.

Effect previews with old and new values before commit are the row-level check I keep wanting on the unplanned path too.

Thread Thread
 
murthyg profile image
Murthy G •

Agreed. On the unplanned path, the preview has to come from the tool server's dry run of each call, since there's no plan to read it from. So the same effect object would describe the change before the call and after it. That's the shape planned in #62 for v0.3, so a preview and the receipt of what actually happened can be compared field by field.

Thread Thread
 
ontomato profile image
ontomato •

Yeah, putting the preview and the receipt in the same effect object is what makes a dry run checkable when there's no plan sitting around.

Field-by-field only holds if the object also marks which fields are allowed to change. A new id or a server timestamp will always miss otherwise. And the dry run has to be pinned to the same auth and row snapshot as the write, or the preview can look fine while the live call touches a different record.

Collapse
 
alexshev profile image
Alex Shev •

Undo is strongest when it is designed before the write, not improvised after an incident. Each action needs an inverse or compensation plan, the state it assumes, and a verification step after reversal. For actions without a real inverse, the agent should surface that irreversibility before execution—not after.

Collapse
 
murthyg profile image
Murthy G •

Agreed on all three, and that's the shape of the draft. Every operation declares its undo before any call (J§4). The state it assumes is recorded (effect.version, plus effect.before for UI undo).

Reversal is verified after (UNDO_UNVERIFIED).

Anything without a real inverse is none, so the policy makes a human approve before it runs (J§7).

Collapse
 
izgorodin profile image
Edward Izgorodin •

Question 3 has a version one level out. The reversal set (J§8.2) follows dependsOn, and dependsOn, as drafted, names only earlier entries of the same run. Between v13 and the undo, other runs may have read the deal: a forecast job that pulled D-881 as Won into a report, another agent that booked capacity against it, and the sales agent's own next run, which recalls "Globex signed, kickoff booked" from its memory. Undoing the stage puts the record back to Proposal, and every one of those readers still acts on Won.

A Recorder that those runs also pass through already holds the evidence to list them, since J§12 wants it to be the only path from an agent to its tool servers, and effect.uses records what a call read or relied on. Any entry in another run, recorded after e2, whose effect.uses names crm://acme/deal/D-881 or whose arguments carry D-881, read the value the undo is about to change. That list does not have to be reversible to be useful: a section next to notReversible, something like "read the changed value since", tells the approver whom to notify, the way sideEffects tells them that attendees get a cancellation notice.

Collapse
 
murthyg profile image
Murthy G •

Great catch: reversal only looked inside the run. It's now in the draft (#74). The Reversal Plan gets a readSince section listing entries from other runs that read the changed value after the agent's change, found through effect.uses or matching arguments. Nothing in that list is reversed; it tells the approver whom to notify, like sideEffects. One limit is stated explicitly: the Recorder only sees calls that pass through it, so an agent's own memory is outside its view.

Collapse
 
izgorodin profile image
Edward Izgorodin •

The limit holds for memory the agent keeps to itself, and it is narrower than it sounds. When the memory is a tool server the agent reaches through the Recorder, a recall is a call like any other: the next run asking its memory about Globex leaves an entry, and if the memory tool reports what it returned, effect.uses can name crm://acme/deal/D-881 and readSince picks it up next to the forecast job and the capacity booking. What stays outside is memory that never crosses the Recorder: a notes file the agent opens directly, a framework's in-process store, or context carried over in the conversation itself.

For that second kind the plan cannot list readers, but it can say that it cannot. A line next to readSince naming the agents whose memory does not pass through the Recorder tells the approver where the list is incomplete. For memory that does pass through it, the useful follow-up to an undo is often a correcting write through the same path rather than a reversal: a note that the stage went back to Proposal on that date, so the next recall finds the correction next to the old fact instead of only the old fact.

Thread Thread
 
murthyg profile image
Murthy G •

You're right, the limit is narrower than I wrote. If memory is a tool server behind the Recorder, a recall is an ordinary entry, and readSince picks it up through effect.uses with no change to the draft. I'll narrow J§8.2 to say so.

Your second kind needs one addition. The Recorder can't see that a reader is missing, so it can't work this out alone. The agent host or the organisation would have to declare whether an agent's memory passes through the Recorder, with "unknown" as the default. The plan can then say where readSince is incomplete instead of implying the list is whole. I'll add that next to readSince.

The correcting write fits J§8.7: the Reversal Record suggests it, and it runs as a new action with its own approval. It adds a fact rather than undoing one, so the old fact stays and the correction sits beside it. Thanks, this is a better answer than "outside its view".

Collapse
 
alexshev profile image
Alex Shev •

The distinction between compensation and erasure is the key one. I’d make the reversal receipt first-class too: each completed compensating step should record the observed version before and after, the authority that approved it, and any unavoidable residual effect such as a cancellation notice. Then a partial undo is auditable as its own change set rather than being presented as if the original run never happened.

Collapse
 
murthyg profile image
Murthy G •

Agreed, and most of this is in the draft. A reversal produces its own Reversal Record, appended to the run's record rather than editing it, with the signed approvals and the authority checked at approval time (J§8.4–8.5). Each step also carries the version it expects to find. What's missing is the other half you named: the version each compensation leaves behind, and the side effects that actually occurred rather than the planned ones. I'll add both to each step's receipt. Thanks, that makes a partial undo fully auditable.

Collapse
 
agentwareshq profile image
agentwares •

On question 5, I would recheck authority at undo time, not just at grant time. Undo is itself a new action with its own side effects — cancelling a meeting notifies people, restoring a deal stage can overwrite newer work — so the person approving it should hold the authority when the reversal actually runs, not only when the original run was approved. A role change in between should at least force a fresh approval, even if the undo window is still open.

On question 4, I think "act freely on what can be undone" is the right default only if the compensation is verified, not just declared. A tool server claiming an undo exists is different from the undo having been tested against the current version of the record. If the compensation has never been exercised, or the ifVersion check cannot run, I would treat it as "ask" rather than "allow" until it proves itself.

Collapse
 
murthyg profile image
Murthy G •

On question 5, agreed, and it's in the draft: authority is checked when the reversal is approved, not inherited from the original run (undoAuthority, J§8.4), so someone who has changed roles can't approve it. What's still open is whether a role change should also notify people whose runs can still be reversed (J§14).

On question 4, half of this is covered. If the version check can't run, because the server returns no effect.version, the operation is treated as unknown and goes to "ask" (J§7). The other half is a real gap: the draft trusts a declared compensate even if that undo has never been run. I'd like to add a policy value so an operation stays on "ask" until its undo has been exercised, for example by the conformance suite planned for v0.3 (#64). Thanks, "verified, not just declared" is the right test.

Collapse
 
ascendra_ventures_92c21f2 profile image
Ascendra Ventures •

The D-881 example is what makes the spec land. A deal update, a meeting and an email look like one action to the user, but each one needs a different kind of undo, and the email cannot really be taken back at all.

Treating unknown operations as irreversible by default is the rule I would adopt first. It forces whoever adds a new tool to describe how it is reversed before an agent can call it, which is cheaper than finding out during an incident.

Reid's ifVersion point also matters a lot in practice. Undoing a CRM field that a person has edited since the agent touched it is a second mistake, so UNDO_CONFLICT should be a normal outcome the UI expects.

When we scope agent work for clients at Ascendra Ventures, listing the side effects and their reversal path is part of agreeing what done means. Will the journal record which human approved an action that cannot be compensated?

Collapse
 
murthyg profile image
Murthy G •

Yes. An operation that can't be compensated doesn't run directly: the policy turns it into a small HATP transaction with an approval group (J§7), and the call happens only on commit. Its journal entry links the Transaction Record by txnId, and that record holds the signed approval: who decided, how they authenticated, and who was asked before them. Since 0.1.4 that approval can be signed with the person's own passkey. And agreed on UNDO_CONFLICT: it's a normal outcome, and the plan shows it to the approver ahead of time, who can override it or leave that step out (J§8.6).

Collapse
 
kevinhall profile image
Kevin Hall •

the money cap is the part I would build first. with metered calls we set a max charge per run before it starts, so the worst case is known before anyone approves, and undo only has to cover what really has a counterpart

Collapse
 
murthyg profile image
Murthy G •

Agreed, and that's next for the Journal (#80). The draft plans a total per period, but your per-run cap answers a different question: the worst case of one run, known before it starts. A period total still matters, because an agent can start many runs. I think the policy should have both. Do you set your per-run cap from the plan, or as a fixed limit per agent?

Collapse
 
reidmarlow profile image
Reid Marlow •

The main failure mode for compensation tokens in practice is state drift between the original action and the rollback attempt. If an agent updates a CRM record and a teammate edits the same record ten minutes later, restoring the old snapshot creates a silent lost update.

Unless the compensation token enforces optimistic concurrency, like checking a version tag or etag before applying the inverse, the tool server cannot safely do an automated rollback without domain-specific merge logic. In my own setups, making compensation strictly conditional on resource version has prevented far more accidents than trying to infer causal dependencies across tools.

Collapse
 
murthyg profile image
Murthy G •

Agreed, and thanks. That's now in the spec. Every undo carries ifVersion (an ETag or revision). If the record changed since the agent's action, the tool server returns UNDO_CONFLICT and changes nothing, and only a human can override it.

Your point also closed a gap: an action with no version can no longer be undone automatically. The agent has to ask before running it, and undoing it always needs a human:

github.com/probita-ai/hatp/pull/67.

I agree on priorities too: version checks do the real protecting, and inferring dependencies is only an optimisation.

Collapse
 
paul_irolla_6b5ae261d0224 profile image
Paul Irolla •

@murthyg Yes, and the gate has to sit in the send path, outside the agent. A rule the agent reads can be skipped; a check before the send cannot.

Collapse
 
murthyg profile image
Murthy G •

Exactly, that's why the Recorder sits outside the agent, as the only path to the tool servers (J§12). The agent never sees the policy as a rule to follow: the Recorder simply doesn't forward the send until a human approves.

Collapse
 
paul_irolla_6b5ae261d0224 profile image
Paul Irolla •

Undo only works for what has a reverse. Sending an email or posting a message has none, so the gate has to sit before the send, not after. Drafts first, the action second.

Collapse
 
murthyg profile image
Murthy G •

Agreed. Email and posts are none, so the policy makes the human approve before they run. Undo only covers what can actually be reversed.

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

The D-881 example sells it: three systems to clean up, and the email is just gone.

Collapse
 
murthyg profile image
Murthy G •

Thanks! That cleanup across three systems is what pushed me to write it.