ADR-0022: Client-side money-mutation protocol
Status: Accepted · Date: 2026-07-25 · Amended: 2026-08-12, 2026-08-16 (ADR-0041, ADR-0042), 2026-09-04, 2026-09-15, 2026-10-01 (ADR-0059), 2026-10-16 (ADR-0009; decisions 1 and 4, the listing by key) · Decision Makers: Vladislav Aleshaev
Context
ADR-0009 specifies the server side of idempotency: a keyed HMAC over the raw request body bytes, a five-state protocol, TTLs and the replay header. It gives the client one sentence: generate a UUID per user-intent and reuse it across retries. ADR-0008 puts the auth level in the BFF session, which makes step-up a transport concern. Between them the client is left one question for each way a request can end: does the server possibly hold this key, or is the key spent? One wrong cell of that table moves a customer's money twice, and none of these failures is visible at code-review time. This record is the client half of ADR-0009; frontend/src/hooks/useIdempotentMutation.ts cites it.
Decision
- One key per user-intent.
useIdempotentMutationmints a lazycrypto.randomUUID()into a ref on the first submit. It lives in memory only: never Redux, neversessionStorage, and never minted insidebaseQuery, inside an endpoint definition or on form mount, because a key created before the user has expressed an intent is not tied to an intent. A key read back from the ledger names a request that committed: every row of the listing carries the key that wrote it (ADR-0009), so such keys can sit in the query cache. The listing by key keeps none in what it stores and hands none to a flow, and a key read back is never sent again. - KEEP the key, so that a user-driven Retry re-sends it byte-identically, on
409 IDEMPOTENCY_IN_FLIGHT, onNETWORK, onPARSEand on any5xx, because in each the server may have recorded the key or be recording it: a fresh key there is a client-made double-spend. A refused authorisation (AUTHORIZATION_EXPIRED,AUTHORIZATION_INVALID) keeps it too (ADR-0042). - DROP the key on
2xx, on a business4xx, on401 INVALID_PIN, on422 IDEMPOTENCY_KEY_REUSEand on400 KEY_MISSING/KEY_INVALID, because the server has answered definitively. Not in the list: a 409 on a money send whose body names no code (HTTP_409, read from the status) latches the check asRESULT_UNKNOWNdoes, because it may be anIN_FLIGHT; and so does a send that ends with no HTTP status (a2xxwhose body failed its schema, or an abort by the caller), because the server may have acted.PARSEin decision 2 is an answer whose body is not JSON, and the 65 s abort isNETWORK(ADR-0059): both keep the key. 409 IDEMPOTENCY_RESULT_UNKNOWNdrops the key and latchesverifyRequired. Submit refuses to mint a new key until the owning flow, never the generic hook, hears the user say it did not go through, which callsresetIntent, because a new key armed automatically resubmits money the server may already have moved. The flow offers that action only once nothing sent under the old key can still commit: 3 min 5 s after the latest send under it began (the 65 s abort, then the claim's 2-minute stale age), or at once when every authorisation presented under it was refused. On this code the four money mutations invalidate the tags their success invalidates. When the 409 saysapplied: truethe commit is proven: the flow says the payment went through and offers the history, andresetIntentdoes nothing, so no new key can exist in that page or dialog. A movement the listing finds by the key is the same proof (confirmWentThrough).- Routing is on
errorCode, never on HTTP status, because two opposite 409s (IN_FLIGHTandRESULT_UNKNOWN) demand opposite behaviour and a status collapses them into one. - A body-affecting form edit calls
resetIntentonly while no key is held, because an edited body under the old key is a fingerprint mismatch (422) and the edit is a new intent. While a key is retained (the last outcome unknown, or its authorisation refused) an edit latchesverifyRequiredinstead (requireVerify, which drops the key), because a new key there is a new intent while the first may still land (ADR-0059). The transfer pages disable their form while a key is live. A transfer page and the withdrawal dialog latch the same way, and so let the key go, when the mint of a new authorisation under a held key answersACCOUNT_NOT_FOUND, because the account is closed and no authorisation can be had for that key again. It is safe: a mint is made under a held key only after every authorisation presented under it was refused, and the check view stands between the old key and a new one. The withdrawal dialog latches so too when the list it reads on Continue lacks its account while a key is held, as for an edit. keyRetainedblocks dismissal while any key is live, not only while a request is in flight, because abandoning a kept key and reopening the dialog mints a fresh key over the same money.- Step-up replay is byte-identical and happens at
baseQuerylevel. After a PIN elevation the wrapper replays the original serialisedFetchArgsexactly once, same body bytes and sameIdempotency-Key, and never rebuilds the payload at hook level, because a different key order changes the fingerprint and returns422 IDEMPOTENCY_KEY_REUSE. At most one elevation and one retry; parallel 403s collapse behind an async mutex, each replaying its own stored args. The interceptor applies to every level-2 endpoint, which today is one: the reveal,GET /api/accounts/{id}/full-number(ADR-0020), transfers having left the gate (ADR-0041). - Two PIN transports, one
PinInputcomponent. INTERCEPTOR mode: a 403 withX-Auth-Level-Requiredopens the modal, then the request replays. BODY mode (the PIN inside the request body, withdraw only): withdrawn, see ADR-0056; a transfer and a withdrawal carry a minted authorisation in a header (ADR-0042).hasPin: falseroutes to set-pin onboarding. - No attempts-remaining counter is displayed, because no such field exists in the contract and a "2 attempts left" would be fabricated data on a security surface.
429 PIN_LOCKEDwith itsretryAfterSecondsis the only real signal. - Zero optimistic updates on money: no
updateQueryDatapatching of balances or of the transaction list and nouseOptimistic, because in a bank a briefly wrong balance is a correctness failure. Correctness comes from tag invalidation and refetch.
Rejected
- Rejected: automatic retry on 5xx and network errors, because it turns a possible single spend into a probable double spend and removes the human from the one decision that needs one.
- Rejected: minting the key inside
baseQuery, becausebaseQuerysees requests, not intents: a retry and a resubmission are indistinguishable there. - Rejected: keys in
sessionStorageso that a reload resumes an intent, because unsubmitted intent would survive a session boundary, and a stale key days later is worse than a lost draft. - Rejected: a client-side guess at whether a key landed, because it is worse than asking.
Consequences
- Every monetary mutation goes through one hook whose state machine is written down. It costs a hook more complex than a bare mutation, which every new money surface must adopt.
- The flow component, not the hook, owns the
RESULT_UNKNOWNverify step and the went-through view. It testswentThroughbeforeverifyRequired, since the first is never set without the second. - Not covered: "the server never saw it" and "the server saw it and died" look the same: no answer (
NETWORK) or a5xx. KEEP (decision 2) is the safe answer for both, so a lost request occupies its key until the TTL: correctness first. An abort by the caller is decision 3, not this. - A record read as committed answers in the 409 itself (
applied: true); otherwise the user is asked to check: with the flag absent, a rejection with no HTTP status, a 409 naming no code, an edit with a key held, or a closed account met under a held key by the mint of a new authorisation or by the withdrawal dialog's check on Continue. The check view then asks the ledger, by key and account:GET /api/transactions?AccountId=&IdempotencyKey=lists the rows on that account that the requests sent with the key wrote (ADR-0009), and the payee of a transfer never receives the sender's key. The page is read strictly, and only a row that carries the key sent, of the type the send writes there, andCompleted, counts: an app that does not know the parameter, one put back by a failed check (ADR-0061), answers the account's latest page unfiltered, and its rows carry no key. A key is unique per endpoint and for 24 h only, so a row's type and time tell which request of that key wrote it. A row found is the payment going through, shown with its amount, number and time. An empty page is evidence that nothing was applied only once nothing under the key can commit (decision 4), or its record has expired, and only on an account still open: the listing answers a closed account 403, and a move between the caller's accounts then asks its other account, whose row carries the same key. Before then the view says the payment may still go through, and asks again by itself at the bound. The bound rests on the request deadline of ADR-0058, which a frozen API cannot enforce (its residual risk 4), nor a COMMIT that outlives its connection's cut; the same key sent again once the claim is stale is fenced, and applies the money at most once. "Start over" mints a new key and does not send the old one: the hook has dropped it (decision 4), a transfer or a withdrawal would need a new authorisation (ADR-0042), and once the evidence holds a new key cannot pay twice. - Not covered: leaving the check view ends the check. Once the key is dropped its exits are open (decision 7 bars them only while a key is live): the dialog's X and Escape, and "Check recent transactions" on every flow. A visitor who leaves while a send under the old key may still commit, then comes back and sends again, sends a new key, and both payments can land.
- Not covered: the mutex bounds elevation to one retry. A second 403 after an elevation surfaces as an error: the session level is not sticking, which is a BFF defect to diagnose there.
- Not covered: two browser tabs. Each mints its own key for its own intent; server-side velocity limits are the control for paying twice on purpose, outside this record.
- Not covered: the body and key halves of decision 8's replay have no live caller, since the only route replayed is the bodiless, keyless reveal. The test keeps the interceptor general.
Verified by
policies.test.tsx, idempotency.test.ts, stepup.test.ts, withdrawHandler.test.ts and stepup-interceptor.test.tsx under frontend/src; for the applied: true case of behaviour 6 also useMoneyWizard.blocker.test.tsx and transfer-went-through.test.tsx; for behaviour 7 also movementByKey.test.ts, movementLookup.test.tsx, timeoutChain.test.ts, useMovementCheck.test.tsx, ResultUnknownView.test.tsx, deposit.test.tsx, withdraw.test.tsx and transfer-found.test.tsx. Deleting one of them reverses this decision. The seven behaviours they hold:
- The same key survives a 503 and a user-driven Retry; a new key appears only after a 422 and an edit.
- GET retries on 503; POST never does.
- A ProblemDetails
traceIdreaches the UI as a normalisedApiProblem. - Step-up replay carries a byte-identical body and the same key; the body is compared as raw text.
- Withdraw with a wrong PIN does not dispatch
sessionExpired, and drops the key. IN_FLIGHTkeeps the key across a Retry;RESULT_UNKNOWNforces the verify dialog before any new key can exist, and withapplied: trueno new key can exist in that page or dialog at all.- The check view asks the listing by the key that was sent, on the account it was sent for: a movement found is "went through" and no new key can exist; the new-key action is offered only once nothing under the key can commit, or at once when every authorisation presented under it was refused; and the stored lookup holds no key.
Related
ADR-0008, ADR-0009, ADR-0019, ADR-0020, ADR-0041, ADR-0042, ADR-0056, ADR-0059.