# Idempotent write gating

> Idempotent write gating is a safety policy for re-running an AI agent's recorded work: every tool call is classified by its side effect — read, idempotent write (an operation that converges when retried, like a PUT or an upsert), or destructive (one that compounds, like a send or a charge) — and on re-execution only reads fire by default. Idempotent writes wait behind an explicit opt-in flag, destructive steps behind a stronger one, and a gated step fails loudly with the exact withheld call printed on the receipt. The result is retry-safe agent operations: a recorded job can be replayed, retried, or scheduled without duplicating side effects, because the runner refuses the writes instead of trusting every endpoint to deduplicate them.

Markdown twin of https://www.reelier.com/learn/idempotent-write-gating — one entry in Reelier's glossary of agent-replay terms. The blockquote above is the canonical definition; quote it with the link.

## Retries are the normal case

An agent's tool calls get re-executed all the time: a timeout retry, a scheduled re-run, a deterministic replay of a recorded run, a human re-running a job that died halfway. Each re-execution asks the same question of each call: is this safe to fire twice?

Distributed systems answered this long before agents. An operation is idempotent when running it twice leaves the world in the state one run would have produced. HTTP bakes the distinction into its methods — `PUT` and `DELETE` are defined idempotent, `POST` is not — and payment APIs formalized the retry side with idempotency keys: the client attaches a unique key, the server recognizes a retry of the same request and returns the recorded result instead of charging twice.

Agents inherit the problem in a worse shape. The caller is a model, the calls are generated rather than hand-written, and recorded runs get re-executed by schedulers and replay tools by design. The vocabulary you will meet in the wild: `retry-safe agent operations`, `at-most-once execution`, `agent side effects`. "Write gating" is Reelier's name for the runner-side half of that toolkit: classify every call's effect up front and refuse the risky ones by default.

## Three effect classes, ranked by what a mistake costs

Reelier classifies every tool call as `read`, `idempotent-write`, or `destructive` from verb tokens in the tool name. `get`, `list`, `search` read. `create`, `update`, `set`, `upsert`, `put` are idempotent writes: repeating them converges rather than compounds. `delete`, `send`, `pay`, `publish`, `charge` are destructive: a second firing does new damage. The builtin `http.post` classifies as an idempotent write. One classifier is shared by the compiler and the MCP adapter, so the two can never disagree about a tool.

The ladder is asymmetric on purpose. A false read is a write that slips the gate; a false destructive only costs coverage. So MCP tool annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`) may tighten a classification or refine an unrecognized verb, never downgrade a verb-list match — the MCP spec itself requires clients to treat annotations as untrusted — and a tool name with no recognized verb and no annotation defaults to destructive with a review flag, never to read.

The census is printed where a human reads it: `reelier compile` reports `effects: read=N idempotent-write=N destructive=N` for every compiled skill and warns when steps are side-effectful, because replaying a skill re-executes them.

## What fires on replay, what refuses

On `reelier run`, reads execute unconditionally. An `idempotent-write` step refuses unless the run started with `--allow-writes`; a `destructive` step refuses unless `--yes`, which implies `--allow-writes`. The refusal is specific: the step fails, the receipt prints the exact filled call that was withheld, arguments included, and the run exits 1.

The gate holds through escalation. Model help during replay is opt-in and BYOK, in levels: at the default `--max-level 0` an LLM client is never constructed; L2, the widest level, may re-execute one read or idempotent-write step to heal a drifted assertion. A destructive step is never escalated, at any level.

The default matters because read-only jobs are rare. In the measured corpus behind Reelier's launch writing, 1.1% of scanned sessions were read-only end-to-end. Nearly every real agent job writes somewhere, so a replay tool's write policy carries most of its safety story.

## The boundary: gating is not idempotency

The gate refuses calls at the runner. It does not make the operation on the other end idempotent. Once you pass `--allow-writes`, the class name becomes a claim you have to honor: `update`, `set`, and `upsert` converge when repeated; a `create` against an API with no idempotency key can still duplicate rows. The verb list stays conservative for the same reason — `publish` counts as destructive even though it looks set-like, because it broadcasts.

Classification reads names and hints, not server code. A tool named like a read that mutates will classify wrong; the default-deny rung and the tighten-only annotation rule shrink that window without closing it. The skill file is the correction path in both directions: a step can override its effect (a POST that searches, marked `effect: read`), and because a skill is markdown in your repo, the override shows up in review and in every diff.

## A real receipt

The receipt below is a real gated run, not a mockup: a two-step skill — Step 1 an `http.get` read of the npm registry, Step 2 an `http.post` notify step marked `effect: idempotent-write` — replayed with the published CLI and no `--allow-writes`. The read executes and binds the fresh version; the write refuses in 0ms, and the receipt prints the exact call it withheld.

```
npx -y reelier@latest run write-gate-demo.skill.md

