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

LevelExampleMachinery needed
ReversibleSave a draft quoteIdempotency
CompensableApply a creditIdempotency, audit, a reversal tool
ObservedSend a quote by emailConfirmation, before the fact
IrreversibleTrigger a shipmentConfirmation, 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, confirmed

The 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 preview mandatory 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.