Chapter 6 of 7 · 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.
| Control | Catches a confidently wrong target | Independent party | Cost | Where it fits |
|---|---|---|---|---|
confirm: true | No | No — same generation | One argument | Stray or accidental calls |
| Restating the target name in arguments | No, and raises the model's confidence in its error | No | One argument | Nothing; 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 first | Sometimes — surfaces the resolved name and blast radius before acting | No, but there is a turn boundary and fresh evidence | One extra round trip, ~2 s | Bulk or fan-out operations |
| Human approval in the loop | Yes, when the human has the original request | Yes | Minutes to hours of latency | High-value, low-frequency actions |
| Soft delete — marking the row inactive and keeping it rather than removing it — with a retention window and an undo call | No — it makes the mistake reversible instead of preventing it | Not applicable | Schema and storage complexity | Everything 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.
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.
{
"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