✓ Step 1 — Latest dist-tag from the registry [passed] 167ms
✗ Step 2 — Notify the release webhook [failed] 0ms
    - Refusing to execute a write step (effect: idempotent-write) — replay is read-only by default. Pass --allow-writes to execute it. Filled action: http.post {"url":"https://example.com/hooks/releases","body":{"version":"0.13.0"}}

FAILED: 1/2 steps ok, 1 failed, 167ms total
```

Run by this page's author on 2026-07-21, in a scratch directory, with the published CLI (`npx -y reelier@latest`, resolved to `reelier@0.13.0`); exit code 1. The refused call's body reads `"version":"0.13.0"` because Step 1 ran first and bound the fresh registry value. The POST never left the machine.

## FAQ

**Does write gating make my agent's API calls idempotent?**

No. The gate decides whether a recorded write re-fires on replay; idempotency is a property of the operation itself. The two compose: keep writes gated by default, and when you do allow them, prefer operations that converge — set/upsert semantics, or requests carrying an idempotency key the server deduplicates on.

**If I pass --allow-writes, will a replay duplicate my side effects?**

Every `idempotent-write` step re-fires, so the honest answer is: only if those steps actually converge. That is what the class name asks of them — `update`, `set`, `upsert`, `put`. Destructive steps (`send`, `delete`, `pay`, `publish`) still refuse without `--yes`, and no escalation level ever re-executes them.

## Related

- [Deterministic replay — the re-run this gate makes safe](https://www.reelier.com/learn/deterministic-replay)
- [Run receipts — where a refused write shows up, filled action and all](https://www.reelier.com/learn/run-receipt)
- [Level-0 (zero-token) replay — the no-model default the gate runs under](https://www.reelier.com/learn/level-0-replay)
- [registry-latest — a read-only portfolio skill that replays green with zero flags](https://www.reelier.com/skills/registry-latest)
- [From the blog: scheduled agents are retries by construction](https://www.reelier.com/blog/scheduled-agents-zero-token)

## Sources

- [MDN: Idempotent — HTTP's definition; PUT and DELETE idempotent, POST not](https://developer.mozilla.org/en-US/docs/Glossary/Idempotent)
- [Stripe: Idempotent requests — idempotency keys, the API-side pattern for safe retries](https://docs.stripe.com/api/idempotent_requests)
- [MCP spec: Tools — tool annotations, and why clients must treat them as untrusted](https://modelcontextprotocol.io/specification/2025-06-18/server/tools)
- [seldonframe/reelier — the open-source (AGPL-3.0) implementation: classifier, write gate, escalation levels](https://github.com/seldonframe/reelier)

Other terms: [Deterministic replay](https://www.reelier.com/learn/deterministic-replay) · [Agent drift](https://www.reelier.com/learn/tool-call-drift) · [Run receipt](https://www.reelier.com/learn/run-receipt) · [Level-0 (zero-token) replay](https://www.reelier.com/learn/level-0-replay) · [Snapshot testing for AI agents](https://www.reelier.com/learn/agent-snapshot-testing) · [Session-to-skill (skill compilation)](https://www.reelier.com/learn/skill-compilation) · [Recording MCP tool calls](https://www.reelier.com/learn/mcp-recording) · [Regression testing for AI agents](https://www.reelier.com/learn/agent-regression-testing) · [Assertion grammar for agent outputs](https://www.reelier.com/learn/assertion-grammar) · [Agent memory vs deterministic replay](https://www.reelier.com/learn/agent-memory-vs-replay) · [Headless agent](https://www.reelier.com/learn/headless-agent)
