Exactly-once side effects for tool calls

Your agent retried. The refund moved twice.

Agents retry on timeout, re-plan halfway through, and run as two workers that never see each other. The tool endpoint sees three valid requests and does the thing three times. Idem sits in front of that endpoint, recognises the requests as one intent, and executes it once — every caller gets the same stored result.

How coalescing works
01 — Live ledger

Fire the same refund twice. Count the executions.

Every scenario below sends real HTTP requests to the engine on this box. The request log on the left is what arrived; the counters on the right are what the ledger actually did. No fixtures, no animation of fake numbers.

idle
Each scenario resets the ledger, then fires. Watch requests and executions diverge.

Request log

POST /api/invoke · live
TIMERUNIDFINGERPRINTOUTCOME
no requests yet
requests
0
executions
0
coalesced
0
replays
0
held
0
Fingerprint diff — byte-different, semantically identical
request A …
↓ same semantic hash ↓
request B …
one fingerprint—

02 — Why this is not a retry helper

Retry helpers dedupe strings. This dedupes intent.

Two agents almost never send byte-identical JSON. Key order differs, one formats money as "$12.00" and the other as "12.00 USD", both attach different trace ids. String equality sees three calls. Idem sees one refund.

/ 01

A semantic fingerprint

Keys are sorted recursively, money is parsed into an integer minor-unit value with an ISO currency, volatile fields — request id, idempotency key, timestamp, trace id — are dropped, and emails are lowercased. The hash identifies the action, not the payload's spelling.

/ 02

One promise per fingerprint

The first request reserves the fingerprint and starts the call. Everyone who arrives while it is reserved or in-flight awaits the same promise and receives the same result object, marked coalesced. The tool endpoint is called once, no matter how many workers race.

/ 03

Replay is a read

After commit, the result lives in the ledger. A duplicate arriving hours later is served from that record with replay:true and never touches the tool. The proof is boring on purpose: requests go up, executions do not.

03 — Method

Five steps. One hash. One execution.

No model, no queue, no distributed consensus in this build — a single process with an in-memory ledger. That is enough to show the mechanism honestly, and it is the same shape you would put in front of Redis or Postgres.

01

Normalize

Drop volatile keys, lowercase emails, parse the amount into minor units. "$12.00", "12.00 USD" and "1200 cents" all become {amount_minor: 1200, currency: "USD"}.

02

Fingerprint and reserve

Hash the canonical JSON. If the fingerprint is unseen, write a reserved ledger row and start executing. The row is the lock; the promise is what callers wait on.

03

Coalesce

A concurrent duplicate finds the row in reserved or in-flight and awaits the existing promise. It does not start a second execution and does not return early with a fake success.

04

Commit, then replay

On success the row becomes committed with the stored result. Later duplicates read it back as replay. On failure the row is released so a retry can try again.

05

Hold the suspicious near-duplicate

Same tool, customer and amount, but a new runId more than 5 s after the first arrival: different fingerprint, same economic intent. That is either a legitimate second refund or a runaway loop. Idem refuses to guess — it parks the row as held until a human releases it.

// semantic identity canon = sortKeys(stripVolatile(args)) canon.amount_minor = toMinor(amount, currency) canon.customer = lower(customer) fp = sha256(tool + "\n" + canon) // ledger states reserved → in-flight → committed // decision on arrival if fp committed → replay (no call) if fp in flight → coalesce (await promise) if near-duplicate > 5s, new runId → held else → execute once // invariant the demo proves executions = distinct(fp) executed requests ≥ executions

The 2.5 s execution window is simulated with an await so the race is observable. In production the ledger is the durable part; the promise coalescing is per-process and backed by the same row lock.

04 — API

Point the agent at the proxy. That is the integration.

Same request shape the tool already accepts, plus a runId. The response tells you which of the four things happened.

# two workers, different serialization, one intent
curl -s https://idem.toyou.gr/api/invoke \
  -H 'content-type: application/json' \
  -d '{
    "tool": "refund",
    "runId": "worker-a",
    "args": {
      "customer": "Ada@Example.com",
      "amount": "$12.00",
      "currency": "usd"
    }
  }'

# inspect the ledger
curl -s https://idem.toyou.gr/api/state
// first caller
{
  "outcome": "executed",
  "fingerprint": "9c1f…",
  "result": {
    "refund_id": "re_4a91c2d0e7",
    "amount_minor": 1200,
    "currency": "USD"
  }
}

// everyone who raced it
{ "outcome": "coalesced", "coalesced": true, … }

// anyone after commit
{ "outcome": "replay", "replay": true, … }

POST /api/release {id} releases a held near-duplicate. POST /api/reset clears the demo ledger. GET /healthz for liveness.

05 — Limits, stated up front

What this will get wrong.

Exactly-once is a property of a boundary you choose, not a law of the universe. These are the three limits that decide whether Idem fits.

Semantic collisions block legitimate duplicates

If a customer genuinely needs two identical $12 refunds, the fingerprint cannot tell that from a retry. Idem will coalesce or replay the second one. The escape hatch is a new runId and the 5 s hold — which is a human decision, not a proof.

It sits in the write path

Every tool call now depends on the proxy and its ledger being up. That is one more hop of latency and one more thing to page someone about. A sidecar that is down must fail closed, not silently pass duplicates through.

Teams think their orchestrator already does this

Most agent frameworks claim idempotent tools, then dedupe on a run id that is regenerated on every re-plan. The duplicate arrives with a new id and wins. This proxy exists because that assumption is usually untested, not because orchestration is bad.