Skip to content

14 min read

Recoverability on a destructive tool

On this page

A destructive tool is one whose effect cannot be withdrawn by calling it again — a cancellation, a deletion, a send. The design question it raises is where to spend effort: on preventing the wrong call, or on being able to reverse it. Prevention is where the instinct goes, and it is the harder of the two, because a control that runs at the moment of the call has no more information than the model already has.

Recoverability is the cheaper and more general answer, and the reason is a difference in when each one has to be right. Prevention has to be right in the same generation that produced the mistake. Recoverability only has to be right afterwards, when a human has noticed something is wrong, and it works against mistakes nobody anticipated — which is most of them, since the anticipated ones have usually been validated away already.

What a confirmation flag actually controls

The standard proposal after a destructive-tool incident is a required confirm: true argument, often with the target's name restated so the model has to check before acting. That proposal is asking one party to check another party's work, and in this design both parties are the same model in the same generation. The tokens that emit confirm: true are sampled from the same forward pass, conditioned on the same context, as the tokens that emit customer_id: "cus_8812". There is no interval in which a second opinion could form, no independent source of the customer id, and nothing that could produce disagreement.

The mechanism under that is worth spelling out, because "the same forward pass" is a phrase that can be repeated without being understood. A check is a check only when the checker holds evidence the actor did not. A human reviewing a cancellation has the original email in front of them and the resolved account name in front of them, and the check is a comparison between two independently obtained things. When a model emits a tool call there is one context, one pass over it, and one sequence of tokens coming out. The tokens spelling confirm and the tokens spelling cus_8812 are drawn from the same distribution conditioned on the same evidence, so whatever produced the wrong id produces, with equal force, the flag that endorses it. Requiring the flag adds an argument to the call. It adds no source of evidence, and a check with only one source is a restatement.

The restated field is worse than neutral. It puts the model's belief into its own context as an explicit assertion, and subsequent turns condition on it, so an agent that later reviews its own work reads a confident statement of the wrong target and agrees with it. A call reading {"customer_id": "cus_8812", "customer_name": "Northwind Traders", "confirm": true} is, as a log line, more deliberate-looking than the bare call that would have caused the same incident. The control produces an audit trail that reads as careful, which is the specific reason it survives incident reviews.

A second-order effect runs the same way and is what makes the restated field worse than merely useless. Once "customer_name": "Northwind Traders" sits in the transcript, it is context for every turn after it, and a model asked later to review its own work reads that line as a statement of what was intended rather than as a record of what it assumed. The assumption has been laundered into evidence by being written down. That is the identical property that makes a resolved name in the result valuable, which is why the direction the name travelled in matters more than the fact that it appears: a name that came out of the database is persuasive and checkable, and a name that came out of the model is persuasive and is the model's own belief handed back to it.

A confirmation flag does control something. It stops calls the model makes without having formed an intent at all — a tool selected because its description matched a phrase in the user's message, an example from the tool description echoed back as a real call, a stray invocation during a retry loop. Those are real and the flag catches them, at the cost of one required argument. What it cannot do is catch a wrong target that the model is confident about, and confident wrong targets are where destructive-tool incidents come from, because a model that is unsure tends to call the read tool first.

Confirmation becomes a genuine control the moment the confirming party is different from the acting party. A UI approval step, an operator who sees the resolved customer name before the call executes, a policy engine that checks the account against the ticket's account, or a second model without the first's context are all independent in the way confirm: true is not. Each of them costs latency and the attention of a human, which is exactly why the flag is attractive; it looks like the same control for free.

ControlCatches a confidently wrong targetIndependent partyCostWhere it fits
confirm: trueNoNo — same generationOne argumentStray or accidental calls
Restating the target name in argumentsNo, and raises the model's confidence in its errorNoOne argumentNothing; it is worse than the flag alone
Dry-run preview, a call that reports what would happen without doing it, which the model must make firstSometimes — surfaces the resolved name and blast radius before actingNo, but there is a turn boundary and fresh evidenceOne extra round trip, ~2 sBulk or fan-out operations
Human approval in the loopYes, when the human has the original requestYesMinutes to hours of latencyHigh-value, low-frequency actions
Soft delete — marking the row inactive and keeping it rather than removing it — with a retention window and an undo callNo — it makes the mistake reversible instead of preventing itNot applicableSchema and storage complexityEverything destructive that can be reversed

