AI agents

Two-Phase Writes From the Agent's Side: Dry Run, Then Commit

Antonio Evans8 min read

A sequence diagram with two lanes: Agent on the left, Server on the right.
Key points

Key points

  1. 1Between the dry run and the commit, the agent must hold the plan token, the exact inputs that produced the plan, and the plan's expiration.
  2. 2The commit call must carry an input fingerprint so the server can reject a stale plan with a 409 response.
  3. 3The most common failure is interleaving: the agent runs other write actions after the dry run and then commits the old plan.
  4. 4Server-side tests prove the commit refuses bad tokens, but only agent-side tests prove the agent never sends a stale one.

The gap between the two calls belongs to the agent

A two-phase write looks simple from the outside. The agent calls a dry run endpoint, receives a plan describing exactly what would happen, then calls a commit endpoint to apply it. The server does the hard part: it validates inputs, builds the plan, mints a token, sets an expiration, and refuses to apply anything the token does not cover.

Server teams test that part well. They test token reuse, expiry, and mismatched payloads. What goes untested is the space between the calls, because the agent owns that space. The agent decides what to retain, how long to retain it, what other work to do in between, and whether to retry. Each of those decisions can turn a safe protocol into an unsafe one.

The failure is rarely dramatic. It is a plan that was correct when made and wrong when applied, with a token that still validated because nobody told the server the inputs had moved. Any prepare-then-apply protocol has this shape, and the classic objection to two-phase commit in microservices is exactly this window: the coordinator can fail after prepare and leave participants uncertain (Stack Overflow). An agent is a coordinator with an unusually short memory.

What the agent must hold between the calls

Treat the dry run response as a contract with four fields that must survive intact until the commit, or until the agent abandons the plan.

  • The plan token. An opaque value the server uses to look up the stored plan. The agent never parses it, never regenerates it, and never guesses at its structure.
  • The plan ID. A log-friendly identifier, separate from the token, that appears in logs and user-facing confirmation. If a human approves the write, this is what they approve.
  • The expiration. An absolute UTC timestamp, converted once at receipt. Once the clock passes it, the plan is dead and no commit should be attempted.
  • The exact inputs and their fingerprint. The canonical serialized form the agent sent, plus the fingerprint the server computed over it. The commit presents that fingerprint so the server can confirm nothing moved.
{
  "plan_id": "plan_8f21c",
  "plan_token": "pt_9c0e...opaque...",
  "expires_at": "2026-03-14T09:41:12Z",
  "input_fingerprint": "sha256:6b1f...",
  "actions": [
    {"op": "update", "target": "invoice/4412", "field": "status", "to": "paid"}
  ]
}

Store this object verbatim in task-scoped state. Do not re-derive the fingerprint locally; the server's value is authoritative, and a disagreement is a bug to surface, not to paper over.

The interleaving problem: correct calls, wrong order

This is the failure server-side tests never catch. An agent runs a dry run for a refund and gets a fingerprint over the current invoice state. Before committing, it fetches a support ticket, which is read-only and harmless. Then it notices a stale customer address and issues an address update, which is a write. Then it commits the refund with the original token.

The token is valid. The plan is not. If the commit carries the fingerprint captured at dry run time, the server sees the mismatch and returns a conflict. If the agent is expected to recompute the fingerprint before committing, a sloppy implementation recomputes over the new state, and the server applies an old plan to a new world.

The decision rule is blunt and belongs in your tool descriptions and your code: once a dry run returns, the inputs it covered are frozen until the commit resolves or the plan is abandoned. Reads are fine. Writes that invalidate the plan are not, unless followed by a fresh dry run.

If a needed write does occur, discard the token, run the dry run again against the new state, and present the new plan. The second plan may be identical, costing one round trip, or it may differ, in which case you avoided applying a stale intent.

This is the same discipline schema systems enforce: a producer should not assume its consumer is at the same version, and the safe move is to add and drain rather than mutate in place (Conduktor). The agent is the producer of a write intent, and the plan is the version it asks the server to honor.

