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.
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.
- MDN: Idempotent — HTTP's definition; PUT and DELETE idempotent, POST not
- Stripe: Idempotent requests — idempotency keys, the API-side pattern for safe retries
- MCP spec: Tools — tool annotations, and why clients must treat them as untrusted
- seldonframe/reelier — the open-source (AGPL-3.0) implementation: classifier, write gate, escalation levels