Idempotency¶
What happens to a write when nobody sees the answer.
The problem¶
A turn is a loop: the model generates, asks for a tool, the tool runs, the
result goes back, it generates again. Tools are the only part of that loop
which touches anything outside the transcript — fetch reaches a workspace's
allowed hosts, write-object puts bytes in a bucket.
So there is a window, between a request leaving the pod and its response arriving, where the world has changed and we do not know it. Anything that ends a turn inside that window — a crash, a lease expiring, a pod being evicted — leaves a write whose outcome was never observed.
Not a stop: that is read at a round boundary and never lands inside the window at all, for the reasons under Stop solved this differently below. What is left is the set of endings nobody chose.
Today nothing records that. The turn fails, the job is retried, and the retry
runs the tool again because there is nothing to tell it the first attempt got
as far as sending. For fetch_url against a search engine this is harmless.
For a POST that moves money it is not, and the tools that move money are the
ones a platform like this exists to run.
What a shell gets for free, and we do not¶
The obvious model is Claude Code: interrupt a command, and the shell sends a signal and detaches. Whatever the command does next is its own business.
That works because a shell command is a separate process. It has its own memory, its own lifetime, its own fate, and the kernel enforces the boundary. Detaching costs nothing because nothing is shared.
A tool call here runs inside the store, on the guest's linear memory, on the
pod's heap, charged against the pod's admission. There is nowhere to detach it
to. Keeping an interrupted call alive means keeping the whole instance alive —
and admission charges ASSUMED_TURN_BYTES -- 100MB -- for a turn's lifetime,
so an interrupted turn that lingers is one that still costs a pod most of that.
There is also no signal. Nothing in wit/agent.wit lets the host ask a guest to
wind up; the only interruption primitives are blunt ones — drop the future,
which unwinds at an await point, or exhaust fuel, which traps. Neither is a
request the guest can answer gracefully.
So the guest is always torn down. That is not a judgment call, it follows from where the memory lives. The judgment call is what we record about the call it was suspended inside.
Three states, not two¶
Most caches model two states: we have a result, or we do not. That is precisely what breaks under cancellation, because an interrupted write silently becomes "we do not" and gets re-executed.
The missing state is the one in the middle:
completed— sent, answered, result recorded. Replay is exact and free.attempted— sent, outcome never observed. The world may or may not have changed.- absent — never tried.
attempted rather than unknown: the transcript records events, not our
feelings about them. We know exactly what we did (sent it) and exactly what we
did not (observe the outcome). Nothing is unknown except the remote's state,
which was never ours to know.
An attempted record never resolves itself. Not on retry, not on a timer,
not by a heuristic about how likely the request was to have landed. It is
durable, and it is resolved by evidence or by a person.
Key and cache are different mechanisms¶
They solve different halves and conflating them is how these systems go wrong.
The key is for the recipient. It is a claim about identity that a remote system honors: send the same key twice, get the original response back, and correctness holds even if our pod died between send and receive. This is real idempotency, and it is the only thing that closes the window.
The cache is for us. It is a memory of what we did, and it can only ever be best-effort, because the gap where our record is wrong is exactly the gap the key exists to cover. A cancelled call cannot be cached as a result, only as an attempt.
Which settles what to do with an attempted record on replay:
- With a key → resend it. That is what the key is for. The recipient
dedupes, the ambiguity collapses,
attemptedbecomescompleted. - Without a key → we cannot resolve it and must not pretend. It stays
attemptedand the model is told so.
Three levels, opt-in¶
A tool declares which it wants. Default is the current behavior, so nothing changes for tools that do not care.
none— nothing recorded, replay re-executes. Right for reads and for anything whose repetition is harmless.cached— the host derives a key, records the outcome, and serves a replay from the record. Buys replay-within-workflow and honestattemptedstates. Works against recipients that know nothing about idempotency.keyed— the tool accepts an idempotency key; the host mints a stable one and passes it through. Nowattemptedis resolvable, and the guarantee is end-to-end rather than best-effort.
A properly keyed tool should never need a human. But keyed is the
minority case, and building as though it were the default gets the priorities
backwards.
Stripe and the payment processors support idempotency keys because they had to. A customer's booking system, a framework-generated CRUD endpoint, the internal API a workspace actually points an agent at -- most of them have never heard of one. Those recipients are the reason this document exists, not the degraded path it falls back to.
That is the honest position: from our side this can only ever be best-effort, and best-effort is better than the alternative, which today is a retry that re-runs the tool with nothing recording that the first attempt sent. Even with nothing else built, telling the model "sent, outcome not observed" removes the silent duplicate.
Two things to try before escalating¶
attempted with no key is unresolvable by us. It is not always unresolvable
by the recipient, and two declarations turn a large class of unkeyed APIs into
resolvable ones.
A read-back probe. Many APIs that cannot dedupe a write can still answer
whether the thing exists -- GET /bookings?reference=.... A tool that declares
how to ask lets the host resolve attempted by asking, with no person
involved and nothing required of the recipient beyond an ordinary read.
This is worth more than it first looks. It does not improve our record, which was never the weak part: we know exactly that we sent. It asks the place that knows the part we cannot see, which is the only way that question gets a real answer.
The probe has to be safe to run repeatedly and must distinguish "not there"
from "cannot tell" -- a probe that errors is not evidence of absence, and
treating it as such would resolve attempted to "never happened" on the
strength of a network blip. Failing to confirm means still attempted, the
same rule the egress commitment follows: could not verify means refused.
A natural key the recipient merely stores. If the workspace's API accepts a reference field and keeps it -- an invoice number, a booking reference the agent generates -- then the write carries identity even though the API knows nothing about idempotency. A duplicate is then detectable by the probe above, and sometimes refused by the recipient's own uniqueness constraint, which is the best outcome available without their cooperation.
Both are properties of the recipient rather than of the tool's own logic, which
is why they belong beside none/cached/keyed as things a tool declares
about what it is talking to.
Escalation remains for what neither covers: a recipient that cannot be asked and stores nothing that identifies the write. If that fires often it is a signal about the integration rather than about the platform.
Key derivation is the whole ballgame¶
If the key is a hash of the arguments, then two legitimately distinct calls with identical arguments — "append a line to the log", deliberately, twice — collapse into one, and the second silently returns the first's result. That is a correctness bug that presents as a cache hit, which is the worst kind to find.
So scope is explicit and the tool chooses, because only the tool's author knows whether repetition is meaningful:
(session, turn, call-ordinal)— safe default. Dedupes crash-replay of the same call and nothing else. Two deliberate identical calls stay distinct.(session, arguments)— dedupes across a session. Changes tool semantics: the second identical call is defined to be the same call.- explicit — the tool names its own key from its own arguments, e.g. an invoice id. Best when the domain has a natural identity.
A model repeating itself is the same problem¶
Crash replay is not the only way one write gets sent twice. A model can do it
in a single turn, as two separate calls. A small model booking a room did
exactly that: the first POST /bookings came back 201, it sent the identical
request again, got 409 for the room it had just taken, and told the person
the room was gone. Nothing crashed and nothing retried; the model just did not
read its answer.
The ordinal scope cannot see this -- the two calls have different ordinals, so they are two calls. The explicit scope sees it without trying, because a key derived from what the operation is does not care why it was sent again. A booking is the room, the guest and the dates; sent a second time it derives the same key, finds the first record, and is answered with the first booking. That is one mechanism covering both cases, and it is why explicit is the scope worth building first for any operation whose domain has an identity: the recipient's own key where it takes one, the host's record where it does not.
It is also a protection no larger model makes unnecessary. A model that reads every answer still has its job retried after an eviction, and the derived key is what makes that retry the same booking.
Where an operation declares its identity¶
Today only the charge has a derived key, and only as prose:
charge_payment_account.md tells the model to build
charge-<booking_id>-<amount_pence> and send it. That works when the recipient
takes a key and the model follows the instruction. It gives the host nothing
to derive a key from, so where the recipient takes none there is no record to
keep.
The declaration belongs on the operation, in the skill's specification, where the host can read it:
operation:
method: POST
url: http://outturn-hollowbrook:8084/bookings
body: [room_id, guest_name, arrival, departure]
identity: [room_id, guest_name, arrival, departure]
Built, as identity in an operation's frontmatter
(skill-operations.md), with key: field <name> or
key: header <name> where the recipient takes one.
Fields of the request body, by name, whose values together are the write's identity. From them the host derives the key, so the model is not asked to:
- The recipient takes a key (the operation also names the header or field
it goes in): the host sends the derived key, and the recipient dedupes.
That is
keyed. - It takes none: the host keeps the record under the derived key and
answers a repeat from it. That is
cached-- best-effort, because a write the recipient took and we never saw answered is stillattempted, but it turns every repeat we did see answered into the first answer. - No identity is declared: writes are recorded under the ordinal scope, which covers crash replay and nothing else.
A write is a POST, PATCH or DELETE unless the operation says otherwise;
reads are never recorded. The skill author knows the domain, so the identity is
theirs to declare, and the OpenAPI wizard proposes one from the request schema
-- the required fields of a create are usually it -- for them to confirm.
A skill-derived key and a host-derived one must not disagree, which settles what happens to the charge: once the host derives keys, its skill stops telling the model how to build one, and the specification names the fields instead.
Hollowbrook proves both halves. Its charge declares an identity and a key field, so the host sends the key and the house dedupes -- and its prose no longer tells the model how to build one. Its booking declares an identity and no key, since the house takes none, so the host covers it.
How the host covers it¶
The runtime host asks the API's record (/v1/work/invocations, under the
turn's lease) before a declared write goes out, keyed by workspace for a day:
- Never seen: claimed as
attempted, and sent. - Seen answered: the first answer is given again, marked so the model says it was already done, and nothing is sent.
- Seen sent, never answered: refused. Whether it landed is not known.
A recipient's refusal -- a 4xx -- or the gateway's changed nothing, so it
releases the claim and the retry it invites goes out. A connection that broke
after sending leaves it attempted, and so does a 5xx: a 502, 503 or 504 from
a proxy often follows a write the server behind it committed, and sending it
again is the mistake this exists to prevent. So does an answer cut off on the
way back -- the recipient's reply ending mid-body, which the gateway marks
did-not-complete as it does a lost connection, or the gateway's own reply
arriving in a shape the runtime cannot read: either way the recipient
answered, so it may have done what it was asked. Settling is best-effort: a completed write left
unrecorded looks attempted to a repeat, and is refused rather than sent twice.
Why the host owns it¶
Same argument as egress. If the guest owns idempotency, a workspace's untrusted code decides whether its own writes dedupe, and "did this happen twice" depends on code we did not write.
The host mints the key, the host owns the record, the host writes the three
states. The guest asks for a tool and is told what happened, which is the
existing contract — chat already says cancellation is "the host dropping the
stream rather than something the guest must handle", and this is that principle
applied to writes.
Stop solved this differently, and better¶
This section used to describe what a stop would do with an in-flight tool call:
tear the guest down, record the call as attempted, synthesise a tool result
saying the outcome was not observed. The stop that shipped does none of that,
because it never stops inside a round.
limits.cancelled is read at the round boundary, before chat is called, so a
turn that is asked to stop returns what it has written and runs nothing. And a
round that the gateway cut partway is refused wholesale: the guest sees a
completion with tool calls and no finish reason, treats the arguments as
possibly truncated mid-JSON, and answers every call with "not run" rather than
executing any of them (agents/default/src/lib.rs).
So a deliberate stop cannot strand a tool call, and this machinery is not what makes stop safe. Avoiding the window entirely beat recording it.
Timers fire once¶
A wakeup is a job, and a job is at-least-once: a lease nobody renews is reaped
and the work handed out again. For a sleep that is harmless, because ending
one is a conditional update on its queue item and the second firing finds it
settled. A timer has no item, so its firing writes the note, enqueues the turn
and closes the job in one transaction (wake::fire_timer_on); a crash loses
all three or none, and the re-fire after a reap is the only firing there is.
The worker finishing a turn closes the job the same way, in the commit that
makes the reply final, so a reap between the two cannot have the retry find a
finished attempt and answer the prompt a second time.
What is still open¶
Everything that ends a turn without reaching a round boundary. A pod
evicted, a worker crashed, a lease expired while a fetch was in flight: the
guest is gone, the job is retried, and the retry runs the tool again with
nothing recording that the first attempt got as far as sending.
That is the case this document is for, and it is narrower than it was written to be -- not "what a stop has to avoid creating" but what a crash creates whatever anyone intends. It is also the case that cannot be designed away by choosing a better boundary, because nothing chose it.
Side effects may have landed and nobody can say. That is a property of a crash rather than of a decision, and it is the same guarantee a killed shell command gives, so it is not a weaker position than the familiar one.
Not yet built¶
The record and declared identities are built, above. The rest is not. Written before the tools that need it, while the design is
still free: today there is fetch and object storage, so the pressure is low.
The pieces, roughly in order:
- A
tool_invocationstable holding the tristate, keyed and scoped as above. Partitioned by workspace like everything else that grows per-workspace. - Derivation policy in the WIT, so a tool declares its level and scope.
x-outturn-identityread from a skill's specification, so afetch_urlwrite to a declared operation derives its key on the host -- see Where an operation declares its identity. Proposed by the OpenAPI wizard.- The replay path in the host's tool dispatch, which is where a retry after a crash finds the record the first attempt left.
- The read-back probe, which is what keeps escalation rare against the recipients this is mostly for. Later than the tristate and before any UI for resolving one by hand.
Stop is no longer on that list, for the reason given above.
The pressure is still low, and it is worth being honest about why. The tools
that exist are fetch_url and object storage, and a repeated write_object is
idempotent by construction -- it replaces. So the case that needs this is a
POST through fetch_url to a workspace's own API, which is exactly what
integrations.md is about. This becomes urgent when
integrations do, and not before.
What approvals already assume, and what will break¶
Approval gating is built (see approvals.md), and it has a dependency on this design that nothing else records -- so it is recorded here, where somebody building the tristate will read it.
When the gateway refuses a call for want of an approval, the guest is answered with a tool result ending "Do not retry this request." That sentence is right while the turn is parking: it stops the guest spinning on a call nobody has answered yet.
It is wrong the moment somebody says yes. The refusal is a tool result, so it replays verbatim to the turn that resumes -- a turn whose entire purpose is to make that call -- and the model reads the last thing in its own transcript telling it not to. It obeys, says nothing, and the turn ends with the grant unspent and the call never made. That happened twice before it was found, with every other part of the loop looking correct.
So the guest is never asked to make it. The refused request is held whole,
and once somebody says yes the platform sends it, from the next turn's runtime,
before the guest runs -- with the claim, the key and the settle every declared
write gets -- and worker::answered puts the answer where the refusal was. See
approvals.md, The approval carries the call. Only the held
request is sent after approval: a call the guest makes again is refused and
asked about again, because the grant that let the held one out travels in a
token the guest's calls never carry.
What becomes of it is this document's tristate, given to somebody who can find
out. Answered, it is completed. Never sent -- the gateway's not-sent mark,
a connection that failed before a byte was written -- nothing happened, and it
is sent again. Sent with no answer is attempted: sent again only where the
operation carries a key the recipient dedupes on, and otherwise put in front of
a person as outcome unknown, to check and then resend or mark done. A held
request is marked sending with the API before it goes, so a pod lost before
it reports leaves it attempted too, rather than looking unsent to the turn's
retry -- which would otherwise send an undeclared fetch_url twice.
An approval raised before requests were held still takes the old path:
worker::answered retracts the sentence on the resuming turn, replacing it with
"It has since been approved, so make this call now." That retraction is only
safe because the gate refuses before dispatch. The call provably never went
out, so telling the agent to make it is telling it to do something once, not
twice. The safety rests on where the gate fires, and nothing in the sentence
says so.
Two things follow for the work below:
- If a gate ever refuses after dispatch -- an approval required by the
recipient rather than by us, a refusal that arrives as a response -- then the
retraction is telling an agent to redo a call that may already have landed.
That is exactly
attempted, and the retraction must then consult the tristate rather than assuming. - A gated call is the best-behaved case this document has: it is a
POSTthroughfetch_urlto a workspace's own API, with a key the skill already requires.charge_payment_account.mdderivescharge-<booking_id>-<amount_pence>, which is a real key on a real write, arrived at through the skill rather than through the host. When derivation policy moves into the WIT, that skill is the worked example to check the design against -- and the place to notice that a skill-derived key and a host-derived one must not disagree.
The approval loop therefore needs no change when the tristate lands. What needs checking is the assumption above, and it is checked by asking one question: can a gate refuse a call that was already sent?
Human in the loop¶
This section is about escalating an unresolved attempted write to a person,
which is a different thing from the approval gating now built: that asks
permission before a call, this asks a person to settle what happened after
one. They will share a queue -- action-queue.md -- and
nothing else.
An attempted write that neither a key nor a probe can settle is not something
an agent should resolve by guessing, and not something the platform should
resolve by policy. "Did the payment go through, retry or not" has no correct
default. It is a person's call.
A person is the last resort rather than the first, which is the point of the two declarations above: the goal is that escalation is rare, not that it is well-designed.
The approval queue exists now, so the UI this would surface in is no longer
hypothetical -- but nothing here waits for it. An attempted record is
durable and resolves whenever something resolves it: a person reading the
transcript today, a HITL flow later, or an automated reconciler for recipients
that can be asked "did you get this". The record does not care which, so HITL
arrives as a UI over state that already exists rather than as a migration.
Until then the requirement is only that attempted does not quietly resolve
itself — the model is told plainly that the outcome was not observed and must
not assume either way, retry is available but never automatic, and the record is
visible in the transcript. That is a human in the loop, just synchronous and
unstructured.