Designing the commit so the server can say no

Three choices make staleness detectable instead of silent.

The commit takes the token and the fingerprint, and nothing else that matters. If the agent can send fresh inputs alongside the token, the server must decide which wins, and the approved plan stops being the thing that executes. Keep the plan payload server-side.

The conflict is specific. A 409 whose body names the changed field and the plan's creation time is far more useful to an agent than a generic error. It can then re-plan automatically or escalate. A bare 409 teaches it nothing.

Expiry is enforced, not advisory. A token past expiry is refused even when inputs are unchanged. The window exists because the world is only known to be as the plan assumed for so long.

Idempotency belongs on the commit, not the dry run. The commit should accept a client-generated key so a timeout and retry produce one applied plan. The standard contract: the server stores the key with its result, returns the stored result on repeat, and rejects the same key with a different payload (Basis Theory). The key must be generated before the first attempt, not during a retry (Zuplo).

ResponseMeaningAgent action
200, appliedPlan executedReport success with plan ID
200, stored resultEarlier attempt already applied itTreat as success
409, fingerprint mismatchInputs changed since dry runRe-run dry run, re-evaluate
409, token already usedDouble commit attemptRead outcome by plan ID
410 or 409, expiredWindow closedRe-run dry run if still intended
5xx or timeoutUnknown server stateRetry with the same idempotency key

The timeout rows matter most. An agent that treats silence as failure and starts a new plan will sometimes apply the write twice.

Worked example: a refund that must not double-apply

A support agent refunds 480.00 USD against invoice 4412 using refund.dry_run and refund.commit.

  1. It calls the dry run with {"invoice_id": "4412", "amount_cents": 48000}. The server returns a plan valid for ten minutes, fingerprint sha256:6b1f..., and a token.
  2. It stores the plan and checks expiry against UTC. Eight minutes remain.
  3. It calls shipping.lookup, a read, and logs the delivery exception as evidence.
  4. It commits with the token, the fingerprint, and idempotency key idem_7a12.
  5. Suppose another process already applied a partial 200.00 USD refund. The fingerprint no longer matches, and the server returns 409 naming the changed field.
  6. The agent does not retry. It re-runs the dry run, gets a plan for the remaining 280.00 USD, and presents that to the reviewer.

The interesting number is the gap between steps 5 and 6. A naive implementation retries twice, gives up, and reports "refund failed" while the invoice sits in a state nobody described. The correct implementation produces a truthful description of what remains.

If step 4 times out after the server applied the plan, the agent retries with idem_7a12, receives the stored result, and reports success once. If it times out before applying, the retry applies it. The agent cannot tell these apart, and does not need to.

The tests on each side of the boundary

Server tests prove the commit refuses bad requests. Agent tests prove the agent never sends them.

TestOwnerProves
Same token committed twiceServerSecond commit refused
Fingerprint mismatchServer409 names the changed field
Commit after expiryServerRefused with valid token
Same idempotency key, different payloadServerRequest rejected
Dry run, write, then commitAgentAgent re-plans instead of committing
Commit timeout, retryAgentSame idempotency key reused
Turn ends mid-planAgentPlan abandoned, not resumed

The last row is where agent frameworks go wrong. If the plan lives in a conversation transcript rather than explicit task state, a later turn can pick up an old token and commit it.

The tool schema should make misuse hard. If the commit marks both token and fingerprint as required, a missing field fails before the request leaves the process. JSON Schema's dependentRequired expresses the constraint that a token without a plan ID is invalid (JSON Schema reference, Stack Overflow).

