> ## Documentation Index
> Fetch the complete documentation index at: https://docs.neobank.proofof.tech/llms.txt
> Use this file to discover all available pages before exploring further.

# Bound the idempotency window

> Idempotency keys are honoured for a stored twenty-four-hour window, replays carry Idempotent-Replayed true, and expired keys are refused, never re-executed.

# 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.
