ADR-0118: A destructive tool call is approved by code, per call, through the approval queue¶
Status: Proposed (2026-09-21). The owner answered decisions 1-4 below in an interview on the same day. Build steps 1 to 5 are implemented. Amended 2026-09-22: a write may declare approval: pinned to take this approval path (decision 1, below); and a reversible trash of a category the owner named, whose emails code picks, runs without a card (decision 3, below).
Status pass (2026-10-18): Accepted; the decision is implemented (kernel/governance/plugins/destructive_approval.py, plugin_host/manifest.py). the owner's decision is implemented; status moves from Proposed to Accepted
Relates to: ADR-0110 (tool effects in the manifest), ADR-0108 (the approval queue, its channels and resume), ADR-0106/0107 (pause and resume), ADR-0068 (the escalation judge and its re-run), ADR-0067 (FileManager never deletes a file it did not create), ADR-0117 (device scopes).
Amends ADR-0110 §1 by adding a third effect value.
Context¶
On 2026-09-21 the owner asked the phone "Can you delete my emails or not?". The email agent correctly replied that it would not delete them, because no email tool deletes anything. The owner's follow-up question was the real one: if the tool existed and policy said to ask, would governance catch it? The answer, read from the code, is partly:
| What happens today | Where | Why it is not enough for a delete |
|---|---|---|
A tool declares effect: read \| write and, for a write, confirm: once \| never. |
ADR-0110, runtime/plugin_host/manifest.py |
A delete and a new reminder are the same kind of thing. A plugin may declare confirm: never for a delete. |
confirm: once lets the write run once the run has an ask_user step after it proposed that write. |
kernel/governance/plugins/tool_policy.py:51, agent/agentic_core.py:300 _run_asked_user |
The code checks that a question was asked, not what the owner answered. The reply reaches the model as free text ("User answered: {reply}", agentic_core.py:736). A "no" is honoured only if the model reads it that way. |
| One confirmation covers the rest of the run. | same | After one "yes" the model can call the tool with arguments that were not in the question it asked. |
A PreToolUse require_approval becomes an observation the model reads. |
agentic_core.py:1491 |
It creates no approval row and does not pause the run. Only the PostStep evaluator writes rows to the queue (ADR-0108 §1), and by then the step has already run. |
In enforce mode, escalate and reroute re-run the whole plan. |
runtime/escalation_actions.py maybe_escalate |
Nothing there checks whether the first attempt already executed a write. A delete could run twice, or a second run could invent a different delete. |
What already works and is reused rather than rebuilt:
- The approval queue (ADR-0108): durable rows whose request terms cannot change after they
are written.
tests/security/test_governance_isolation.pypins that (_IMMUTABLE_FIELDS, §9). One answering capability,respond_to_approval, serves the API, the CLI, Telegram and the web. Approving resumes the run through the turn pipeline, so the curator and every judge still run. Since #579 anapproval.requestedevent also pushes a banner to the phone. - Device scopes (ADR-0117):
POST /governance/approvals/{id}/respondis a gated write, so a device pairedreadalready gets a 403 (server/iris_api/main.py,_control_writes_guard). - FileManager's precedent (ADR-0067 and
filemanager/quarantine.py): a file "delete" moves the file to quarantine, which keeps it for a retention window and can restore it. IRIS never permanently deletes a file it did not create.
Decision¶
1. A third effect: destructive (owner decision 1: data loss only)¶
tools:
trash_email:
effect: destructive
undo: restore_email # required when the service offers a reversible form (decision 3)
destructive means the call removes or overwrites the owner's data: deleting or trashing an
email, deleting a calendar event, overwriting or quarantining a file, dropping a finance record.
Irreversible outbound actions (sending an email, a payment, a public post) are not
covered by this ADR. They stay write until a separate decision, because the owner wanted this
change scoped to data loss.
Amendment (2026-09-22, owner decision 8 of the promo-classification interview): the
approval: pinnedwrite form. Sending an email stayseffect: write(it is not data loss), but it cannot be taken back, so the owner wants every send approved on the same pinned card. Awritemay now declareapproval: pinned(writes only; it excludesconfirm, and such a tool cannot be anundo). Its gate becomesapproval, and one predicate (approved_per_call: destructive, or gateapproval) routes it through the same mechanism as decision 2 —validatebefore queuing,describefor the card,DestructiveApprovalHook, one pinned row, approve executes exactly the pinned call, reject resumes with "nothing was changed, sent or deleted", fail-closed without a queue or a resumable run. Nothing was duplicated. The card carrieseffect: write(rows from before default todestructive), so it reads "acts for you" rather than "deletes data", and the prompt marks the tool as a write with a card, not a delete. The first-ask turn-back (_turn_back_ask) still applies to destructive tools only. First user:send_email(email_workflows): plain text, no attachments or drafts;reply_to_idthreads a reply (In-Reply-To, References, Gmail threadId) and fills the recipient and a "Re:" subject. Gmail'sgmail.modifygrant (step 5) already coversmessages.send, so no scope changed.Two guards added after the first real send (owner, 2026-09-22). The owner asked for a test email to their own address and the model wrote a blend of their two account names, an address that exists nowhere; the card also came from the account that had received mail most recently. Now
validaterefuses any To/Cc address that is neither in the owner's request this turn nor already in their mail (sender, recipient or cc), so an invented address never reaches a card. A new message goes fromfromwhen the owner names an account, else fromIRIS_EMAIL_DEFAULT_SENDER(local setting, never in the repo), else the first connected account in a fixed order; a reply keeps the account that received the original.
confirm is not allowed on a destructive tool. Manifest validation refuses
effect: destructive together with confirm, the same way it already refuses confirm on a
read tool. There is therefore no way to declare confirm: never on a delete. Core built-in tools
follow the same rule in code: today none of them is destructive (memory_forget is reversible
through user_fact_history and stays write, confirm: never).
2. Every destructive call waits for a queued approval, itemised (owner decision 2)¶
At PreToolUse, a destructive call does not run and does not come back to the model as an
observation. DestructiveApprovalHook (priority 50, after every other PreToolUse deny):
- writes one approval row per call, pinning the tool and its exact arguments as
canonical JSON (sorted keys), so "is this what was approved?" is an exact comparison. The
card text lists them. A batch is one call that takes a list (owner decision,
2026-09-21):
trash_email(ids=[...])is one row naming every id. The loop proposes one call per step, so a tool that takes one item at a time costs one approval per item; - tells the owner through the channel router, which also fires
approval.requested, so the phone gets a banner (#579); - returns
require_approvalcarrying the row's id. No new outcome was needed: a PreToolUse decision with an approval id is something to wait on, and the loop halts on it; the confirm-once decisions carry none and stay advice. That is how evaluator approvals already worked.
The loop then checkpoints the run with the approval id on it (ADR-0106), links the row to the checkpoint (ADR-0108 §3), and ends the turn saying what is waiting and where to answer it. The turn is not re-run by escalation (decision 5): that would raise a second approval.
The hook refuses instead of queueing when nobody could act on the answer: no approval queue (approvals disabled), or a run that cannot pause and resume (a core built without a checkpoint store, the approval link and the approval reader, such as a plugin's own degrade-path loop). With no governance kernel at all, the loop refuses before asking.
The row's item list is a term of the request, so it joins _IMMUTABLE_FIELDS in the isolation
test: nobody, including the resume, can change what was approved after the fact.
Approve resumes the run through the turn pipeline (ADR-0108 §5). Before the model says anything, the resumed loop executes the pinned calls from the row, not calls the model generates again, and puts their results in as the paused step's observation. Each executed call claims the approval, and the hook checks that claim against the row itself: the row must be approved and pin exactly this tool with exactly these arguments. A forged id or a changed argument is denied. The model then continues from real results. It cannot add an item, change an argument, or say "and the other ones too" on the strength of the earlier approval.
Reject resumes the run too (owner decision, 2026-09-21), with the observation "the owner
rejected this:
Expiry follows ADR-0108 §7: a lapsed row says so, and nothing executes. A destructive approval stays answerable for an hour (owner's decision, 2026-09-21), since it is often answered from the phone away from the conversation; evaluator halts keep the queue's 10-minute default.
This replaces "ask once, then the model decides". Consent is a field on a row, set by
respond_to_approval, which a human caller reaches through an authenticated surface. No
agent-facing tool can answer it (ADR-0108 §4 already guarantees this).
3. Reversible first, and still approved (owner decision 3)¶
Where the service has a reversible form (Gmail trash keeps mail for 30 days, FileManager
quarantine can restore), the tool must use it. It declares the undo tool in undo:, and the
approval card says how long the undo stays available. A tool that permanently deletes when a
reversible form exists is a review failure. The manifest cannot prove the service lacks one,
so this is enforced by the plugin contract test that ships with each destructive tool, not by
the loader.
Reversible deletes still go through decision 2. Being able to undo a mistake is a second line of defence, not a reason to skip the first.
Amendment (2026-09-22, owner, promo-classification PR 3b): a reversible delete whose items CODE picks from a category the owner named runs without a card. "Delete my promo emails for this week" matched 308 emails on the owner's real mail (about 60 promotions a day), and the owner asked why a card is needed at all when Trash keeps them 30 days. The card guards two risks: the model choosing the wrong items, and instructions hidden in mail steering a delete. The first is gone when no model picks the items. So the email plugin has two tools.
trash_email(effect: destructive, unchanged) takes ids the model chose, and every call still waits for the card.trash_categoryiseffect: write,confirm: never: given{category, since_days}it resolves the owner's word to the categories that exist (paths plus the Gmail tab label that maps to them), takes the newest 200 in the window, trashes them, and replies with the count, a sample, how many remain and how to undo it (restore_emailwith no ids restores the whole batch). Guards that replace the card: it moves nothing for a name that matches no category, or one that covers every category ("email"); 0 matches is "nothing to trash"; it never deletes permanently. With no card, the mailbox's own bucket decides (owner, same day, after the probe): when the provider has a label for the category (Gmail's Promotions tab), only mail carrying that label is trashed, wherever triage filed it. The first probe trashed 6 order, delivery and receipt emails that "promo" reached through IRIS's owndeals-promotions/amazonpath while Gmail had filed them as Updates. Browsing (list_by_category,search_inbox) still shows both. A category with no provider bucket is matched by its paths. Accepted residual risk (owner): injected mail can still ask for a category to be trashed without a card. Undo covers that for 30 days. The owner chose this over approve-once-per-day and over removing the card fromtrash_emailtoo. The manifest keepsundo:on destructive tools only, sotrash_categorynames its undo in its reply.
The undo tool itself is write, confirm: never: restoring what the owner just lost is what
their sentence ("undo that", "restore it") already authorises, the same argument ADR-0110 used
for memory_restore.
4. Who can approve (owner decision 4)¶
Any surface respond_to_approval already serves: a paired control device (the phone
today), the CLI (iris approvals), Telegram, and the web UI on a control session. A
read device can see the card but cannot answer it; that is already enforced (see Context).
Face ID is added when the Swift app lands (mobile plan Track 6, decision 15). Until then a
tap on a control device is the bar. The owner chose not to block destructive tools on Track 6.
5. Escalation never re-runs a turn that changed something¶
The ReAct loop records the declared effect of every non-read tool it invokes, on the sync
and the streaming path. It records the call before it runs, because a write that raised
half-way may still have changed something. A call governance held back never ran and is not
recorded. The react handler reports that list as effects_executed in the result's metadata,
and resumed: true when the turn continued a paused run. The executor already carries a
handler's metadata onto AgentResult on every path, so there is no separate field to keep in
step.
maybe_escalate acts on a verdict only when every result reports effects_executed and
the list is empty. That covers clarify as well as escalate and reroute: after a write, a re-run
can repeat it, and a question in place of the answer hides what was done. The check runs at
the top of every hop, because a re-run can itself write before the next one.
Fail-closed: a result that does not report effects_executed is treated as unknown and
not acted on. A plugin handler can write outside the loop, and a loop that wrote and then fell
back to a deterministic answer drops its report. Trusting silence would miss both. The
cost is that escalation only acts on turns the loop answered, which is where a model's answer
can fall short in the first place.
A resumed run (decision 2, and ADR-0106's ask_user resume) is never escalated: it exists
because a human already decided about this exact work. The curator still records the
judge's verdict on all of these turns, so shadow data keeps flowing.
This also closes the pre-existing gap for plain write tools. A duplicate reminder is a smaller
harm than a double delete, but it is the same bug.
6. Audit¶
Each executed destructive call writes an audit row carrying the approval id, the item, the result, and whether an undo exists. The row is written to the side-effect ledger as well, so a resume after a crash knows the call already ran and does not repeat it.
Alternatives considered¶
- Keep confirm-once and parse the reply for "yes". Rejected. Deciding consent from free text is a keyword classifier in core, which the owner's rule of 2026-09-13 forbids, and it still lets the model regenerate the arguments after consent. The row pins what was consented to; a parser cannot.
- An LLM judge decides whether the reply was consent. Rejected for the same reason the escalation judge was just fixed (#580): the model under judgement ended up grading itself, and consent to data loss is not a place for a probabilistic answer.
- One approval per item. The owner chose itemised batches (decision 2). A per-item row is still what a one-call-per-step model produces, so nothing is lost for the careful case.
- Reversible deletes stay
write,confirm: once. The owner rejected this (decision 3): confirm-once consent is checked by the model, and undo is a backstop, not consent. - Require Face ID now. The owner rejected this (decision 4): the web app cannot do it, so every destructive tool would wait for Track 6.
Consequences¶
- No destructive tool exists today, so this changes nothing a user sees until one ships. The first candidates are Gmail trash and calendar delete. The FileManager quarantine flow already asks through its own propose-then-approve intercept; moving it onto this mechanism is a later, separate change.
- A new PreToolUse outcome,
await_approval, becomes a halt with a queue row. That is the first approval that pauses a run before a tool runs, not after. The resume has to execute the pinned calls, which ADR-0108 §5's resume does not do today: an approved evaluator halt resumes with a real tool result already in place. - The approval row gains an item list. The isolation test's
_IMMUTABLE_FIELDSgrows by one field (ADR-0108 §9). - A destructive turn takes two turns. That cost is the point.
- Plugin authors declare
destructiveandundo, and supply a one-line rendering per item for the card. The core never learns a plugin's vocabulary. - Reversible: the new effect is additive. Removing it later means reclassifying the tools that use it; no stored data depends on it except the approval rows' item lists.
Build order (a proposal, for the owner to reorder)¶
- Done. Manifest:
destructiveandundo;confirmandanswers_directlyare refused on a destructive tool, andundomust name awrite,confirm: nevertool in the same manifest. The gate a destructive tool carries isapproval, shown in the Plugins panel. Until step 3 builds the approval halt, the loop refuses every destructive call — before governance, so it holds with governance off, and after alias resolution, so a near-miss name cannot slip past. It tells the model nothing was changed. Declaring one is safe; running one is not possible yet. - Done.
effects_executedin the loop's result metadata, and escalation skips turns that changed something (decision 5). This protects thewritetools that exist today. - Done. The approval halt, itemised rows and a resume that executes the pinned calls
(decision 2). Demoed by
tests/integration/test_destructive_approval_runtime.py, a real runtime with a test destructive tool and a scripted model: the turn halts, andrespond_to_approval(what the API, CLI and Telegram call) resumes it. The playground has no "answer the approval" step, so it was not the demo. - The approval card on the phone renders the item list and the undo window (decision 3).
Prototype signed off 2026-09-21 (https://claude.ai/artifact/P7Xys8Dw98HNB2d24kk2pP), with
the owner's answers: the item lines come from a plugin
describe(args)function (it can look up subjects; ids mean nothing to the owner), run once and frozen into the row as a card (title, lines, declared undo tool andundo_window_days, and the owner's request); approving is a red button naming the action ("Trash 3 emails") with a confirm sheet. 4a (done):describe, the manifest window, the frozen card (an immutable term of the request), plain words for every surface that reads text (context_summary: CLI and lapse notices; Telegram gets the card with Markdown escaped; the phone banner shows the title),kind/card/itemsonGET /governance/approvals, and a chat message that names the action and links to Activity. 4b (done): the web card from the prototype on Activity (a red "deletes data" card: title, one line per item, the undo sentence, "You asked", the exact call behind a toggle, Reject and a red button naming the action); the confirm step reuses the app's confirm dialog (centred, not the prototype's bottom sheet) with the same words; rejecting needs no second tap. App-relative links in chat now stay in the app, so the halt message's link opens the card instead of a browser tab. Telegram (owner's request, 2026-09-21): the approval message carries buttons. A destructive one shows the action ("Trash 3 emails") and Reject; tapping the action asks "Trash 3 emails? Yes / Cancel" before anything runs, the Telegram form of the confirm sheet. Rejecting is one tap. See the ADR-0108 correction for how a tap is answered. - Done. The first real destructive tool:
trash_email(email library, registered byemail_workflows),undo: restore_email,undo_window_days: 30. Owner's decisions (2026-09-21): Gmail's grant moves fromgmail.readonlytogmail.modify(trash and restore, never permanent delete; old tokens keep reading, trashing asks for a new consent), and search results show each email's id so the model names exact emails. The card lists each by subject, sender and date, looked up from the local store. A trashed message leaves the local store (Gmail's sync only adds mail, so it stays gone) and atrashedledger keeps its account forrestore_email, which with no ids restores the last batch ("undo that"). Demoed bytests/integration/test_gmail_trash_approval.pywith the email plugin's real manifest; on the phone once the owner re-authorises (gmail-auth.md). Found on the owner's first try (2026-09-21): email requests were answered by the email plugin's own agent, a loop with no checkpoint store or approval wiring, so the destructive call was refused ("this run cannot pause") and the turn fell back to the inbox digest; a resume would also have been re-routed there. The step-5 tests had driven a loop directly, not the route the request takes. Owner's decision: email joins the shared loop, the way finance did (ADR-0077):emailis a loop intent, and the plugin's agent is the degrade path through_reassert_loop_intents. The integration test now also drives a real runtime with the email plugin mounted, through theemailagent, torespond_to_approval; with the old routing it fails with the owner's exact symptom. The real local router sends "trash the promo emails from this week" toemail.