ADR-0010: Idempotency key retention and expiry
Status: Accepted · Date: 2026-10-19 · Resolves: OQ-4
Context
An expired key must never cause a second charge. payment.idempotency_key is UNIQUE forever, while
idempotency_key rows expire. We do not know how long terminals store and forward (OQ-4).
Decision
- Working assumption A12: a terminal never resends a payment more than 24 h after its first attempt.
idempotency_key.expires_at = created_at + 24 h(safeledger.idempotency.retention, default24h).- Within retention: the standard semantics (FR-2). A replay returns the same status and an equal
JSON body.
jsonbdoes not preserve bytes, so "byte for byte" is not promised. - After expiry the key is never reused. A request whose key row is expired, or whose key exists
in
payment.idempotency_key/reversalwithout a live key row, gets422, problemcode = IDEMPOTENCY_KEY_EXPIRED. It is not executed. A late store-and-forward replay can therefore never debit twice. - Fingerprint: SHA-256 hex over the request body re-serialised from the generated request model,
with properties sorted alphabetically and no whitespace (Jackson
SORT_PROPERTIES_ALPHABETICALLY+ORDER_MAP_ENTRIES_BY_KEYS). Equivalent JSON with different key order or whitespace therefore has the same fingerprint. - Key scope is global per
scope(PAYMENT,REVERSAL). It is not per terminal: UUID keys make collisions negligible, and a collision with a different body is a422. - Purging expired rows is deferred (roadmap). Expired rows are harmless, because they still answer
IDEMPOTENCY_KEY_EXPIRED.
Consequences
- + No double-charge path through key expiry.
- − A terminal that replays after 24 h gets a
422and must start a new payment. Revisit trigger: the POS team reports a longer store-and-forward window. Then raise the retention.