def commit_plan(task, plan, idem_key):
    if plan.expires_at <= utcnow():
        return {"status": "expired", "action": "re-run dry run"}
    if task.inputs_dirty_since(plan.created_at):
        return {"status": "stale", "action": "re-run dry run"}
    resp = post("/refund/commit", json={
        "plan_token": plan.token,
        "input_fingerprint": plan.input_fingerprint,
    }, headers={"Idempotency-Key": idem_key}, timeout=30)
    if resp.status == 409:
        return {"status": "conflict", "detail": resp.json()["changed"]}
    return {"status": "ok", "result": resp.json()}

The dirty flag is the agent's own cheap defense, set by any mutating tool call touching the plan's target.

Failure handling and abandonment

Expired before commit. An agent that pauses for human approval longer than the window hits this routinely. The fix is not a longer window; approval should happen immediately before the commit, not before a queue.

Inputs changed. A 409 is information, not an error to retry. Re-plan.

Status unknown. Retry with the same idempotency key, then read the plan's state by plan ID. Generating a new key and re-planning is how double writes happen. The contract requires one key per logical operation, not per attempt (the idempotency token pattern).

Abandonment. Define it: task end, changed user intent, or expiry. Drop the token and record it as abandoned. "Planned and did not do it" is a distinct audit event from "never planned." In-flight plans need an owner and a lifespan, much as in-flight approval documents need an explicit treatment when rules change under them (approval workflow versioning).

Teams running many agents against shared internal APIs often standardize the tool-calling loop and plan-state rules in one layer. Quanta Inference exposes one API for text, image and embedding models, so the guard lives in one place rather than in every prompt.

Checklist for the agent loop

  1. Call the dry run with deterministically serialized inputs so the fingerprint is meaningful.
  2. Store token, plan ID, absolute UTC expiry, and fingerprint in task-scoped state, never in a revisitable transcript.
  3. Present the plan if a human is in the loop, and commit right after approval.
  4. Before committing, check expiry and the dirty flag. If either fires, re-plan and stop.
  5. Commit with token, fingerprint, and one idempotency key per logical operation.
  6. Map every response: success and stored result are done; conflict and expiry re-plan; unknown retries with the same key.
  7. On task end, clear the token and record abandonment.

The insight worth keeping: a two-phase write is one call plus a promise that the world has not moved. The server can keep that promise only if the caller sends the fingerprint and refuses to commit a plan it knows is stale. Write that promise into the tool schema and the task state, and the rest follows.

FAQ

FAQ

Why not have the server re-check inputs at commit time without a fingerprint?

It can, if it stored the exact inputs and can cheaply re-read every one. Often that is expensive or impossible, and the server still cannot tell whether the agent sent the plan it thinks it sent. A fingerprint makes the check a cheap comparison and lets the 409 name the changed field.

What should the agent do on a 409?

Do not retry. A fingerprint mismatch means the world moved and the plan describes a state that no longer exists. Discard the token, run the dry run again, and re-evaluate the new plan, including any human approval step.

How long should a plan stay valid?

Long enough to cover one human decision or one agent reasoning step, short enough that inputs are unlikely to change. Derive it from the cost of the write: a reversible draft edit tolerates a longer window than a payment. Enforce expiry strictly, because an advisory window is not a control.

Does an idempotency key replace the plan token?

No. The token proves a specific plan exists and is unexpired. The idempotency key proves two attempts are one logical operation, so the server returns one result instead of applying twice. Keep them in separate fields so logs and audits can tell them apart.

What if the agent process dies between the calls?

The plan expires and the token becomes unusable, which is correct: the write does not happen and the next turn starts from a fresh dry run. If the work must survive a restart, persist the plan in durable task state with its expiration and check expiry first on resume.

Sources

Sources

  1. Why is 2-phase commit not suitable for a microservices ...
  2. Schema Evolution: 8 Kafka Best Practices
  3. Idempotency | Basis Theory Developer Documentation
  4. Implementing Idempotency Keys in REST APIs
  5. Conditional schema validation - JSON Schema
  6. jsonSchema attribute conditionally required - Stack Overflow
  7. The Idempotency Token Pattern Every Event-Driven ...
  8. When approval rules change, should in-flight documents ...
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles