Skip to main content

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 stores expires_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, gates action_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.