ADR 0018: Bound the idempotency window and name a replay
Context
ADR-0005 made every financial mutation an idempotent operation, but the records it writes were honoured forever, and a replayed acceptance was byte-identical to a first acceptance. A client could not tell whether its request executed or was deduplicated, the promise “same result for an equal replay” had no stated end, and the table only ever grew. Bridge’s published idempotency contract — a bounded guarantee window, a marked replay, and a stable refusal afterwards — is the shape this API adopts for itself and asks of Perflo in PFR-007.Decision
Each idempotency record storesexpires_at, stamped at write time from
idempotency_key_ttl_seconds (twenty-four hours). Storing it means a
configuration change never retroactively re-opens or closes already-issued
keys — the property a published guarantee window needs.
A key presented after its window is refused with HTTP 409
idempotency_key_expired and is never executed: once the window lapsed
the API cannot promise “no duplicate”, and executing would risk a second
transfer. The expiry check runs before the request-hash comparison, so an
expired key with a changed body still hears the actionable remedy — mint a
new key — rather than being sent to fix a body that would still be
refused. An unequal replay inside the window stays HTTP 409
idempotency_key_conflict: a distinct code, because the remedy differs.
A replayed acceptance is marked with the response header
Idempotent-Replayed: true, sent only on a replay — never as false, or
absence and false would mean the same thing twice. The marker is a header,
not a body field, because OperationView is also returned by reads and
polls where “was this a replay” is meaningless. The header is declared on
all ten keyed routes and exposed to cross-origin browser clients.
A replay returns the live operation row, not a stored response body. All
keyed routes return a handle to a state machine designed to change; a
frozen body would report accepted for a transfer that already failed,
would hand back a dead approval URL, and would persist the decrypted
provider approval context that Vault ciphertext exists to protect.
Identity of the answer satisfies the guarantee: same operation, no side
effects, no duplicate.
An hourly worker task prunes records whose creation is older than
idempotency_record_retention_seconds (thirty days), in bounded batches
so a hot table never takes a long lock. Pruning a record deletes only the
child row of the operations foreign key: the operation survives, its reads
keep working, and the audit trail is intact — the only thing lost is
finding that operation by key, which expiry already withdrew twenty-nine
days earlier. Nothing deletes operations, so no orphan path exists in the
other direction.
Consequences
Between expiry and pruning — from twenty-four hours to thirty days — a reused key gets a deterministic 409. Past retention the record is gone and the key reads as brand new; that reuse is outside the published guarantee, as it is outside Bridge’s. Clients should mint a fresh UUID per intent and keep it until the operation settles.POST /v1/quotes remains unkeyed and
is the named follow-up: it is the one unkeyed route that both writes a
durable row and calls the provider, but keying it needs a shape change to
the idempotency table’s non-nullable operation link, and meanwhile a
duplicate quote is an inert row re-reading an estimate — confirmation
consumption and quote binding prevent any duplicate transfer. POST /v1/kyc/sessions stays unkeyed because the live adapter’s session start
performs no provider call and writes no row; there is nothing to
deduplicate until PFR-003 lands a session-minting endpoint.
Rejected alternatives
Persisting response bodies for replay: a snapshot reports a state the operation has left, gatesaction_required on an expiry that has passed,
and stores decrypted approval context outside Vault.
Adopting HTTP 422 for the unequal replay, as Bridge does: a missing
Idempotency-Key is already a 422 validation_failed, so Bridge’s scheme
would collide “you forgot the key” with “you reused it”, and RFC 9110
calls a key already bound to a different request a conflict with current
state.
HTTP 410 for the expired key: more precise, but it would add a new
documented response to every keyed route for a distinction the machine
code already carries.
No TTL: an unbounded promise over an unbounded table.
A request-scoped context variable for the replay marker: implicit state
read at a distance, and one refactor away from living on the shared
service singleton where concurrent requests would cross-contaminate. The
tuple return is house style.