The dry-run row is the interesting one in that table, because it is the only control that is not independent and still sometimes works. Its argument is not that a second party checks the first. It is that the model's second call is conditioned on something the first was not: a tool result carrying the resolved name and the blast radius, which came out of the database. A turn boundary is where new evidence can enter a conversation, and evidence is exactly what confirm: true could never supply. That is also why it works only sometimes. The model is still the only reader, so one confident enough to be wrong may read the resolved name and see what it expected to see. A dry run raises the chance of catching the error; it does not make catching it structural.

Cancelling in a way that can be un-cancelled

Redesigning cancel_subscription for recoverability changes what it does to the record and what it returns. The subscription moves into a scheduled state rather than being terminated, service continues to the end of the paid period, and a restoration window extends past that, during which the record and its provisioning can be brought back exactly.

State diagram: start → active; active → cancellation_scheduled (cancel_subscription); cancellation_scheduled → active (restore_subscription); cancellation_scheduled → cancelled (period ends); cancelled → active (restore_subscription (within 30 days)); cancelled → purged (retention expires); purged → start.

The purged state is not decoration. A retention window that never closes is a data-retention problem and an erasure-request problem, so the window has an end, and after it the restoration path is a support escalation rather than a tool call. Thirty days past service end was chosen for this system because it comfortably exceeds the longest observed detection lag, which was six days, plus a margin for a customer who only notices at the next invoice.

Two quantities set that window, and it is worth seeing which of them is doing the work. The first is the detection lag: how long a wrong cancellation went unnoticed, with six days the longest anyone had recorded. A window sized to just cover that would be a window sized to the worst case already observed, which is the same mistake as sizing an idempotency key's retention to the retries that happen to be in the log. The second quantity is the billing cycle, because a customer who does not notice a degradation does notice an invoice, and that places a reliable detection event roughly a month out. Thirty days past service end covers the observed lag several times over and reaches that invoice, which is what makes it a defensible number rather than a round one. The window's far end is set by something unrelated to either — an erasure request has to remain satisfiable, so retention cannot be indefinite — and purged is where the two requirements meet.

The result the tool returns matters as much as the state change.

json
{
  "subscription_id": "sub_4f21",
  "customer": {"id": "cus_8812", "name": "Northwind Traders", "plan": "Scale (£2,400/mo)"},
  "status": "cancellation_scheduled",
  "service_ends_at": "2026-03-31T23:59:59Z",
  "restorable_until": "2026-04-30T23:59:59Z",
  "affected": {"seats_revoked_at_period_end": 45, "api_keys_disabled_at_period_end": 3},
  "undo": {
    "tool": "restore_subscription",
    "arguments": {"subscription_id": "sub_4f21", "cancellation_id": "can_7b90"}
  }
}

Naming the customer in the result is the field that catches a wrong target, and it works for a reason the confirm argument does not share. The name in the result comes from the database rather than from the model, so it is evidence rather than an assertion, and it arrives at a turn boundary where the agent's next output is a summary a human reads. An operator who asked for one company and receives "cancelled Northwind Traders, 45 seats, £2,400/mo" has been handed the discrepancy in the sentence they were going to read anyway. The affected counts do similar work by making the blast radius legible — how much this one call actually touched, in units the reader can weigh against what they asked for: a cancellation that revokes 45 seats when the request concerned a two-person account is visibly the wrong one.

The units in those fields are chosen so a reader can convert between them without being taught how. A plan of £2,400 a month is £28,800 across a year, which is the same customer described at the scale a churn conversation is usually held at, and 45 seats is a headcount that can be held against the size of the company the operator meant to cancel. Neither quantity is available from sub_4f21, and neither is available from {"status": "cancelled"}. What the result is doing is converting an identifier nobody can check into figures a person can weigh against their memory of the request — and the weighing happens for free, because those figures are already inside the sentence the agent was going to write anyway.

