Chapter 71 · Tools and actions
Side effects, and the action you cannot take back
Reversibility is a spectrum, not a flag. Four levels, the two-stage pattern that converts an irreversible action into a reversible one, and the undo that is not an undo.
11 min read
Chapter 70 split reads from writes. This chapter splits writes, because draft_quote and send_quote are both writes and only one of them can be taken back.
Reversibility is the axis that decides how much machinery a tool needs, and it is not a boolean.
Four levels
| Level | Example | Machinery needed |
|---|---|---|
| Reversible | Save a draft quote | Idempotency |
| Compensable | Apply a credit | Idempotency, audit, a reversal tool |
| Observed | Send a quote by email | Confirmation, before the fact |
| Irreversible | Trigger a shipment | Confirmation, a second approver |
Level 3 is the one that gets misclassified. Sending a quote feels reversible — you can send a correction — but the recipient has seen the first one, and no system action removes that. A quote with the wrong price sent to a customer is a commercial fact whatever you do next.
// So reversibility is declared with its level, and the level drives the
// controls rather than a boolean.
export type Reversibility = 'reversible' | 'compensable' | 'observed' | 'irreversible'
export const sendQuote: Tool = {
name: 'send_quote',
kind: 'write',
reversibility: 'observed', // ⚠️ not 'compensable'
requires: 'send_quote',
// ...
}// And the executor derives the controls, so a tool cannot be added with
// the wrong combination.
const CONTROLS: Record<Reversibility, Controls> = {
reversible: { confirm: false, audit: true, secondApprover: false },
compensable: { confirm: false, audit: true, secondApprover: false, reversalTool: true },
observed: { confirm: true, audit: true, secondApprover: false },
irreversible: { confirm: true, audit: true, secondApprover: true },
}The two-stage pattern
The most useful structural move in this section: convert an irreversible action into a reversible one plus a trivial confirmation.
// ❌ One tool, irreversible, and the model decides when to fire it.
send_quote({ accountName, sku, quantity, discountPercent })
// ✅ Two tools. The model does the work; a person does the irreversible bit.
draft_quote({ accountName, sku, quantity, discountPercent }) // reversible
send_quote({ quoteId }) // observed, confirmedThe split does three things at once.
All the judgement is in the reversible stage. Account resolution, pricing, discount entitlement — every place the model can be wrong — happens where being wrong costs a deleted draft.
The irreversible stage has one argument. send_quote({ quoteId }) has nothing to get wrong except which quote, and the confirmation shows the quote.
The confirmation shows a real artefact. A person approving "send this quote" sees the quote. A person approving "send a quote to Henderson for 200 units at 12%" is approving a description, and descriptions hide errors.
// The preview is the artefact, not a summary of the arguments.
async preview(args: SendQuoteArgs, scope: Scope) {
const quote = await getQuote(args.quoteId, scope)
return {
kind: 'document',
title: `Quote ${quote.reference} to ${quote.account.name}`,
recipient: quote.contact.email, // ⚠️ the thing most often wrong
body: await renderQuotePdf(quote),
totals: { net: quote.net, discount: quote.discountPercent, gross: quote.gross },
}
}Surfacing the recipient separately is deliberate: the wrong-account failure from chapter 69 shows up as an email address, and an address is something a rep recognises instantly.
Compensation is not undo
For level 2, a reversal exists and it is a new fact rather than an erasure.
// ❌ Deleting the record pretends it never happened. Finance disagrees,
// the audit trail has a hole, and reconciliation breaks.
await db.query(`delete from credits where id = $1`, [creditId])
// ✅ A compensating entry. Both facts remain true and both are auditable.
export const reverseCredit: Tool = {
name: 'reverse_credit',
kind: 'write',
reversibility: 'compensable',
requires: 'apply_credit',
async run(args, scope) {
const original = await getCredit(args.creditId, scope)
return applyCredit({
accountId: original.accountId,
amount: -original.amount,
reason: `Reversal of credit ${original.reference}: ${args.reason}`,
reverses: original.id,
}, scope)
},
}⚠️ A reversal tool is itself a write with the same controls. It is not a safety valve that bypasses them — and a reversal applied to the wrong credit is a second error, not a correction.
The side effects you did not write
Chapter 70 ended on a read tool that marked orders as viewed. The general problem is wider: an action in one system triggers effects in others.
// Declare the full effect chain, from a conversation with the owning team
// rather than from reading your own code.
export const updateOrder: Tool = {
name: 'update_order',
kind: 'write',
reversibility: 'observed', // ⚠️ because of the third effect below
sideEffects: [
'updates the order record',
'recalculates the warehouse pick list',
'sends a confirmation email to the customer contact', // ⚠️ observed
'appears in the nightly ERP sync',
],
// ...
}The third line is why this tool is observed rather than reversible. Reverting the order record is one query; the email has been read.
// A cheap discipline that keeps the list honest: the preview renders it.
// A rep confirming an action sees what else it will do.
preview: async (args, scope) => ({
summary: `Change order ${args.orderId} quantity to ${args.quantity}`,
alsoWill: tool.sideEffects.filter((e) => !e.startsWith('updates the order record')),
})Showing the side-effect list in the confirmation found two stale entries on Atlas within a month — a rep said "it doesn't email them any more", and the list was corrected.
Dry run as a first-class mode
Beyond shadow mode, which hides the effect from the user, a dry run is explicit and user-facing.
// Every write tool implements it, and the executor can always offer it.
export interface Tool {
run(args: unknown, scope: Scope, context: RunContext): Promise<ToolResult>
// ⚠️ Mandatory for write tools, enforced in registerTool. A tool that
// cannot describe what it would do cannot be confirmed meaningfully.
preview(args: unknown, scope: Scope): Promise<Preview>
}// Which makes "what would happen if" answerable without doing it — a
// genuinely useful capability in its own right, not only a safety control.
// "If I applied a 15% discount to the Henderson order, what would it come to?"Rate limits on effects, not on calls
A per-user rate limit on tool calls is standard. The limit that matters is on effects, and it is different.
// Per capability, per window, with a cap on cumulative value where the
// action has one. Chapter 123 covers abuse; this is the blast-radius bound
// for an agent that goes wrong while behaving normally.
export const EFFECT_LIMITS: Record<Capability, EffectLimit> = {
send_quote: { perHour: 20, perDay: 60 },
apply_credit:{ perHour: 5, perDay: 15, cumulativeValuePerDay: 25_000 },
open_ticket: { perHour: 15, perDay: 40 },
update_order:{ perHour: 30, perDay: 100 },
}// ⚠️ Scoped per user AND globally. A bug that affects every user is not
// caught by a per-user limit, and it is the bug that matters.
if (await effectCount(capability, { scope: 'global', window: '1h' }) > limit.perHour * 10) {
await pauseCapability(capability)
alert(`global rate for ${capability} exceeded — capability paused`)
}The global pause is the control that bounds a bad deploy. Atlas's limits have never fired for a user and fired once globally, on a deploy where a precondition check was accidentally inverted and ticket creation ran at eleven times the normal rate for four minutes.
The failure mode: the reversible action that was not
update_order is classified reversible. Changing a quantity is one update, and the previous value is in the audit log.
A rep asks Atlas to change an order quantity from 200 to 180. It does. The order is in picking status, and the warehouse system had already generated a pick list for 200 — which a picker is holding.
Reverting the order record does not recall the picker. The physical state has diverged from the system state, and the reconciliation is a phone call.
The error is not in the tool. It is that reversibility was assessed as a property of the record rather than of the state the record is in.
// Reversibility is conditional on state, and the tool must say so.
export const updateOrder: Tool = {
name: 'update_order',
kind: 'write',
// ⚠️ A function, not a constant. The same action is reversible on a
// draft order and observed on one that is being picked.
reversibility: async (args, scope) => {
const order = await getOrder(args.orderId, scope)
return order.status === 'draft' || order.status === 'confirmed'
? 'reversible'
: 'observed'
},
// ...
}// And the executor resolves it before choosing controls, so a state-
// dependent action gets a confirmation exactly when it needs one.
const level = typeof tool.reversibility === 'function'
? await tool.reversibility(args, scope)
: tool.reversibility
if (CONTROLS[level].confirm && !context.confirmed) {
return { ok: false, kind: 'needs_confirmation',
preview: await tool.preview(args, scope),
// Say WHY confirmation is required. "This order is being picked" is
// the information the rep needs to decide.
reason: `This order is in '${order.status}'. A change now affects work in progress.` }
}Reversibility is a property of the action in a state, not of the action. A tool that declares it as a constant will be right most of the time and wrong exactly when it matters.
Key points
- Reversibility has four levels. The misclassified one is "observed": the state is restorable and the knowledge is not.
- Derive controls from the level in the executor, so a tool cannot be registered with the wrong combination.
- Split irreversible actions into a reversible stage that carries all the judgement and a one-argument stage that a person confirms.
- Confirm against a real artefact, not a description of the arguments. Surface the recipient separately.
- Compensation is a new fact, not an erasure, and a reversal tool carries the same controls as the original.
- Declare the whole side-effect chain, including effects in systems you do not own, and render it in the confirmation so it stays honest.
- Make
previewmandatory for write tools. It is a safety control and a useful feature. - Rate-limit effects per capability, per user and globally. The global pause is what bounds a bad deploy.
- Reversibility depends on the state the object is in. Declare it as a function and say why confirmation is required.
Next: confirmation flows — when a human must approve, and how to ask without training them to click yes.