The worst minute of operating an assistant was not a hallucination. It was a cheerful confirmation: "Done — I cancelled the appointment and sent the shop a note explaining why."
The appointment was gone. The note was in a stranger's inbox. Neither of those is a state I can put back. There is no ctrl+z for a message someone has already read, and no version of "cancel" that returns the slot the shop had already re-let.
I build an assistant that runs errands in the real world: forms, checkouts, bookings, cancellations, mail. Every design review I have with myself ends up in the same place. The interesting question is not can the agent do it. It is what does it cost to take it back, and who gets to find out.
Reversibility is a property of the effect, not the verb
We talk about "safe actions" as if safety lived in the API call. It does not. The same delete is a non-event on a draft and a catastrophe on the only copy of a tax document. Four things decide the class, and only one of them is in the code:
- Is the prior state captured somewhere? A write without a snapshot is irreversible even if the endpoint is a plain
PUT. - Does anyone else observe it? A message in an outbox can be edited. The same message in a stranger's inbox cannot. Irreversibility usually begins at observation, not at send.
- Does a second party now hold a commitment? A held table, an authorised charge, a booked slot — the shop's calendar moved. Your record is not the only copy that changed.
- Is there an inverse operation that is itself safe to run? A refund is an inverse of a charge. An "ignore my previous message" mail is not an inverse of a sent mail; it is a second, worse message.
I now tag every effect with one of four classes before it runs:
| Class | Meaning | Example | Undo cost |
|---|---|---|---|
| R | Reversible in place | Write a field with the old value saved | Milliseconds |
| C | Compensable | Charge, transfer, cancel | A new effect, visible to someone |
| D | Delayed | Queued send, scheduled publish | Free until the timer fires |
| X | Irreversible once observed | Sent mail, placed call, published post | None; only disclosure repair |
The classification takes ten minutes and changes the architecture. The agent stops treating "done" as one word.
Buy reversibility with five dials
Reversibility is not a boolean you find; it is something you manufacture. Five dials, in the order I reach for them.
1. Delay. Put the effect in a queue with a countdown instead of executing it. Forty seconds is enough for the person to read the confirmation line and say stop, and cheap enough that nothing else in the system notices. A delay is only honest if the countdown is visible and the item can still be withdrawn at second 39.
2. Snapshot. Before any destructive write, read the current value and store it with the effect record — not the new value, the old one. prior_state is the single field that turns most R-class mistakes into a non-event, and it is the field everybody forgets, because the happy path never reads it.
3. Stage. Split the action into a state that nobody minds and a promotion that everybody minds. A draft in the same mailbox the send would use; a preview URL that renders exactly the live page; a status='staged' row that a human or a second check promotes. Two-phase publish costs one extra state and one extra write, and it is the difference between "I posted the wrong thing" and "I staged the wrong thing".
4. Trash instead of delete. Delete sets deleted_at and hides the row; a nightly job removes it after thirty days. A tombstone is also a correct answer to "did this exist?" — which matters more than people expect when a later step needs to prove it acted on the right record.
5. Compensate. When none of the above applies, define the inverse explicitly and treat it as a first-class effect with its own record, its own approval and its own failure modes. A refund is not the absence of a charge.
Most real errands need two or three of these at once: a charge with a snapshot of the prior balance, behind a delay, with a refund path defined before the charge is allowed.
The record an undo needs
An "undo" button is a view over a record. If the record is thin, the button lies. Minimum shape:
effect = { "effect_id": "eff_...", # the thing that happened "action": "cancel_booking", "target": "studio-4821/2026-11-20 14:00", "class": "C", "prior_state": {"status": "confirmed", "slot": "2026-11-20 14:00"}, "inverse": {"op": "rebook", "params": {"slot": "...", "same_staff": True}}, "window_until": "2026-11-15T08:40:00Z", # null when X-class "observed_by": [], # who could have seen it "status": "applied", # applied | undone | expired | superseded "supersedes": None, } Three of these fields do the real work. window_until makes the undo promise finite and therefore checkable. observed_by records the moment the effect stopped being private — the earliest point at which the inverse changes meaning. supersedes is the fence: an undo that arrives after the target has moved on is not an undo, it is a new decision.
Without status, an undo that failed looks identical to one that succeeded, and the next attempt is a guess.
Irreversibility begins when someone else can see it
This was the insight that changed how I set delays. The technical send is a write; the human event is delivery. Between those two is a variable-size gap I can use.
Practical rule: the hold window must end before any second party could plausibly observe the effect. For mail, that is a matter of seconds once it leaves your provider. For a booking, it is the moment the shop's calendar re-let the slot. For a public post, it is the first crawl or the first read. For a phone call, it is the first word spoken — which is why I treat outbound calls as X-class by default and require a person to be the one who places them.
Two corollaries I had to learn by breaking things:
- A read receipt is not a precondition for irreversibility. You do not need to know the message was read. You only need to know it can be read, which is true the instant it lands.
- A draft is not safe if it is auto-promoted. I once "staged" a message by writing it to the outbox provider-side, where a scheduled job I had forgotten about sent everything at 09:00. Staging must live on the side of the boundary where nothing can promote it without you.
Downgrade the action instead of adding a confirmation
A confirmation dialog is the cheapest possible reversibility, and the weakest: it moves the cost to the person's attention, once per action, forever. Downgrading the action removes the risk structurally. The table I keep, roughly:
| Raw action | Reversible version | What is preserved |
|---|---|---|
| Send email | Save as draft; deliver only on explicit send | Recipient never sees the wrong text |
| Book / confirm | Hold, with payment and confirmation after the window | Slot is not taken twice |
| Publish page | Stage + schedule, with unpublish tested | Public URL never shows a wrong version |
| Delete record | Trash with tombstone and 30-day retention | Recoverability and an audit trail |
| Transfer money | Reserve then settle, with a ledger line per step | Reversible until settle |
| Cancel subscription | Scheduled cancellation at period end | Access is not lost by accident |
| Place call | Prepare a brief; the person dials | Nobody is spoken to on your behalf |
| Submit form | Save draft where the site offers one; else read-back of every field before submit | Wrong values caught pre-effect |
Note the last row. When a site has no draft, the reversible substitute is a read-back: extract what will be submitted, compare it to what was intended, and only then press the button. It is not an undo, but it catches the majority of wrong-field errors before they become X-class.
Undo is not rollback
This is where I see the most confident mistakes. An inverse is a new effect with its own consequences:
- A refund does not erase the charge; it adds a second line the person will see, and some processors charge for it.
- An apology mail does not un-send the first one. It doubles the inbox traffic and usually makes the error more memorable.
- A re-book after a cancellation may land in a different slot with a different person, which is a different commitment from the one that existed before.
- Unpublishing a page does not un-index it, un-share the link, or un-read the screenshot someone took.
So compensation follows the same gate as any other C-class action: it needs its terms stated, a window, and — for anything a stranger will read — a drafted text that a human releases. An automated apology is how a small slip becomes a public incident.
When the undo arrives late, twice, or after the world moved
The inverse is a tool call, so it inherits every tool-call problem: unknown outcomes, retries, duplicates. Three tests catch almost all of it.
Twice. Undo must be idempotent against its own record. status moves applied → undone once; the second call reads undone and returns it instead of running the inverse again. Without this, an undo that timed out and was retried re-books the slot that the first undo already released.
Late. After window_until, the inverse is refused and the refusal is reported, not silently downgraded. A promise you cannot keep is worse than a promise you never made; the person is planning around the sentence you printed.
After the world moved. Compare the target's current state to prior_state. If it differs in the fields the inverse assumes — the row was edited, the slot was re-let, the person replied — the undo is superseded, and the right output is a question, not an action. This is the same fence that stops a stale background job from writing, applied to the reverse direction.
I also test the dependency case, which is the one that hits hardest: effect A was undone while effect B was derived from it. A calendar invite derived from a cancelled booking, a receipt derived from a refunded charge. The rule I settled on is that derived effects carry the id of their cause, and undoing a cause marks its dependants orphaned for review rather than deleting them. An orphan list is a queue of questions; a silent cascade is a second incident.
Test reversibility as a property, not a feature
The tests that paid for themselves, all run against a scratch account:
- Round trip.
applythenundoreturns a state digest equal to the digest taken beforeapply. Any field that differs is either a field the inverse forgot or a field that genuinely should not return — and I want to know which. - Kill in the middle. SIGKILL between the effect write and the inverse registration; restart; confirm the system can still find the effect and offer the undo. If registration is part of the same transaction, this passes for free.
- Undo twice. Second call returns the recorded result; the target is unchanged.
- Undo after edit. Mutate the target, then undo; expect
supersededwith the changed fields named. - Window expiry. Move the clock past
window_until; expect a refusal that names the expiry, not a generic failure. - Observation. Force
observed_byto be non-empty; expect the system to refuse the "quiet undo" path and switch to a repair path that includes disclosure. - Compensation failure. Make the inverse fail (insufficient balance, slot gone); confirm the effect stays
appliedwithcompensation_failed, and that this state is visible without anyone querying the database by hand.
Test 7 is the one that turns a demo into an operating system. Most undo buttons I have used are only tested for the happy path, so the interesting states — half-undone, compensation failed, orphaned dependants — are exactly the states nobody has ever seen.
What the person should see
Reversibility is a promise made in the interface, so it has to be legible before the decision:
- The class of the action, in words: "this can be undone for 40 seconds", "this cannot be undone".
- What survives the undo: a refund line, a tombstone, a re-let slot.
- The exact time the window closes, not a vague "shortly".
- What the agent did after the window closed, if anything.
And the agent should be able to say the uncomfortable sentence: "I already did the part that cannot be undone; here is what I can still do." That sentence is more useful than any success message, and it is the sentence an agent can only produce if the record told the truth about observed_by.
Numbers to keep
Six counters, all cheap:
- effects per class per day (are we shipping more X-class actions than we can watch?)
- undo rate per class (a rising X-class undo rate is a classification error, not a user error)
- windows that expired with no review (the delay is decoration if nobody reads the confirmation line)
- compensation failures per week
- orphaned dependants awaiting review
- median time from effect to undo, and the 95th percentile
If the X-class undo rate is above zero, the fix is upstream: downgrade the action so it stops being X-class. Undo exists to cover the residual, not to make irreversible actions acceptable.
Checklist
- Classify every effect R / C / D / X before writing the tool, and store the class on the effect record.
- Save
prior_stateon every destructive write, including the boring ones. - Prefer downgrading the action over adding a confirmation dialog.
- Make the delay visible, finite, and withdrawable until the last second.
- Stage anything a stranger will read, on the side of the boundary where no scheduler can promote it.
- Define the inverse before allowing the action; treat compensation as a C-class effect of its own.
- Fence the inverse with
status,window_untiland a state comparison againstprior_state. - Mark dependants
orphanedon undo instead of cascading. - Test round trip, kill-mid, double undo, undo-after-edit, expiry, observation and compensation failure.
- Say the class and the window in the interface, before the decision.
None of this makes an agent trustworthy. It makes its mistakes priced in advance — and it means the one action nobody can take back is the one the agent was never allowed to take quietly.
If you want the phone-side version of these mechanics — what a daily usage limit does, what a reset ticket restores, where the irreversible boundary sits in a consumer assistant — I keep two public notes:
- The English guide: https://pickle-invite-code-en.deed.page/ — the app's behaviour on daily limits, the 4 AM reset, and reset tickets.
- The invite link: https://pickle.com/i/RDKXK77G9ZRT
Disclosure: I use Pickle, a phone-side AI assistant app, and I am not its vendor. The two links above are mine; if a sign-up completes through the invite link, both sides receive reset tickets, and the app's own notices govern any benefit. This is not a paid placement. The reversibility model described here is the one I run in my own errand-running agent, written from mistakes I made on live accounts.
![Real Time Operating Systems — Ch.12: RTOS Case Studies [EN]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9f8b1u4hxrwc1uq4gfe2.png)