ADR-0049: Closing an account is authorised like a transfer
Status: Accepted · Date: 2026-09-06 · Amended: 2026-09-07 (ADR-0050), 2026-09-11 (ADR-0044), 2026-09-14 (ADR-0044), 2026-09-21 (ADR-0056), 2026-10-10 (D9), 2026-10-11 (D8, D9), 2026-10-14 (D7)
Context
ADR-0008's table of protected operations says Delete account: Level 2, and before this decision nothing enforced it: DELETE /api/accounts/{id} was [Authorize] and no more. Measured then, a DELETE carrying a Step-Up-Authorization header answered 200 at level 1 with no PIN enrolled, while the reveal of an account number answered 403 STEP_UP_REQUIRED from the BFF. Two 422 guards, a non-zero balance and the primary account, already make the money case unreachable. Their order, balance before primary, is code (RefuseIfNotClosable), held by a unit test and by no probe.
Decision
- D1: A closure costs a PIN, because the account's owner cannot undo it: no endpoint lists or restores a deleted account, and its history leaves their view with it.
- D2: At the API, on the ADR-0042 rail, not as a BFF level-2 rule, because a session flag is a property of the session and not of the act, a BFF rule leaves a caller that reaches the API directly unprotected, and a parameterised DELETE fits neither branch of
RequiresPinVerification: a third branch is what ADR-0041's trigger (a) names as reopening its lockout gap. The reveal's exemption does not apply: a closure has a subject to bind, the account id. - D3: The binding is
(AccountDeletion, user, account, amount rendered 0)under the v1 payload.StepUpBinding.ForAccountDeletion(accountId)is(accountId, null, null, 0m), and nothing else is bound. No field is added, so the payload stays v1, with no migration. The operation name is in the hash: a transfer's authorisation on the same account answers 401AUTHORIZATION_INVALID. A unit test of the two hashes holds that: on the wire a transfer also differs by payee and amount. - D4: The mint is
POST /api/accounts/{id}/deletion-authorizations, operation in the segment, because the resource is the account: a mint that hangs off a resource other than its operation names the operation in the segment. Body{pin}, 201{authorizationId, expiresAt}, no[RequireIdempotency], because minting moves nothing and a repeat costs a PIN attempt. It runs ownership → the two 422 guards →MintAsync: a guard that reveals only the caller's own state answers before the PIN is consulted, so a wrong PIN on a funded or primary account costs nothing. No PIN enrolled is 422PIN_REQUIRED: "PIN must be set before authorising this operation." - D5:
ConsumedByTransactionIdis NULL for a closure, because a closure has no ledger row, and an account id in that column would mislead the evidence pack, which joins it to a movement's id. TheAccountDeletedrow'sDetailnames the authorisation consumed (ADR-0044). For a row written before 2026-09-14 the operator query below is the record; its 5 s window is no key. - D6: The two 422 guards stay ahead of the presence check, a deliberate difference from ADR-0042, because
NON_ZERO_BALANCEandPRIMARY_ACCOUNT_DELETEreveal only the caller's own account state, whichGET /api/accountsalready gives: there is no oracle to protect. The order is the contract: ownership → balance → primary → presence → validation → the transaction (D8). - D7: One refusal event, and the 422s stay log-only. A DELETE with no authorisation writes
AccountDeletionRefusedthroughRecordRefusalAsync, after the ownership check, so the row names an account the caller owns and outlives the 401. A PIN found wrong at the mint writes the same event (ADR-0044). The 422s and a request the lock refuses unread write none: both repeat at will. - D8: An explicit transaction, consume after the save, re-entrancy written into the delegate.
DeleteAccountAsyncruns the soft delete, theAccountDeletedrow, the save, thenConsumeAsync, then the commit, because two autocommits would leave the account closed with nothing spent: a consume that matches no row throws and rolls the soft delete back. Each attempt reloads the account, answers 404 if it is closed or gone and runs the guards again: a deposit that raced the closure ends in 422. An outer loop retries aDbUpdateConcurrencyException; without it eight DELETEs at once answered500,500,500,500,500,200,500,500. A closure that loses its account's row on each of its 8 attempts answers the busy 503 of ADR-0058 D6, with noapplied, when the request let no commit start: nothing is closed, no audit row is written and the request spends no authorisation, so the same DELETE can be sent again with the same one. It first looks at what an attempt looks at before its save, the account: closed or gone by then it is the 404, credited by then the 422, the primary one by then the guard's 422. Then the validation reads the authorisation again: spent by another request by then it is the 401. - D9: A write whose account closed or went while it ran is refused as a request on a closed account: 404
ACCOUNT_NOT_FOUNDfor the caller's own account, with nothing written and nothing spent. A deposit, a withdrawal and both transfers look at their accounts again after every reload, because a reload returns a closed row with a currentRowVersion. A row that is gone is treated as a closed one: proven for a deposit and for the destination of an internal transfer, which answer the same 404, and for a payee. A withdrawal's account, a sender's and the source of an internal transfer pass through the same test with no proof that removes their rows, because a row that holds money has ledger rows the store does not let go. An external transfer whose payee account is closed or gone pays another open account of the same payee, at its first look and at every later one: the primary first, then the oldest. The payer names a person, and the authorisation binds that person, not an account. An account that only stopped being primary while the transfer ran is still paid. The transfer answers 422RECIPIENT_NO_ACCOUNTonly when none is open. A withdrawal or a transfer that committed and lost its acknowledgement answers from its idempotency claim, also when its account closed in between: proven for a withdrawal and for the source of an external transfer. A closure whose row is gone answers the same 404, with no audit row and nothing spent: proven for a row that goes at the closure's save and for one that goes before its first reload. A change of the primary account reads both rows from the store on every attempt: a target that goes at its save answers the same 404 and the primary stays; an old primary that goes at its save holds no primary to clear, and the target becomes the user's one primary account. Past its 8 attempts a change of the primary account answers the same busy 503, and the 404 when its target closed or went as the last attempt lost.
SELECT s.Id, s.Operation, s.Status, s.CreatedAt, s.ConsumedAt, s.ConsumedByTransactionId,
e.Sequence, e.Event, e.OccurredAt
FROM StepUpAuthorizations s
JOIN AuditEvents e ON e.ActorUserId = s.UserId AND e.Event = 'AccountDeleted' AND e.SubjectId = @a
AND s.ConsumedAt BETWEEN DATEADD(second, -5, e.OccurredAt) AND DATEADD(second, 5, e.OccurredAt)
WHERE s.UserId = @u AND s.Operation = 'AccountDeletion' AND s.Status = 'Consumed'
ORDER BY s.ConsumedAt; -- @u is the actor (AuditEvents.ActorUserId), @a the accountError codes on DELETE /api/accounts/{id}, in the order of D6. The mint answers the two 422s too; PIN_REQUIRED (422), INVALID_PIN (401) and PIN_LOCKED (429) come only from the mint.
| Order | Code | Status | Means |
|---|---|---|---|
| 1 | ACCOUNT_NOT_FOUND / ACCESS_DENIED | 404 / 403 | not the caller's account |
| 2 | NON_ZERO_BALANCE | 422 | guard, ahead of the presence check |
| 3 | PRIMARY_ACCOUNT_DELETE | 422 | guard, ahead of the presence check |
| 4 | AUTHORIZATION_REQUIRED | 401 | none presented: "This account closure has not been authorised." An empty header binds to null and lands here |
| 5 | AUTHORIZATION_EXPIRED | 401 | valid, window passed: the PIN is asked again, no attempt spent |
| 5 | AUTHORIZATION_INVALID | 401 | uniform: unknown, not the caller's, spent, wrong operation, wrong account |
| — | model-state 400 | 400 | header present but not a UUID, keyed Step-Up-Authorization |
Rejected
- Rejected: striking the Level 2 row as won't-do, because it contradicts what ADR-0008 always said.
- Rejected:
/api/accounts/{id}/authorizations, because a second kind of mint would collide there. - Rejected: the account id in
ConsumedByTransactionId, because the evidence join never matches it. - Rejected: the presence check ahead of the guards, because the guards are no oracle (D6).
- Rejected: a "closing" state between open and closed, because nothing is in flight for longer than a request. A write carries the row version it read: when a closure and a money write meet at the save, the one that commits second loses and reads the row again. A closure that meets a deposit sees the money and refuses; a deposit that meets a closure sees the closed account and refuses. A write that reloads after a closure committed loses no save: it sees the closed row and refuses (D8, D9).
Consequences
- The Level 2 row is enforced at the API by a third
StepUpOperation, a mint and a required header on the DELETE, with no migration, no new column and no change toAuthLevelMiddleware. DeleteAccountDialogmints on the sixth digit, then deletes with the header; no idempotency key.- Measured, under the row numbers the code cites. Row 2: a wrong PIN on a funded account answers 422 with the PIN counter unchanged. Row 11: a DELETE 130 s after the mint answers 401
AUTHORIZATION_EXPIRED, no attempt spent, the authorisation still Pending. Row 14: a[ProducesResponseType(422)]on the mint action outranks the document transformer's entry and publishes the bare "Unprocessable Entity", so the mint action carries none. - A probe on the real stack leaves its audit rows: the chain is append-only, the row is the record.
Not done, each one not covered:
- No
SubscriberNoticeon a closure: whether one is owed is its own decision, in ADR-0047's shape. - An expired or invalid authorisation on the DELETE writes no audit row, as on a transfer.
- The history loss stays open:
GET /api/transactionsleaves out the rows of a closed account. DeletedAtandUpdatedAtare stamped from two clocks; ADR-0050 gave only the ledger its clock.- No
evidenceverb for closures: the audit row'sDetail, or D5's query, is the operator's tool.
Revisit when
- A second non-money operation wants a mint:
StepUpBindinggets its own shape, the payloadv2|. - A closure notice is decided: it joins an audit row (ADR-0047's shape), and the gate does not move.
- The reveal moves onto a mint:
AuthLevelMiddleware's level-2 rule then protects nothing. - An operation outlives its request, a scheduled payment or a hold: a closing state then earns its column, because something is in flight while no transaction holds the account.
Verified by
AccountDeletionSqlServerTests(D8, and D9 for a row that is gone),AccountDeletionAuthorizationTests,AccountServiceTests(also D9, a row gone before the reload),StepUpAuthorizationServiceTests(the operation name in the hash),BusyAccountWriteSqlServerTests(a closure and a change of the primary account past their attempts).- D9, on SQL Server:
DepositIntoClosedAccountSqlServerTests,TransferOrWithdrawalOnClosedAccountSqlServerTests,PayeeAccountClosedMeanwhileSqlServerTests,ClosureAfterALostAcknowledgementSqlServerTests,SetPrimarySqlServerTests(the primary account). - The SPA:
stepUp.contract.test.ts,money.contract.test.tsanddeleteAccount.spec.ts.
Related
ADR-0008, ADR-0041, ADR-0042, ADR-0044, ADR-0047, ADR-0050, ADR-0056.