Returning the undo call alongside the result closes the loop for the agent itself. The alternative is a restoration tool that exists in the schema and that the model has to remember and reconstruct arguments for, several turns later, under the impression that it has done something wrong. Handing it the exact call, with the cancellation id it would otherwise have to look up, means the correction is one turn from the moment of realisation, and it means the undo path is exercised by ordinary operation instead of existing only in a runbook.

The case where nothing can be un-sent

The same agent has send_customer_email, and the design above does not transfer, because the state that matters is in someone's inbox. Soft deletion has no meaning there; a retention window on your copy of the message changes nothing about the copy that was delivered.

Part of the design does carry over unchanged. The result should name the recipient and quote the resolved address and subject, because that is the same evidence-at-a-turn-boundary property, and a summary that says "sent to ops@northwind-traders.com" is where a human catches the wrong company. The blast radius should be in the result too, since the difference between one recipient and a distribution list of 400 is exactly the thing a reader needs to see.

What has to be added is a window that the domain does not provide on its own. Sending through an outbox with a hold — the message is queued, a cancel_scheduled_email tool can withdraw it, and delivery happens after some delay — manufactures the reversible interval that cancellation had for free. A 90-second hold catches the agent's own realisation and costs almost nothing in perceived responsiveness; a 15-minute hold catches an operator reading the summary; anything longer stops being an email tool. This is the general move for irreversible actions, and it is worth recognising as a trade rather than a trick, because the delay is paid on every send in exchange for reversibility on the small fraction that are wrong.

Each of those durations is the length of a particular feedback loop rather than a preference, which is what makes them derivable instead of arbitrary. The agent's own realisation arrives within its next turn or two, which is seconds, so a hold measured in tens of seconds covers it. An operator reading the summary reads it when they next look at their queue, which is minutes. A customer noticing a wrong email and saying so is hours or days, and no hold reaches that far while the thing remains an email tool. The question is therefore not how long a hold to set but which loop you intend to be inside, and that answer determines the number. It is the general form of the trade for irreversible actions: reversibility has to be manufactured as an interval, and the interval must be at least as long as the loop that would detect the mistake.

Where the hold cannot be afforded, the controls have to move outside the model entirely, since there is nothing left to undo. Constraining who the tool can send to is the most effective of them: an email tool bound to the customer on the current ticket cannot email a different customer at all, which converts the wrong-target failure into a tool call that returns recipient_not_in_scope with the ticket's actual customer named in the message. Rate caps in the same spirit — at most three external emails per agent run, at most one to any address — bound the damage of a loop without depending on the model's judgement. These are restrictions on the tool's surface in the sense of topic 5.10, on tool granularity, applied specifically as a blast-radius control rather than as a clarity one.

The honest limit on all of it is that recoverability restores your system's state and not the world's. Restoring the subscription six days later did not un-degrade the service the customer had already lost, and withdrawing a queued email does nothing if the hold expired. Undo shortens the interval during which a mistake is compounding, and past a certain point the only remaining instrument is a compensating action that a human has to perform and apologise for.

The control worth building on a destructive tool is the one that works after the mistake rather than during it. A soft state with a retention window turns a wrong call into a reversible one; a result naming the resolved target and the blast radius puts the discrepancy in front of the human who is already reading; and the undo call returned inline means the correction is one turn away rather than a runbook away. A confirm: true argument costs a token and catches the calls the model never meant to make, which is a real but small category, and it should not be mistaken for the control that would have caught the expensive one.

Whether any of this behaves as designed is not something a code review can establish. Every claim in this chapter is a claim about what an agent does when it encounters a particular result — that it uses the undo call, that it stops when told the recipient is out of scope, that it repairs rather than repeats — and claims of that shape are settled by running the agent against the tool and watching what it emits.

Tool design — semantics — read the current version at https://daviddominguez.dev/learn/en/tool-design-semantics/recoverability-on-a-destructive-tool

Back to top