---
url: /reference/IDENTITY_CASE_MATRIX.md
---
# Identity Case Matrix

One row per behavioral case for every route `src/modules/identity/presentation/controllers/` exposes. Each case names the test that proves it, quoted verbatim as a spec file path, then the separator "›", then the exact test title. A case with no proving test answers `—` and is listed under [Gaps](#gaps), together with the lowest layer that could prove it honestly.

`npm run lint:case-matrix` parses every such reference on this page and fails the build if the file does not exist or carries no `it(`/`it.each(` with that exact title. This table cannot silently drift from the suite it describes.

## Totals

| | |
|---|---:|
| Routes | 28 |
| Per-route cases | 346 |
| Per-route cases proven | 344 (the 2 unproven rows name a case with no failure mode to prove: issuance that never refuses, and a route with no guard) |
| Cross-route invariants | 11 (11 proven) |
| Gaps | 0 |

## Registration and session entry

`sign-ups.controller.ts`, `sign-up-confirmations.controller.ts`, `sign-ins.controller.ts`, `sign-in-second-factors.controller.ts`, `session-renewals.controller.ts`, `sign-outs.controller.ts`. None of these seven routes carries `ElevationGuard`; only the two sign-out routes carry `SessionGuard` at all.

### POST /sign-ups

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: registers a brand-new email/phone identity and queues the verification code for asynchronous delivery | `201 Created` · `{ data: { challengeExpiresAt } }`, no delivery field | `test/e2e/sign-up.e2e-spec.ts › registers an email identity and answers 201 inside the envelope, accepted with only an expiry` |
| Response never carries the code it just queued | `201 Created`, body excludes the code | `test/e2e/sign-up.e2e-spec.ts › never answers with the verification code it just queued` |
| Idempotent repeat: registering the same still-unverified address again | `201 Created`, same accepted shape, a fresh message is queued without superseding the older one — `sign_up` messages are never superseded across registrants, so both codes stay pending (OX-S8) | `test/e2e/sign-up.e2e-spec.ts › answers a second registration for an unverified address the same way, queuing its own message without superseding the first (OX-S8)` |
| Indistinguishable refusal: a squatter registers first, the real owner registers the same address with a different password | `201 Created` both times, byte-identical accepted body, so a caller cannot tell whether they were first | `test/e2e/sign-up.e2e-spec.ts › answers the real owner the same way as the squatter, so neither learns anything` |
| Another account left the address unconfirmed (pending sign-in-method addition); its real owner signs up | The unconfirmed claim and its challenges are revoked; the owner gets a new identity with its own password; the other account is untouched | `test/e2e/sign-in-methods.e2e-spec.ts › never lets the real owner of an address sign up into the account that left that address unconfirmed` |
| Repository: an unconfirmed address held by an established account (verified identifier or active method) | Released; a new identity claims it | `src/modules/identity/infrastructure/registration/identity-registration.repository.integration-spec.ts › gives the owner of an address its own identity when another account left that address unconfirmed` |
| Refusal: identifier already verified by its owner | `409 · identity.identifier_taken` | `test/e2e/sign-up-confirmation.e2e-spec.ts › refuses a registration for an address whose owner verified it` |
| Refusal: identifier fails normalization (bad email/phone shape) | `422 · identity.identifier_unusable` | `test/e2e/sign-up.e2e-spec.ts › answers an unusable identifier as unprocessable content` |
| Refusal: no channel can notify the identifier (e.g. phone with nothing to reach it), refused before any write | `422 · identity.identifier_channel_unavailable`, no transaction opened | `src/modules/identity/application/registration/register-identity.use-case.spec.ts › refuses before writing anything when nothing could notify that identifier`; `test/e2e/sign-up.e2e-spec.ts › answers an unprocessable identifier when no configured channel could ever reach it` |
| Validation: password too short | `400 · foundation.validation_failed`, body never echoes the address or password | `test/e2e/sign-up.e2e-spec.ts › never echoes the address or the password it refused` |
| Validation: unknown field in body (strict schema) | `400 · foundation.validation_failed`, no message sent | `test/e2e/sign-up.e2e-spec.ts › refuses a body carrying a field the contract does not declare` |
| Validation: `country` supplied on an email registration | `400 · foundation.validation_failed`, no message sent | `test/e2e/sign-up.e2e-spec.ts › refuses a country on an email registration, because it means nothing there` |
| Rate limit: exceeds `SENDS_A_MESSAGE_RATE_LIMIT` | `429 · rate_limit.exceeded`, `retryable: true`, no message sent for the refused attempt | `test/e2e/rate-limit.e2e-spec.ts › refuses the registration that goes past the limit` |
| Concurrency: five simultaneous registrations for the same address | Exactly one wins the claim, the rest see the losing branch (re-challenge), never two identities for one address | `src/modules/identity/application/registration/register-identity.use-case.integration-spec.ts › lets exactly one of five concurrent registrations for one address win` |

### POST /sign-ups/confirmations

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: verifies the identifier with the code it was queued | `204 No Content` | `test/e2e/sign-up-confirmation.e2e-spec.ts › verifies the identifier with the code it was queued` |
| Idempotent repeat: presenting the exact same already-spent code again | `422 · identity.verification_refused` | `test/e2e/sign-up-confirmation.e2e-spec.ts › spends the code, so the same one never verifies twice` |
| Refusal: wrong code, body reveals nothing about the account | `422 · identity.verification_refused` | `test/e2e/sign-up-confirmation.e2e-spec.ts › refuses a wrong code with a body that says nothing about the account` |
| Indistinguishable refusal: wrong code on a real address vs. an address nobody registered | Identical `422 · identity.verification_refused` body | `test/e2e/sign-up-confirmation.e2e-spec.ts › answers an address nobody registered exactly as it answers a wrong code` |
| Refusal: wrong password paired with the correct code | `422 · identity.verification_refused` | `test/e2e/sign-up-confirmation.e2e-spec.ts › refuses the code when the password is not the one the registration carried` |
| Refusal: identifier fails normalization | `422 · identity.identifier_unusable` | `src/modules/identity/application/verification/verify-identifier.use-case.spec.ts › refuses an unusable identifier before opening a transaction` |
| Attempt budget: the challenge stops accepting attempts once its allowance is spent, even a correct code afterward | `422 · identity.verification_refused` | `test/e2e/sign-up-confirmation.e2e-spec.ts › stops accepting attempts once the challenge has none left` |
| Lockout: a wrong code on an already locked account answers the same ordinary refusal, never `423`, and the lock is not audited again | `422 · identity.verification_refused`, nothing audited | `src/modules/identity/application/verification/verify-identifier.use-case.spec.ts › answers a wrong verification code on an already locked account with the ordinary refusal, never a locked answer, and does not audit the lock again` |
| Account lockout: trips once wrong guesses across several requests reach the threshold, each request trying every one of this identity's own live challenges, but the owner still answers a fresh code afterward | Account locks, a freshly issued code still consumes a challenge normally | `src/modules/identity/application/verification/verify-identifier.use-case.integration-spec.ts › locks the account once wrong guesses across several requests reach the threshold, each request trying every one of its own live challenges, but still lets the owner in afterward with a fresh code` |
| One wrong-code request spends exactly one account failure, no matter how many of the registrant's own live challenges it tries; each of those challenges still gets its own attempt charged | Account failure count `+1`; every tried challenge's `attempt_count` `+1` | `src/modules/identity/application/verification/verify-identifier.use-case.integration-spec.ts › spends exactly one account failure per request, no matter how many of its own live challenges get a charged attempt`; `src/modules/identity/application/verification/verify-identifier.use-case.spec.ts › spends exactly one account failure for the whole request, no matter how many of its own live challenges it tried` |
| Validation: code is not exactly six digits, refused before touching the database | `400 · foundation.validation_failed` | `test/e2e/sign-up-confirmation.e2e-spec.ts › refuses a code that is not six digits before touching the database` |
| Configuration: numeric, alphabetic and alphanumeric out-of-band codes each confirm; a wrong code refuses; lower-case succeeds; surrounding whitespace is refused as the schema documents | `204`/`422`/`400` per case, per configured format | `test/e2e/out-of-band-code-formats.e2e-spec.ts › sign-up confirmation: issued code succeeds, wrong code refuses, lower-case succeeds and surrounding whitespace is refused as the schema documents` |
| Rate limit: exceeds `ANSWERS_A_CHALLENGE_RATE_LIMIT` | `429 · rate_limit.exceeded` | `test/e2e/rate-limit.e2e-spec.ts › refuses the $routeName request that goes past its own limit, counted on this route alone` |
| Concurrency: a stranger re-registers the same address while the owner's code is still in flight | The owner's original code still verifies afterward; the stranger's competing claim gets its own, separately-verifiable code | `test/e2e/sign-up-confirmation.e2e-spec.ts › lets a stranger change nothing about the code already in flight` |

### POST /sign-ins

Includes provider/OAuth sign-in — the same route and handler, discriminated by `identifierType`.

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: password sign-in opens a session | `201 Created` · `{ data: { kind: 'session', accessToken, refreshToken, ... } }` | `test/e2e/sign-in.e2e-spec.ts › opens a session for an account that proved its identifier` |
| Happy path: provider (Google/Apple) sign-in opens a session for a vouched-for token | `201 Created` · session | `test/e2e/provider-sign-in.e2e-spec.ts › opens a session like any other, for a token the provider vouches for` |
| Happy-path variant: identity holds an active second factor — answers a ticket, never a token | `201 Created` · `{ data: { kind: 'second_factor_required', secondFactorTicket, ticketExpiresAt } }` | `test/e2e/sign-in-second-factor.e2e-spec.ts › answers a ticket instead of a session when the identity holds an active second factor, and never a token` |
| Never repeats a refresh token across sessions | Each session gets a distinct refresh token | `test/e2e/sign-in.e2e-spec.ts › never repeats a refresh token across two sessions` |
| Refusal: wrong password | `401 · identity.sign_in_refused` | `test/e2e/sign-in.e2e-spec.ts › refuses a password that is not the one on the account` |
| Refusal: identifier never verified | `401 · identity.sign_in_refused` | `test/e2e/sign-in.e2e-spec.ts › refuses an identifier nobody has confirmed yet` |
| Indistinguishable refusal: wrong password vs. unknown account | Byte-identical `401` body | `test/e2e/sign-in.e2e-spec.ts › answers an unknown account exactly like a wrong password` |
| Indistinguishable refusal: an already-locked account presenting a wrong password vs. an ordinary wrong password on any other account | Byte-identical `401 · identity.sign_in_refused` body | `test/e2e/account-lockout.e2e-spec.ts › locks a sign-in account after enough wrong passwords, and answers a further wrong password exactly like a wrong password on any other account` |
| Indistinguishable refusal: a disabled/unconfigured provider vs. wrong password | Byte-identical `401` body, reveals nothing about which providers are configured | `test/e2e/sign-in.e2e-spec.ts › answers a disabled provider exactly like a wrong password, revealing nothing about which providers are configured` |
| Indistinguishable refusal: a provider token the verifier refuses (bad signature/audience) vs. wrong password | Byte-identical `401` body | `test/e2e/provider-sign-in.e2e-spec.ts › answers a refused token exactly like a wrong password, so the discriminated body leaks nothing` |
| Response never leaks the password or the id token | `401` response excludes both | `test/e2e/sign-in.e2e-spec.ts › never says a word about the password in what it answers` |
| Account lockout: the correct password still opens a session even once the account is locked — a lock only ever stops guessing, never checked before a proof | `201 Created` session, lock state never consulted | `test/e2e/account-lockout.e2e-spec.ts › still opens a session for the correct password once the account is locked, because a lock only ever stops guessing` |
| The second-factor gate is checked only after the password is proven, and leaves clearing the account lock to that second step | Second factor checked strictly after password proof | `src/modules/identity/application/sessions/sign-in.use-case.spec.ts › checks for a second factor only after the password is proven, and leaves the lock for the second step to clear` |
| Idempotent repeat / replay: the exact same provider id token, replayed after it already opened a session | `401 · identity.sign_in_refused` (single-use nonce already spent) | `test/e2e/provider-sign-in.e2e-spec.ts › refuses the exact same token replayed after it already opened a session` |
| Repeat visit with the same provider subject (a different token) signs the same identity in again, creating nothing new | `201 Created`, no new identity | `test/e2e/provider-sign-in.e2e-spec.ts › signs the same provider identity in again on a second visit` |
| Race: a provider sign-in against the removal of that same provider, in either order | Never a live session from a removed method: a sign-in that committed first has its session revoked by the removal, a removal that committed first leaves the sign-in refused for the old identity; no deadlock over 20 rounds of each order | `src/modules/identity/infrastructure/sessions/oauth-sign-in-removal-race.integration-spec.ts` › `leaves no live session when the removal starts after the holder was read and the sign-in commits first`; `src/modules/identity/infrastructure/sessions/oauth-sign-in-removal-race.integration-spec.ts` › `leaves no live session and refuses the old identity when the removal commits before the sign-in session insert`; `src/modules/identity/infrastructure/sessions/oauth-sign-in-removal-race.integration-spec.ts` › `never deadlocks and never leaves a live session when both race freely` |
| Race: a provider sign-in against its method being marked compromised after the holder was read | The session the sign-in created is revoked | `src/modules/identity/infrastructure/sessions/oauth-sign-in-removal-race.integration-spec.ts` › `leaves no live session when the method is marked compromised after the holder was read` |
| Another identity removing its own provider while this provider sign-in is in flight | Neither waits nor revokes: the sign-in completes and its session stays live | `src/modules/identity/infrastructure/sessions/oauth-sign-in-removal-race.integration-spec.ts` › `does not wait for, nor revoke, a sign-in when ANOTHER identity removes its provider` |
| A provider sign-in on a provider whose removal already committed | The old identity is not signed in; no live session on it | `src/modules/identity/infrastructure/sessions/oauth-sign-in-removal-race.integration-spec.ts` › `refuses a sign-in on a provider whose removal already committed` |
| Refusal: an unknown provider subject whose token vouches for (`email_verified: true`) an email that already belongs to an active, verified identifier on another identity — most often a Google/Apple account that was unlinked and is signing in again — never creates a second identity for the same person | `409 · identity.account_exists_for_provider_email`, identity count unchanged | `test/e2e/sign-in-method-providers.e2e-spec.ts › refuses signing in with an unlinked Google subject once its verified email already belongs to this account`; `src/modules/identity/infrastructure/sessions/oauth-sign-in.repository.integration-spec.ts › refuses to create a new identity when the verified email already belongs to an active identifier` |
| That same refusal is case-insensitive on the email comparison | `409 · identity.account_exists_for_provider_email` | `src/modules/identity/infrastructure/sessions/oauth-sign-in.repository.integration-spec.ts › refuses regardless of the case the provider token used for the email` |
| Unrelated case: an unknown provider subject whose vouched-for email matches no one on record still opens a brand-new identity, exactly like before | `201 Created`, a new identity | `test/e2e/provider-sign-in.e2e-spec.ts › opens a new identity for a brand-new subject whose verified email matches nobody`; `src/modules/identity/infrastructure/sessions/oauth-sign-in.repository.integration-spec.ts › creates a new identity when the email matches nothing on record` |
| Unrelated case: an unknown provider subject whose token carries an email the provider never marked verified still opens a brand-new identity, even if that email matches an existing account | `201 Created`, a new identity | `test/e2e/provider-sign-in.e2e-spec.ts › opens a new identity when the provider never marked the matching email verified` |
| Indistinguishable refusal: no nonce claim / an unissued nonce / an expired nonce | `401 · identity.sign_in_refused`, identical across all three | `test/e2e/provider-sign-in.e2e-spec.ts › refuses a token with $what, byte-identically to an unissued nonce` |
| Validation: unsupported client type | `400 · foundation.validation_failed` | `test/e2e/sign-in.e2e-spec.ts › refuses a body that names a client nobody supports` |
| Concurrency: password/method/identifier changes committed while password verification is in flight (revocation, disablement, block, password/method replacement) | Refused, byte-identical to a wrong password, no session created | `test/integration/sign-in-credential-race.integration-spec.ts › refuses %s committed while password verification was pending` |
| Concurrency: method/principal revoked concurrently, ordered after session issuance | Issuance still succeeds, but the freshly-opened session is automatically revoked by the concurrent revocation | `test/integration/sign-in-credential-race.integration-spec.ts › serializes %s revocation after issuance and automatically revokes the new session` |
| Concurrency baseline: credential remains eligible throughout | Exactly one session opened | `test/integration/sign-in-credential-race.integration-spec.ts › opens exactly one session when the verified credential remains eligible` |
| Rate limit: exceeds `PROVES_A_PASSWORD_RATE_LIMIT` | `429 · rate_limit.exceeded` | `test/e2e/rate-limit.e2e-spec.ts › refuses the $routeName request that goes past its own limit, counted on this route alone` |

### POST /sign-ins/second-factors

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: current TOTP code completes the sign-in, opens a session usable on a guarded route | `201 Created` · session; challenge consumed, lock cleared | `test/e2e/sign-in-second-factor.e2e-spec.ts › completes the sign-in with the current code and opens a session that works on a guarded route` |
| Happy-path variant: a live recovery code completes the sign-in without ever checking a TOTP method | `201 Created` · session | `src/modules/identity/application/sessions/complete-sign-in-second-factor.use-case.spec.ts › proves a live recovery code and opens a session, clearing the lock and consuming the challenge, without ever checking a TOTP method` |
| Refusal: unknown/invented ticket | `401 · identity.second_factor_refused`, untouched challenge/lockout | `src/modules/identity/application/sessions/complete-sign-in-second-factor.use-case.spec.ts › refuses an unknown ticket without touching the challenge or the lockout` |
| Refusal: wrong TOTP code | `401 · identity.second_factor_refused` | `src/modules/identity/application/sessions/complete-sign-in-second-factor.use-case.spec.ts › refuses a wrong code and records a failed attempt on the challenge and the lockout` |
| Refusal: a code already spent on this exact time step (replay), exactly like a wrong code | `401 · identity.second_factor_refused` | `test/e2e/sign-in-second-factor.e2e-spec.ts › refuses the same code a second time presented with a fresh ticket, proving replay protection` |
| Refusal: a wrong, spent, revoked or foreign recovery code, exactly like a wrong TOTP code | `401 · identity.second_factor_refused` | `src/modules/identity/application/sessions/complete-sign-in-second-factor.use-case.spec.ts › refuses a wrong, spent, revoked or foreign recovery code exactly like a wrong TOTP code, recording the failed attempt and the lockout` |
| Indistinguishable refusal: a wrong code vs. an invented ticket | Byte-identical `401` body | `test/e2e/sign-in-second-factor.e2e-spec.ts › refuses a wrong code, and an invented ticket answers the byte-identical refusal` |
| Indistinguishable refusal: wrong password on a second-factor account answers exactly like an ordinary sign-in refusal on `/sign-ins` | Byte-identical `401 · identity.sign_in_refused` | `test/e2e/sign-in-second-factor.e2e-spec.ts › answers a wrong password on an account with a second factor exactly like an ordinary sign-in refusal` |
| Idempotent repeat / replay: the same ticket cannot be reused, even to answer with the previously-correct code | `401 · identity.second_factor_refused` on the second use | `test/e2e/sign-in-second-factor.e2e-spec.ts › cannot use the same ticket twice, even to answer with the same correct code` |
| Attempt budget (per-ticket, `max_attempts = 5`): five wrong attempts exhaust the ticket itself, so even a later correct code on that same ticket is refused | `401 · identity.second_factor_refused`, never `423` | `test/e2e/sign-in-second-factor.e2e-spec.ts › exhausts the attempt budget of the ticket, refusing even a later correct code` |
| Account lockout (identity-wide, threshold 5, distinct from the per-ticket budget): wrong codes across several fresh tickets accumulate against the identity; a proven password never clears it | 6th cumulative failure (fresh ticket) answers `423 · identity.sign_in_second_factor_locked` | `test/e2e/sign-in-second-factor.e2e-spec.ts › keeps counting wrong codes across fresh tickets, because a proven password alone never clears the lock` |
| The exact failure that trips the identity-wide lock still answers the ordinary refusal, not the locked response | `401 · identity.second_factor_refused` (only the next attempt gets `423`) | `src/modules/identity/application/sessions/complete-sign-in-second-factor.use-case.spec.ts › records the account as locked when a wrong second-factor code is the failure that locks it` |
| Already-locked account answers the locked response | `423 · identity.sign_in_second_factor_locked` | `src/modules/identity/application/sessions/complete-sign-in-second-factor.use-case.spec.ts › answers a locked account following the step-up precedent, once the lockout reports it was already locked` |
| Lockout never gates a correct proof: the correct code still opens a session even while the account is already locked | `201 Created` · session, lock never consulted | `src/modules/identity/application/sessions/complete-sign-in-second-factor.use-case.spec.ts › opens a session with the correct code even though the account is already locked, without ever consulting the lock` |
| The same lockout-bypass guarantee applies to the recovery-code path | `201 Created`, lock never consulted before the recovery code is checked | `src/modules/identity/application/sessions/complete-sign-in-second-factor.use-case.spec.ts › never consults the lockout before the recovery code is checked` |
| Validation: body carries both a code and a recovery code | `400 · foundation.validation_failed` | `src/modules/identity/presentation/dtos/sign-in-second-factor-request.schema.spec.ts › refuses a body carrying both an authenticator code and a recovery code` |
| Validation: body carries neither | `400 · foundation.validation_failed` | `src/modules/identity/presentation/dtos/sign-in-second-factor-request.schema.spec.ts › refuses a body carrying neither` |
| A provider sign-in also requires this second step when a second factor is active | `201 Created` (ticket first, then session) | `test/e2e/sign-in-second-factor.e2e-spec.ts › requires the second step for a provider sign-in too, when the identity holds an active second factor` |
| Rate limit: exceeds `ANSWERS_A_CHALLENGE_RATE_LIMIT` | `429 · rate_limit.exceeded` | `test/e2e/rate-limit.e2e-spec.ts › refuses the $routeName request that goes past its own limit, counted on this route alone` |
| Concurrency: two simultaneous completions racing to spend the same ticket | Exactly one session opened, the other refused | `test/integration/sign-in-second-factor-ticket-race.integration-spec.ts › lets exactly one of two simultaneous completions spend the same ticket` |
| Pending ticket versus credential change: a ticket opened before a password recovery, a password change, the removal of a linked provider or a method marked compromised is revoked in that same transaction, so completing it afterwards, with an authenticator code or a recovery code, is refused and opens no session | `401 · identity.second_factor_refused`, the refusal an unknown ticket gets, no new live session | `src/modules/identity/application/sessions/pending-sign-in-ticket-revocation.integration-spec.ts` › `a ticket opened before a password recovery cannot be completed with an %s`; `src/modules/identity/application/sessions/pending-sign-in-ticket-revocation.integration-spec.ts` › `a ticket opened before a password change cannot be completed with an %s`; `src/modules/identity/application/sessions/pending-sign-in-ticket-revocation.integration-spec.ts` › `a ticket opened before a linked provider is removed cannot be completed with an %s`; `src/modules/identity/application/sessions/pending-sign-in-ticket-revocation.integration-spec.ts` › `a ticket opened before a sign-in method is marked compromised cannot be completed with an %s` |
| A ticket opened after the credential change still completes | `201 Created` · session | `src/modules/identity/application/sessions/pending-sign-in-ticket-revocation.integration-spec.ts` › `a ticket opened after the password recovery still completes`; `src/modules/identity/application/sessions/pending-sign-in-ticket-revocation.integration-spec.ts` › `a ticket opened after the password change still completes` |
| A stored hash rewritten without a real password change (no `password_changed_at` move) leaves a pending ticket alive | `201 Created` · session | `src/modules/identity/application/sessions/pending-sign-in-ticket-revocation.integration-spec.ts` › `rewriting the stored hash without a real password change leaves a pending ticket alive` |
| What a caller from another identity sees: recovering one identity's password leaves another identity's pending ticket alive | `201 Created` · session for the bystander | `src/modules/identity/application/sessions/pending-sign-in-ticket-revocation.integration-spec.ts` › `a password recovery for one identity leaves the pending ticket of another identity alive` |

### POST /sign-ins/renewals

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: exchanges the refresh token for a new pair | `201 Created` · new `accessToken`/`refreshToken` | `test/e2e/session-renewal.e2e-spec.ts › hands out a new pair for the token the session is holding` |
| Renewing keeps the original session deadline (not a sliding extension) | `sessionExpiresAt` unchanged from the original sign-in | `test/e2e/session-renewal.e2e-spec.ts › keeps the deadline the sign in set, because renewing is not extending` |
| Idempotent repeat / replay: presenting the same (now-spent) refresh token again | `401 · identity.renewal_refused`, and the whole session ends as a suspected leak | `test/e2e/session-renewal.e2e-spec.ts › ends the session when a spent token comes back, so a leak costs the thief the account` |
| Refusal: an unknown/invented token | `401 · identity.renewal_refused` | `test/e2e/session-renewal.e2e-spec.ts › refuses a token nobody ever issued` |
| Refusal: a token spent several renewals ago (old-generation replay) ends the current session too | `401 · identity.renewal_refused`, and the session's current token also stops working afterward | `test/e2e/session-renewal.e2e-spec.ts › ends the current session when a token spent several renewals ago returns` |
| Indistinguishable refusal: a replayed (already-spent) token vs. a wholly invented one | Byte-identical `401` body | `src/modules/identity/application/sessions/renew-session.use-case.spec.ts › answers a replay and an invented token with the same refusal` |
| An unrelated real session remains usable after an unknown token is refused | `201 Created` for the still-valid session | `test/e2e/session-renewal.e2e-spec.ts › keeps a real session usable after an unknown token is refused` |
| Refuses when access-token signing fails, without starting the mutating transaction or rotating anything | Rejects, no rotation performed | `src/modules/identity/application/sessions/renew-session.use-case.spec.ts › does not start mutation or rotate when access signing fails` |
| Concurrency: a locked renewal candidate that changed shape (different session id, identity id, or expiry) between find and lock is refused without rotating | `401 · identity.renewal_refused`, no rotation | `src/modules/identity/application/sessions/renew-session.use-case.spec.ts › refuses a changed locked candidate without rotating: %o` |
| Concurrency: replay revocation still commits even if the candidate rotates mid-signing | Revocation is not lost to the race | `src/modules/identity/application/sessions/renew-session.use-case.spec.ts › commits replay revocation if the candidate rotates while signing` |
| Concurrency (database-level race): an in-flight renewal beats an old-generation replay, which is then revoked once the new token is committed | New token survives, old-generation replay revokes it correctly | `test/integration/refresh-token-replay.integration-spec.ts › waits for an in-flight renewal then revokes the newly issued token on old replay` |
| Concurrency (database-level race): an in-flight old replay beats the current renewal, which is then refused post-revocation | Current token refused after the race resolves | `test/integration/refresh-token-replay.integration-spec.ts › waits for an in-flight old replay then refuses the current token after revocation` |
| Several honest generations renew in sequence without ever revoking the session | No false-positive replay revocation | `test/integration/refresh-token-replay.integration-spec.ts › renews several generations without revoking an honest session` |
| Rate limit: exceeds `PROVES_A_PASSWORD_RATE_LIMIT` | `429 · rate_limit.exceeded` | `test/e2e/rate-limit.e2e-spec.ts › refuses the $routeName request that goes past its own limit, counted on this route alone` |

### POST /sign-outs

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: ends the session belonging to the presented access token; its refresh token stops working immediately | `204 No Content`, refresh token then refused with `401 · identity.renewal_refused` | `test/e2e/sign-out.e2e-spec.ts › ends the session, so the refresh token it was holding stops working` |
| Isolation: leaves this identity's sessions on other clients untouched | Other sessions of the same identity keep renewing | `test/e2e/sign-out.e2e-spec.ts › leaves the sessions this caller opened on other clients alone` |
| Idempotent repeat: signing out again with the same (now-dead) access token | `401 · identity.session_required` — the token itself is refused because its session is no longer live, not a `204` no-op | `test/e2e/sign-out.e2e-spec.ts › refuses to repeat the sign-out with the same access token, because signing out already killed it` |
| Repeat at the domain layer keeps the original revocation reason rather than overwriting it | Reason field stable across a second call | `test/integration/sign-out.integration-spec.ts › keeps the reason a session was already ended with` |
| What a caller from another identity sees: a caller/session pair that does not match any owned session ends nothing | Targeted session's `revoked_at` stays `NULL` | `test/integration/sign-out.integration-spec.ts › refuses a session that belongs to somebody else` |
| Concurrency: two simultaneous sign-outs of the same session serialize on the row guard, revoking it exactly once | `revoked_at` set once, reason stable | `test/integration/sign-out.integration-spec.ts › serializes two simultaneous sign-outs of the same session, revoking it exactly once` |
| Guard — no session: no `Authorization` header at all | `401 · identity.session_required` | `test/e2e/sign-out.e2e-spec.ts › refuses $what the same way` |
| Guard — forged/invalid access token: non-Bearer scheme, a token nobody issued, or an empty bearer value, all indistinguishable | `401 · identity.session_required` | `test/e2e/sign-out.e2e-spec.ts › refuses $what the same way` |
| Guard — revoked session: an access token whose session was already ended answers the same refusal (see the idempotent-repeat row above) | `401 · identity.session_required` | `test/e2e/sign-out.e2e-spec.ts › refuses to repeat the sign-out with the same access token, because signing out already killed it` |

### POST /sign-outs/everywhere

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: ends every session of this identity, so every client's refresh token stops renewing | `204 No Content`, all refresh tokens refused afterward | `test/e2e/sign-out-everywhere.e2e-spec.ts › ends every session of this identity, so refresh tokens from every client stop renewing` |
| Happy-path effect: seals a platform Audit Record with the identity as both actor and target | Audit record `identity.sessions.revoked_everywhere`, outcome `success` | `test/e2e/sign-out-everywhere.e2e-spec.ts › seals a platform Audit Record for the identity-wide revocation, with the identity as actor and target` |
| What a caller from another identity sees: a second identity's live sessions are left completely untouched | Other identity's sessions stay live | `test/integration/sign-out-everywhere.integration-spec.ts › leaves a second identity sessions untouched` |
| Not an error for an identity with no live sessions left to end | Completes normally, no exception | `test/integration/sign-out-everywhere.integration-spec.ts › is not an error for an identity with no live sessions` |
| Idempotent repeat: repeating with the same (now-dead) access token | `401 · identity.session_required` | `test/e2e/sign-out-everywhere.e2e-spec.ts › refuses to repeat the sign-out with the same access token, because signing out already killed it` |
| Repeat at the domain layer keeps the original revocation reason rather than overwriting it | Reason field stable across a second call | `test/integration/sign-out-everywhere.integration-spec.ts › keeps the reason a session was already ended with, rather than overwriting it` |
| Concurrency: a session still committing its sign-in when the everywhere-revocation runs is not left live | That session ends up revoked too, not orphaned as "live" | `test/integration/sign-out-everywhere.integration-spec.ts › does not leave live a session whose sign-in was still committing when the revocation ran` |
| Concurrency: a session created after the revocation already began is revoked too, instead of aborting the whole operation | New session is caught and revoked; the operation still succeeds | `test/integration/sign-out-everywhere.integration-spec.ts › revokes a session created after the revocation began, instead of aborting the whole operation` |
| Guard — no session: no `Authorization` header at all | `401 · identity.session_required` | `test/e2e/sign-out-everywhere.e2e-spec.ts › refuses $what the same way` |
| Guard — forged/invalid access token, indistinguishable across every sub-case | `401 · identity.session_required` | `test/e2e/sign-out-everywhere.e2e-spec.ts › refuses $what the same way` |
| Guard — revoked session: same refusal as the idempotent-repeat row above | `401 · identity.session_required` | `test/e2e/sign-out-everywhere.e2e-spec.ts › refuses to repeat the sign-out with the same access token, because signing out already killed it` |

## Session surface, step-ups and provider nonces

`session-context.controller.ts`, `session-history.controller.ts`, `step-ups.controller.ts`, `step-up-confirmations.controller.ts`, `step-up-providers.controller.ts`, `provider-nonces.controller.ts`. All five session/step-up routes carry `SessionGuard` only; `provider-nonces` carries no guard at all.

### GET /session/context

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: freshly confirmed identity | `200 OK` with identityId, masked verified identifiers, active methods, empty organization context | `test/e2e/session-context.e2e-spec.ts › answers the identity, its verified identifiers, active methods and organization state for a freshly confirmed account` |
| Response never leaks the raw identifier | `200 OK`, masked values only | `test/e2e/session-context.e2e-spec.ts › never puts the full email address or an unmasked phone number in the response` |
| What a caller from another identity sees: caller B never sees A's identifiers/methods | Query scoped by `identityId`; no leakage | `src/modules/identity/infrastructure/session-context/session-context.repository.integration-spec.ts › never returns an identifier or a method belonging to another identity` |
| Guard — no authorization header / non-Bearer scheme / unissued token, indistinguishable | `401 · identity.session_required` | `test/e2e/session-context.e2e-spec.ts › refuses $what the same way` |
| Guard — revoked session, next request with the same token | `401 · identity.session_required` | `test/e2e/session-revocation.e2e-spec.ts › refuses the same access token on the very next request once its session signs out` |
| Guard — revoked/dead session vs. a purely forged token, byte-identical | `401 · identity.session_required`, identical body/headers | `test/e2e/session-revocation.e2e-spec.ts › answers a dead session and an invented token with the exact same refusal body` |
| Guard — an elevation token presented as the bearer credential | `401 · identity.session_required` | `test/e2e/step-up.e2e-spec.ts › refuses an elevation token presented as a bearer credential on an ordinary authenticated route` |
| Organization selected, organization-wide owner: grouped access, capabilities, profile and workspace | `200 OK` · `access.organizationWide` holds the owner permissions, `access.scoped` empty, `permissions` equals `access.organizationWide`, `capabilities` equal to `GET /subscription`, `profile` equal to the locale and currency of `GET /organization`, every active business unit and outlet, `truncated: false`, `selection: null` | `test/e2e/session-context-workspace.e2e-spec.ts › answers the owner its organization-wide permissions, the capabilities of GET /subscription, the profile of GET /organization and every active business unit and outlet` |
| Member scoped to one outlet: only that outlet and its business unit; the deprecated flat field stays honest | `200 OK` · `access.organizationWide: []`, one `access.scoped` entry for the outlet with its permissions, `permissions: []`, workspace with that outlet (business unit and legal entity of today, site, timezone) and its business unit with its active verticals | `test/e2e/session-context-workspace.e2e-spec.ts › answers a cashier scoped to one outlet only that outlet and its business unit, its permission only as scoped, and nothing in the deprecated flat permissions` |
| Member scoped to one of two business units | `200 OK` · only that business unit and its outlets in `workspace`, one `business_unit` entry in `access.scoped` | `test/e2e/session-context-workspace.e2e-spec.ts › answers a manager scoped to one of two business units only that unit and its outlets` |
| The scoped outlet is deactivated | `200 OK` · the outlet leaves `workspace` and `access.scoped` | `test/e2e/session-context-workspace.e2e-spec.ts › drops a deactivated outlet from the workspace and the scoped access of the cashier` |
| Valid selection: `outletId`, or `businessUnitId` alone | `200 OK` · `selection` with the business unit, the outlet, the legal entity of the outlet today, its site, its timezone and the active verticals of the unit; a unit alone answers the outlet fields `null` | `test/e2e/session-context-workspace.e2e-spec.ts › answers a valid outlet selection with its business unit, the legal entity that operates it today, its site, timezone and verticals` |
| Selection of an outlet of another business unit, of another organization, unknown, inactive, an unreached unit, a unit of another organization or a unit that does not match the outlet, indistinguishable | `404 · organization.structure_not_found`, byte-identical body | `test/e2e/session-context-workspace.e2e-spec.ts › answers the same 404 for an outlet of another business unit, a foreign, an unknown and an inactive outlet, an unreached unit and a unit that does not match the outlet` |
| Guard and validation: no session; `outletId` that is not a UUID | `401 · identity.session_required`; `400 · foundation.validation_failed` | `test/e2e/session-context-workspace.e2e-spec.ts › refuses a call without a session with 401 and a malformed selection with 400` |
| No organization selected; a selection sent without one | `200 OK` with empty lists and `profile`/`selection` `null`; the selection alone answers `404 · organization.structure_not_found` | `test/e2e/session-context-workspace.e2e-spec.ts › answers empty lists and a null profile and selection without an organization, and the same 404 for a selection sent without one` |
| Member whose roles grant no permission | `200 OK` · empty `access` and empty `workspace` | `test/e2e/session-context-workspace.e2e-spec.ts › answers a member whose role grants nothing empty access and an empty workspace` |
| More than 500 reachable outlets (501 seeded with SQL) | `200 OK` · 500 outlets in creation order, `truncated: true` | `test/e2e/session-context-workspace.e2e-spec.ts › caps the workspace at 500 outlets and says it was truncated (501 outlets seeded with SQL)` |

### GET /sign-ins/history

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: lists open sessions, flags the caller's own | `200 OK`, `isCurrentSession` true only for the presented token's session | `test/e2e/session-history.e2e-spec.ts › lists every open session and flags the one the caller is using` |
| Pagination: `meta.pageInfo` shape, walking pages with `limit`/`after` | `200 OK`, `pageInfo: { hasNextPage, nextCursor }`, no gaps or duplicates | `test/e2e/session-history.e2e-spec.ts › walks every open session across pages with the limit and cursor query params` |
| Pagination: default limit (50) when `limit` is absent | Applies the documented default limit | `src/foundation/pagination/collection-query.schema.spec.ts › defaults to the documented default limit when absent` |
| Pagination: maximum limit (100) accepted | `200 OK` at 100 | `src/foundation/pagination/collection-query.schema.spec.ts › accepts the documented maximum limit` |
| Pagination: limit above 100 rejected | `400 · foundation.validation_failed` | `src/foundation/pagination/collection-query.schema.spec.ts › rejects a limit above the documented maximum` |
| Pagination: structurally malformed cursor | `400 · collection.cursor_invalid` | `test/e2e/session-history.e2e-spec.ts › refuses a structurally malformed cursor as collection.cursor_invalid` |
| Pagination: tampered cursor signature | `400 · collection.cursor_tampered` | `src/modules/identity/infrastructure/sessions/identity-session.repository.integration-spec.ts › refuses a cursor whose signature was tampered with` |
| Pagination: a cursor minted for a different identity | `400 · collection.cursor_incompatible` | `test/e2e/session-history.e2e-spec.ts › refuses a cursor minted for a different identity as collection.cursor_incompatible` |
| Guard — no header / non-Bearer / unissued token / empty bearer value, all indistinguishable | `401 · identity.session_required` | `test/e2e/session-history.e2e-spec.ts › refuses $what the same way` |
| Guard — revoked session / expired / session naming another identity / non-existent session, all indistinguishable from a missing token | `401 · identity.session_required` (byte-identical) | `src/modules/identity/application/session-context/authenticate-caller.use-case.spec.ts › refuses a well-signed token whose liveness port answers not live, because $why` |
| What a caller from another identity sees: caller B never sees A's sessions | `200 OK`, only the caller's own sessions returned | `src/modules/identity/infrastructure/sessions/identity-session.repository.integration-spec.ts › never returns a session that belongs to another identity` |

### POST /step-ups

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: opens a challenge and queues a code for the verified identifier, inside the same transaction (fixes notify-before-commit) | `201 Created` with `challengeExpiresAt` | `test/e2e/step-up.e2e-spec.ts › proves the code queued for a verified identifier and hands back an elevation token bound to this session` |
| Refusal: identity has no verified identifier to reach | `422 · identity.step_up_unreachable` | `src/modules/identity/application/step-ups/open-step-up.use-case.spec.ts › refuses an identity with no verified identifier to reach, without hashing anything` |
| Refusal: none of the identity's verified, non-revoked identifiers can be notified | `422 · identity.identifier_channel_unavailable` | `src/modules/identity/application/step-ups/open-step-up.use-case.spec.ts › refuses when nothing can reach any candidate it found, before opening a challenge` |
| Preference order: a primary identifier the notifier cannot reach is skipped for the next verified, non-revoked identifier in preference order that it can | Code goes to the first candidate `stepUpNotifier.canNotify(...)` accepts, not necessarily the primary one | `src/modules/identity/application/step-ups/open-step-up.use-case.spec.ts › skips a preferred identifier the notifier cannot reach, and uses the next one in preference order` |
| Idempotent repeat: a second open supersedes the first; only the newest challenge answers | Only the newest challenge is lockable/answerable | `src/modules/identity/infrastructure/step-ups/identity-step-up.repository.integration-spec.ts › locks the newest live challenge for the identity, ignoring a stale one` |
| Rate limit: exceeds `SENDS_A_MESSAGE_RATE_LIMIT` | `429 · rate_limit.exceeded` | `test/e2e/rate-limit.e2e-spec.ts › refuses the $routeName request that goes past its own limit, counted on this route alone` |
| Guard — no header / unissued token, indistinguishable | `401 · identity.session_required` | `test/e2e/step-up.e2e-spec.ts › refuses opening a step-up with $what` |
| Guard — revoked session / forged token, byte-identical to missing | `401 · identity.session_required` | `src/foundation/auth/session.guard.spec.ts › answers a missing header and an unrecognised token with byte-identical refusals`; `src/modules/identity/application/session-context/authenticate-caller.use-case.spec.ts › refuses a well-signed token whose liveness port answers not live, because $why` |

### POST /step-ups/confirmations

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: correct code mints an elevation bound to the calling session | `201 Created` with `elevationToken`, `expiresAt` in the future | `test/e2e/step-up.e2e-spec.ts › proves the code queued for a verified identifier and hands back an elevation token bound to this session` |
| Refusal: wrong code, charges an attempt, session survives | `403 · identity.step_up_confirmation_refused` | `test/e2e/step-up.e2e-spec.ts › refuses a wrong code without ending the session, charging an attempt` |
| Indistinguishable refusal: wrong code vs. no live challenge to answer | `403 · identity.step_up_confirmation_refused`, byte-identical | `src/modules/identity/application/step-ups/confirm-step-up.use-case.spec.ts › answers a wrong code and a missing challenge with the same refusal` |
| Attempt budget: 5 attempts per challenge before it is revoked | Attempts counted 0-4, challenge unlockable afterward | `src/modules/identity/infrastructure/step-ups/identity-step-up.repository.integration-spec.ts › holds the attempt budget, revoking the challenge once it is exhausted` |
| Lockout: the wrong code that crosses the lock threshold is still answered as an ordinary refusal, not as locked | `403 · identity.step_up_confirmation_refused`, not `423` | `src/modules/identity/application/step-ups/confirm-step-up.use-case.spec.ts › records the account as locked when a wrong step-up code is the failure that locks it` |
| Lockout: a further wrong code once already locked | `423 · identity.step_up_locked`, the lock is not audited a second time | `test/e2e/account-lockout.e2e-spec.ts › locks a step-up account after wrong codes spread across several of its own challenges, and refuses a further wrong code with a distinct, informative response`; `src/modules/identity/application/step-ups/confirm-step-up.use-case.spec.ts › answers a further wrong step-up code on an already locked account with the lock expiry, without auditing the lock again` |
| Lockout: a wrong code below the threshold refuses and records no lock | `403 · identity.step_up_confirmation_refused`, nothing audited | `src/modules/identity/application/step-ups/confirm-step-up.use-case.spec.ts › refuses a wrong step-up code below the threshold without recording any lock` |
| Lockout: the correct code still mints an elevation even while locked | `201 Created` | `test/e2e/account-lockout.e2e-spec.ts › still mints the elevation for the correct code once the step-up account is locked, because the caller already proved who they are` |
| Idempotent repeat: replaying the same already-consumed correct code | `403 · identity.step_up_confirmation_refused` | `src/modules/identity/infrastructure/step-ups/identity-step-up.repository.integration-spec.ts › cannot reuse a spent challenge` |
| What a caller from another identity sees: one identity's confirmation attempts never touch another identity's challenge | Second identity's challenge/attempt count untouched | `src/modules/identity/infrastructure/step-ups/identity-step-up.repository.integration-spec.ts › leaves a second identity completely untouched` |
| Concurrent revocation: the identifier a live code was queued for gets revoked before confirmation; the challenge is simply not found, never a raw database error | `403 · identity.step_up_confirmation_refused`, never `500` | `test/e2e/step-up.e2e-spec.ts › refuses confirming a step-up once the identifier its code was queued for gets revoked mid-flight, instead of a raw database error`; `src/modules/identity/infrastructure/step-ups/identity-step-up.repository.integration-spec.ts › excludes a challenge from the newest-live lookup once the identifier that received its code was revoked` |
| Guard — no header / unissued token | `401 · identity.session_required` | `test/e2e/step-up.e2e-spec.ts › refuses confirming a step-up with $what` |
| Guard — revoked session / forged token, byte-identical to missing | `401 · identity.session_required` | `src/foundation/auth/session.guard.spec.ts › answers a missing header and an unrecognised token with byte-identical refusals`; `src/modules/identity/application/session-context/authenticate-caller.use-case.spec.ts › refuses a well-signed token whose liveness port answers not live, because $why` |
| Rate limit: exceeds `ANSWERS_A_CHALLENGE_RATE_LIMIT` | `429 · rate_limit.exceeded` | `test/e2e/rate-limit.e2e-spec.ts › refuses the $routeName request that goes past its own limit, counted on this route alone` |
| Concurrency: two simultaneous confirmations against the same challenge | Exactly one consumes it | `src/modules/identity/infrastructure/step-ups/identity-step-up.repository.integration-spec.ts › lets exactly one of two simultaneous confirmations lock and consume the same challenge` |
| Configuration: numeric, alphabetic and alphanumeric out-of-band codes each confirm; a wrong code refuses; lower-case succeeds; surrounding whitespace is refused as the schema documents | `201`/`403`/`400` per case, per configured format | `test/e2e/out-of-band-code-formats.e2e-spec.ts › step-up confirmation: issued code succeeds, wrong code refuses, lower-case succeeds and surrounding whitespace is refused as the schema documents` |

### POST /step-ups/providers

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: fresh provider ID token proves a linked identity | `201 Created` with `elevationToken`, `expiresAt` in the future | `test/e2e/step-up-with-a-provider.e2e-spec.ts › steps up a provider-only identity with a fresh token and enrols an authenticator app with the elevation` |
| Indistinguishable refusal: stale token / wrong subject / unlinked provider / forged signature | `403 · identity.step_up_confirmation_refused`, byte-identical | `test/e2e/step-up-with-a-provider.e2e-spec.ts › refuses a stale token, a token for another subject, a token for an unlinked provider, and a forged token with the byte-identical refusal` |
| Refusal freshness boundary: more than 5 minutes stale refused, exactly 5 minutes accepted | `403` past the boundary; `201` at exactly 5:00 | `src/modules/identity/application/step-ups/confirm-step-up-with-provider.use-case.spec.ts › refuses a token issued more than five minutes ago, without ever spending a nonce or reaching the repository` |
| Indistinguishable refusal: no nonce claim / nonce nobody issued / nonce expired | `403 · identity.step_up_confirmation_refused`, byte-identical | `test/e2e/step-up-with-a-provider.e2e-spec.ts › refuses a step-up token with $what, byte-identically to an unissued nonce` |
| No dedicated attempt budget/lockout on this route: repeated refusals never escalate to `423` | `403` on every consecutive refusal | `test/e2e/step-up-with-a-provider.e2e-spec.ts › refuses a stale token, a token for another subject, a token for an unlinked provider, and a forged token with the byte-identical refusal` |
| Idempotent repeat: replaying the exact same ID token after it already minted an elevation | `403 · identity.step_up_confirmation_refused` | `test/e2e/step-up-with-a-provider.e2e-spec.ts › refuses a step-up token replayed after it already minted an elevation` |
| What a caller from another identity sees: a token linked to a different identity than the caller | `403 · identity.step_up_confirmation_refused` | `src/modules/identity/application/step-ups/confirm-step-up-with-provider.use-case.spec.ts › refuses when the linked provider identity belongs to a different identity than the caller` |
| Never leaks the presented ID token in the response | Response body excludes the raw token | `test/e2e/step-up-with-a-provider.e2e-spec.ts › never lets the id token reach what it answers` |
| Cross-route invariant: a nonce spent by `POST /sign-ins` cannot be spent again here | `403 · identity.step_up_confirmation_refused` | `test/e2e/step-up-with-a-provider.e2e-spec.ts › refuses a nonce already spent by a sign-in when it is presented again to a step-up` |
| Guard — no header / unissued token | `401 · identity.session_required` | `test/e2e/step-up-with-a-provider.e2e-spec.ts › refuses stepping up with a provider with $what` |
| Guard — revoked session / forged token, byte-identical to missing | `401 · identity.session_required` | `src/foundation/auth/session.guard.spec.ts › answers a missing header and an unrecognised token with byte-identical refusals`; `src/modules/identity/application/session-context/authenticate-caller.use-case.spec.ts › refuses a well-signed token whose liveness port answers not live, because $why` |
| Rate limit: exceeds `ANSWERS_A_CHALLENGE_RATE_LIMIT` | `429 · rate_limit.exceeded` | `test/e2e/rate-limit.e2e-spec.ts › refuses the $routeName request that goes past its own limit, counted on this route alone` |
| Concurrency: simultaneous nonce-spend vs. confirm | Exactly one mints an elevation, the other refuses | `test/e2e/step-up-with-a-provider.e2e-spec.ts › lets exactly one of two simultaneous step-ups spend the same nonce` |

### POST /provider-nonces

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: mints a nonce and a 10-minute expiry, exactly once per call | `201 Created` with `nonce`, `expiresAt = now + 10 min` | `src/modules/identity/application/provider-nonces/issue-provider-nonce.use-case.spec.ts › answers the raw nonce and an expiry ten minutes out, once` |
| Storage: persists the hash, never the raw nonce | Stored row has `nonce_hash`, no plaintext nonce anywhere | `src/modules/identity/application/provider-nonces/issue-provider-nonce.use-case.spec.ts › stores the hash of the nonce, never the nonce itself` |
| No refusal cause: issuance never fails on domain grounds | Always `201 Created` | — |
| Repeat: repeating the identical POST does not reuse anything; every call mints a materially distinct nonce | Two calls in a row yield two different ids/nonces | `src/modules/identity/application/provider-nonces/issue-provider-nonce.use-case.spec.ts › gives every issued nonce a distinct id` |
| No guard: route is intentionally public/unauthenticated | Reachable with or without any `authorization` header | — |
| Rate limit: exceeds `SENDS_A_MESSAGE_RATE_LIMIT` | `429 · rate_limit.exceeded` | `test/e2e/rate-limit.e2e-spec.ts › refuses the $routeName request that goes past its own limit, counted on this route alone` |
| Concurrency: simultaneous spend of the same nonce; exactly one wins | One `true`, one `false` | `src/modules/identity/infrastructure/provider-nonces/identity-provider-nonce.repository.integration-spec.ts › lets exactly one of two simultaneous spends of the same nonce win` |

## Password lifecycle

`password-methods.controller.ts`, `password-changes.controller.ts`, `password-recoveries.controller.ts`, `password-recovery-confirmations.controller.ts`. `password-methods` and `password-changes` carry `SessionGuard`; only `password-methods` also carries `ElevationGuard`. The two recovery routes are public.

### POST /password-methods

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: creates a password for an identity with none, and other sessions keep working | `204 No Content` | `test/e2e/password-methods.e2e-spec.ts › lets a provider-only identity create a password and sign in with it once it also has a verified email` |
| Afterward, a verified identifier can sign in with the new password | `201 Created` session | `test/e2e/password-methods.e2e-spec.ts › lets a provider-only identity create a password and sign in with it once it also has a verified email` |
| Refusal: an active password method already exists | `409 · identity.password_method_exists` | `test/e2e/password-methods.e2e-spec.ts › refuses cleanly when an active password already exists, leaving other sessions untouched`; `src/modules/identity/application/password-methods/create-password-method.use-case.spec.ts › refuses cleanly when an active password method already exists, without auditing anything` |
| Refusing an existing password leaves every other session of this identity untouched | Other sessions' access tokens keep working | `test/e2e/password-methods.e2e-spec.ts › refuses cleanly when an active password already exists, leaving other sessions untouched` |
| Concurrency: two simultaneous claims for the same identity serialize on the identity lock; exactly one wins | One `created`, one `already_exists` | `src/modules/identity/infrastructure/password-methods/identity-password-method.repository.integration-spec.ts › lets two simultaneous claims for the same identity serialize on the identity lock, exactly one winning` |
| Guard — missing elevation | `403 · identity.elevation_required` | `test/e2e/password-methods.e2e-spec.ts › refuses without any elevation presented` |
| Guard — elevation minted for a different session | `403 · identity.elevation_required` | `test/e2e/password-methods.e2e-spec.ts › refuses an elevation minted for a different session` |
| Validation: new password shorter than the documented minimum | `400 · foundation.validation_failed` | `test/e2e/password-methods.e2e-spec.ts › refuses a new password shorter than the documented minimum` |

### POST /password-changes

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: current password proven, new password committed, every other session ends, the calling session keeps working | `204 No Content` | `test/e2e/password-change.e2e-spec.ts › replaces the password and keeps the calling session alive while every other session ends` |
| Indistinguishable refusal: wrong current password vs. a new password identical to the current one | `403 · identity.password_change_refused`, same code and detail for both causes | `test/e2e/password-change.e2e-spec.ts › refuses a new password identical to the current one, the same way as a wrong current password` |
| Indistinguishable refusal, edge case: identity has no active password credential at all — a decoy hash is still compared so timing leaks nothing | `403 · identity.password_change_refused` | `src/modules/identity/application/password-changes/password-change.use-case.spec.ts › refuses an identity with no password credential, after still comparing a password` |
| Idempotent repeat: the exact same body replayed right after it already succeeded refuses, because the current password it names has already moved on | `403 · identity.password_change_refused` | `test/e2e/password-change.e2e-spec.ts › refuses the identical body replayed right after it already succeeded, because the current password it names has already moved on` |
| Concurrency: the identity row is locked for the duration of the commit, serializing a change against other writers | `204` for the writer that wins the lock | `test/integration/password-change.integration-spec.ts › does not leave live a session whose sign-in was still committing when the change ran` |
| Concurrency: two simultaneous changes for the same identity serialize on the identity lock; exactly one wins | One `committed: true`, one `committed: false`, the winning hash persists | `test/integration/password-change.integration-spec.ts › lets exactly one of two simultaneous changes for the same identity win, serialized by the identity lock` |
| Downstream effect: a pending second-factor ticket opened before the change is revoked in the same transaction | `401 · identity.second_factor_refused` on completing it; no new session | `src/modules/identity/application/sessions/pending-sign-in-ticket-revocation.integration-spec.ts` › `a ticket opened before a password change cannot be completed with an %s` |
| What a caller from another identity sees: changing identity A's password never touches identity B's sessions or credential | Identity B's password hash unchanged, sessions not revoked | `test/integration/password-change.integration-spec.ts › leaves a second identity sessions and password untouched` |
| Guard — no session at all | `401 · identity.session_required` | `test/e2e/password-change.e2e-spec.ts › refuses $what` |
| Guard — forged/invalid access token | `401 · identity.session_required` | `test/e2e/password-change.e2e-spec.ts › refuses $what` |
| Guard — revoked session, byte-identical to a missing token | `401 · identity.session_required` | `src/modules/identity/application/session-context/authenticate-caller.use-case.spec.ts › refuses a well-signed token whose liveness port answers not live, because $why` |
| Validation: a body carrying a field the contract does not declare | `400 · foundation.validation_failed` | `test/e2e/password-change.e2e-spec.ts › refuses a body carrying a field the contract does not declare` |

### POST /password-recoveries

| Case | Expected answer | Proving test |
|---|---|---|
| Indistinguishable happy path: a registered identifier and an unregistered one answer with the exact same body shape | `201 Created` with `{ data: { challengeExpiresAt } }`, identical field set for both | `test/e2e/password-recovery.e2e-spec.ts › answers an address nobody registered exactly like a registered one, field by field` |
| Refusal: nothing can reach that identifier type | `422 · identity.identifier_channel_unavailable` | `src/modules/identity/application/password-recoveries/request-password-recovery.use-case.spec.ts › refuses before opening a transaction when nothing can notify that identifier type` |
| Refusal: identifier fails normalization | `422 · identity.identifier_unusable` | `src/modules/identity/application/password-recoveries/request-password-recovery.use-case.spec.ts › refuses an unusable identifier before opening a transaction` |
| Infrastructure failure before the account is known (transaction cannot begin, the account lookup throws, or a transaction that found no account fails to commit): reported, identical for a registered and an unregistered address | `503 · Retry-After` for a transient database failure, `500` otherwise; never the `201` used for the happy path | `test/e2e/password-recovery-infrastructure-failure.e2e-spec.ts › answers a transient database failure with the same 503 and Retry-After for a registered and an unregistered address`; `test/e2e/password-recovery-infrastructure-failure.e2e-spec.ts › answers any other database failure with the same 500 for a registered and an unregistered address`; `src/modules/identity/application/password-recoveries/request-password-recovery.use-case.failures.spec.ts › reports the failure of the account lookup instead of answering accepted` |
| Infrastructure failure that can only happen for an existing account (the challenge insert, the enqueue, or the commit of a transaction that wrote a challenge): hidden so existence is not revealed, logged at `error` level with only the error name and code | `201 Created` with `{ data: { challengeExpiresAt } }`, the same as an unregistered identifier | `src/modules/identity/application/password-recoveries/request-password-recovery.use-case.failures.spec.ts › hides an enqueue failure for an existing account behind the exact outcome of an unknown one`; `src/modules/identity/application/password-recoveries/request-password-recovery.use-case.failures.spec.ts › raises the hidden enqueue failure to an error carrying only its name and code`; `src/modules/identity/application/password-recoveries/request-password-recovery.use-case.failures.spec.ts › hides a failure of the challenge insert for an existing account behind the exact outcome of an unknown one`; `src/modules/identity/application/password-recoveries/request-password-recovery.use-case.failures.spec.ts › hides a commit failure of a transaction that wrote a challenge, and raises it to an error` |
| Idempotent repeat: requesting recovery again for the same identifier opens a second live challenge alongside the first; consuming one later revokes the sibling | `201 Created` again, a new `challengeExpiresAt` | `test/integration/password-recovery.integration-spec.ts › revokes sibling live challenges once one is consumed` |
| Validation: a body carrying a field the contract does not declare | `400 · foundation.validation_failed` | `test/e2e/password-recovery.e2e-spec.ts › refuses a recovery request carrying a field the contract does not declare` |

### POST /password-recoveries/confirmations

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: correct code replaces the password; every session of that identity ends, including the one that requested the recovery | `204 No Content` | `test/e2e/password-recovery.e2e-spec.ts › walks the whole recovery flow: lose it, recover it, sign in with the new one, fail with the old one` |
| Indistinguishable refusal: wrong code, an already-consumed/reused code, and an unregistered address all answer identically | `422 · identity.password_recovery_confirmation_refused`, same code and detail, body never contains the identifier | `test/e2e/password-recovery.e2e-spec.ts › answers a wrong code, a reused code and an unregistered address with the same refusal` |
| Refusal: identifier fails normalization | `422 · identity.identifier_unusable` | `src/modules/identity/application/password-recoveries/confirm-password-recovery.use-case.spec.ts › refuses an unusable identifier before opening a transaction` |
| Validation: malformed code shape (not exactly six digits) rejected before touching the database | `400 · foundation.validation_failed` | `test/e2e/password-recovery.e2e-spec.ts › refuses a code that is not six digits before touching the database` |
| Attempt budget: the challenge's own attempt cap is spent — answers exactly like a missing/wrong code, no distinct locked status | `422 · identity.password_recovery_confirmation_refused` | `test/integration/password-recovery.integration-spec.ts › holds the attempt budget and stops accepting attempts once it is spent` |
| Attempt-budget side effect: the failure that crosses the separate account-lockout threshold still answers the ordinary refusal, never `423` | `422 · identity.password_recovery_confirmation_refused` (audited as `accountLocked`) | `src/modules/identity/application/password-recoveries/confirm-password-recovery.use-case.spec.ts › records the account as locked when the wrong recovery code is the failure that locks it` |
| Lockout: a wrong code on an already locked account answers the same ordinary refusal, never `423`, and the lock is not audited again | `422 · identity.password_recovery_confirmation_refused`, nothing audited | `src/modules/identity/application/password-recoveries/confirm-password-recovery.use-case.spec.ts › answers a wrong recovery code on an already locked account with the ordinary refusal, never a locked answer, and does not audit the lock again` |
| Idempotent repeat: replaying the same confirmation after the code already recovered the password | `422 · identity.password_recovery_confirmation_refused` | `test/e2e/password-recovery.e2e-spec.ts › cannot reuse a code once it has recovered the password` |
| Concurrency: the identity row is locked for the commit, serializing recovery against other writers | `204` for the winner | `test/integration/password-recovery.integration-spec.ts › does not leave live a session whose sign-in was still committing when the recovery ran` |
| Concurrency: two simultaneous confirmations of the same code serialize on the challenge lock; exactly one wins | One succeeds, the other refuses | `test/integration/password-recovery.integration-spec.ts › lets exactly one of two simultaneous confirmations for the same code win, serialized by the challenge lock` |
| Downstream effect: a pending second-factor ticket opened before the recovery is revoked in the same transaction | `401 · identity.second_factor_refused` on completing it; no new session | `src/modules/identity/application/sessions/pending-sign-in-ticket-revocation.integration-spec.ts` › `a ticket opened before a password recovery cannot be completed with an %s` |
| The code is the only credential: the confirm request carries no session, token or caller; anyone holding identifier and code completes it | `204`, no authentication required or checked | `test/e2e/password-recovery.e2e-spec.ts › walks the whole recovery flow: lose it, recover it, sign in with the new one, fail with the old one` |
| Configuration: numeric, alphabetic and alphanumeric out-of-band codes each confirm; a wrong code refuses; lower-case succeeds; surrounding whitespace is refused as the schema documents | `204`/`422`/`400` per case, per configured format | `test/e2e/out-of-band-code-formats.e2e-spec.ts › password recovery confirmation: issued code succeeds, wrong code refuses, lower-case succeeds and surrounding whitespace is refused as the schema documents` |

## Sign-in methods

`sign-in-methods.controller.ts`, `sign-in-method-confirmations.controller.ts`, `sign-in-method-removals.controller.ts`, `sign-in-method-providers.controller.ts`. `GET /sign-in-methods` carries `SessionGuard` only; the four mutating routes carry `SessionGuard` and `ElevationGuard`.

### GET /sign-in-methods

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: lists every way in (email, password, phone) after add and confirm | `200 OK` | `test/e2e/sign-in-methods.e2e-spec.ts › adds a phone to an email account and confirms it, listing every way in` |
| Happy path: a linked Google/Apple provider is listed alongside the password, with its provider email masked as a safe display value | `200 OK`, entry `kind: 'google'`/`'apple'`, masked `maskedValue` | `src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › lists a linked provider alongside the password, masking its provider email` |
| A linked provider with no provider email lists a safe `null` display value instead of guessing one | `200 OK`, `maskedValue: null` | `src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › lists a linked provider with no provider email as a safe null display value` |
| What a caller from another identity sees: identity B's list never contains identity A's sign-in methods | `200 OK` (own methods only) | `src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › never lists, never removes, and leaves untouched a real stranger identity` |
| Guard — no session presented | `401 · identity.session_required` | `src/foundation/auth/session.guard.spec.ts › refuses $what with the session-required failure, without asking the authenticator` |
| Guard — forged/unrecognized access token | `401 · identity.session_required` | `src/foundation/auth/session.guard.spec.ts › refuses a token the authenticator does not recognise, and leaves nobody on the request` |
| Guard — revoked session, byte-identical to a missing token | `401 · identity.session_required` | `src/modules/identity/application/session-context/authenticate-caller.use-case.spec.ts › refuses a well-signed token whose liveness port answers not live, because $why` |
| Guard — missing token and forged token answer byte-identically | `401 · identity.session_required` | `src/foundation/auth/session.guard.spec.ts › answers a missing header and an unrecognised token with byte-identical refusals` |
| Guard — revoked session and forged token answer byte-identically | `401 · identity.session_required` | `src/foundation/auth/session.guard.spec.ts › answers a missing header and an unrecognised token with byte-identical refusals`; `src/modules/identity/application/session-context/authenticate-caller.use-case.spec.ts › refuses a well-signed token whose liveness port answers not live, because $why` |

### POST /sign-in-methods

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: claims a new identifier and queues its code, inside the same transaction as the claim | `201 Created` | `test/e2e/sign-in-methods.e2e-spec.ts › adds a phone to an email account and confirms it, listing every way in` |
| Refusal: nothing can reach that identifier kind | `422 · identity.identifier_channel_unavailable` | `src/modules/identity/application/sign-in-methods/add-sign-in-method.use-case.spec.ts › refuses when nothing can reach that kind of identifier, without ever claiming it` |
| Refusal: identifier already verified by a different identity | `409 · identity.identifier_taken` | `src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › refuses an identifier already verified by a different identity` |
| Refusal: identifier is mid-claim (pending, unconfirmed) by a different identity | `409 · identity.identifier_taken` | `src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › refuses an identifier a different identity is still mid-way through claiming` |
| Refusal: identifier fails normalization | `422 · identity.identifier_unusable` | `src/modules/identity/application/sign-in-methods/add-sign-in-method.use-case.spec.ts › refuses an unusable identifier before ever checking whether it can be reached` |
| Self-collision: re-adding an identifier the same caller already has verified | `409 · identity.identifier_taken` | `src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › refuses re-adding an identifier the same caller has already verified` |
| Idempotent repeat: re-adding the same still-pending identifier queues a fresh code that supersedes the older one instead of erroring (OX-S8) | `201 Created`, new code, new expiry | `src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › still resends its own challenge when the same identity retries its own pending claim, superseding the older message (OX-S8)` |
| Concurrency: a rival identity claiming the same identifier while the first claim is pending loses under the per-identifier advisory lock | `409 · identity.identifier_taken` | `src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › lets exactly one of two identities simultaneously claiming the same identifier win, serialized by the per-identifier advisory lock` |
| Guard — no session presented | `401 · identity.session_required` | `src/foundation/auth/session.guard.spec.ts › refuses $what with the session-required failure, without asking the authenticator` |
| Guard — forged/unrecognized access token | `401 · identity.session_required` | `src/foundation/auth/session.guard.spec.ts › refuses a token the authenticator does not recognise, and leaves nobody on the request` |
| Guard — revoked session | `401 · identity.session_required` | `src/modules/identity/application/session-context/authenticate-caller.use-case.spec.ts › refuses a well-signed token whose liveness port answers not live, because $why` |
| Guard — missing elevation | `403 · identity.elevation_required` | `test/e2e/sign-in-methods.e2e-spec.ts › refuses to add a sign-in method without any elevation presented` |
| Guard — elevation minted for a different session | `403 · identity.elevation_required` | `test/e2e/sign-in-methods.e2e-spec.ts › refuses to add a sign-in method backed by an elevation minted for a different session` |

### POST /sign-in-methods/confirmations

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: code matches its own live challenge, verifies the identifier, audits it | `204 No Content` | `test/e2e/sign-in-methods.e2e-spec.ts › adds a phone to an email account and confirms it, listing every way in` |
| Refusal: wrong code, tried against every live challenge (each charged) | `422 · identity.sign_in_method_confirmation_refused` | `src/modules/identity/application/sign-in-methods/confirm-sign-in-method.use-case.spec.ts › refuses and charges every live challenge once none of them match, but only charges the account lockout once for the whole request` |
| Refusal: no live challenge exists to answer | `422 · identity.sign_in_method_confirmation_refused` | `src/modules/identity/application/sign-in-methods/confirm-sign-in-method.use-case.spec.ts › refuses without charging anything when there is no live challenge to answer` |
| Indistinguishable refusal: wrong code and no live challenge | `422 · identity.sign_in_method_confirmation_refused`, byte-identical | `src/modules/identity/application/sign-in-methods/confirm-sign-in-method.use-case.spec.ts › answers a wrong code and a missing challenge with the same refusal` |
| Attempt budget/lockout: the request whose failure reaches `ACCOUNT_LOCKOUT_THRESHOLD` locks the account and audits it exactly once but still answers the ordinary refusal; only the next attempt answers locked | `422 · identity.sign_in_method_confirmation_refused` on the crossing request, then `423 · identity.sign_in_method_locked` | `src/modules/identity/application/sign-in-methods/confirm-sign-in-method.use-case.spec.ts › locks the account once wrong codes accumulate one request at a time across several of its own live challenges, none of them exhausted individually, and records the lock exactly once` |
| Lockout: the correct code still succeeds while the account is locked, and clears the lock | `204 No Content` | `src/modules/identity/application/sign-in-methods/confirm-sign-in-method.use-case.spec.ts › does not refuse the correct code once the account is locked; it succeeds and clears the lock` |
| Idempotent repeat: replaying an already-consumed code | `422 · identity.sign_in_method_confirmation_refused` | `src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › refuses replaying an already-consumed confirmation code` |
| Concurrency: two simultaneous confirm attempts with the same code | Exactly one succeeds, the other refuses cleanly | `src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › lets at most one of two simultaneous confirmations with the same code consume the challenge` |
| What a caller from another identity sees: identity B's code cannot confirm identity A's pending identifier | `422 · identity.sign_in_method_confirmation_refused` | `src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › never lets identity B's code confirm identity A's pending identifier` |
| Guard — no session presented | `401 · identity.session_required` | `src/foundation/auth/session.guard.spec.ts › refuses $what with the session-required failure, without asking the authenticator` |
| Guard — forged/unrecognized access token | `401 · identity.session_required` | `src/foundation/auth/session.guard.spec.ts › refuses a token the authenticator does not recognise, and leaves nobody on the request` |
| Guard — revoked session | `401 · identity.session_required` | `src/modules/identity/application/session-context/authenticate-caller.use-case.spec.ts › refuses a well-signed token whose liveness port answers not live, because $why` |
| Guard — missing elevation | `403 · identity.elevation_required` | `test/e2e/sign-in-methods.e2e-spec.ts › refuses to confirm a sign-in method without any elevation presented` |
| Guard — elevation minted for a different session | `403 · identity.elevation_required` | `test/e2e/sign-in-methods.e2e-spec.ts › refuses to confirm a sign-in method backed by an elevation minted for a different session` |
| Configuration: numeric, alphabetic and alphanumeric out-of-band codes each confirm; a wrong code refuses; lower-case succeeds; surrounding whitespace is refused as the schema documents | `204`/`422`/`400` per case, per configured format | `test/e2e/out-of-band-code-formats.e2e-spec.ts › sign-in-method confirmation: issued code succeeds, wrong code refuses, lower-case succeeds and surrounding whitespace is refused as the schema documents` |

### POST /sign-in-methods/:id/removals

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: removes one of two verified identifiers, leaving the other listed | `204 No Content` | `test/e2e/sign-in-methods.e2e-spec.ts › removes a method once a live elevation proves this exact session` |
| Session contract: removing a password method ends every other session of this identity while the calling session keeps working, the same contract password change already documents | `204 No Content`, calling session's `revoked_at IS NULL`, every other session's `revoked_at IS NOT NULL` | `src/modules/identity/application/sign-in-methods/remove-sign-in-method-sessions.integration-spec.ts › removing the password method ends every other session while the calling session survives`; `src/modules/identity/application/sign-in-methods/remove-sign-in-method.use-case.spec.ts › revokes the target method with the calling session id, so the caller session can survive` |
| Session contract: removing a linked provider method ends every other session while the calling session keeps working | `204 No Content`, calling session's `revoked_at IS NULL`, every other session's `revoked_at IS NOT NULL` | `src/modules/identity/application/sign-in-methods/remove-sign-in-method-sessions.integration-spec.ts › removing a linked provider method ends every other session while the calling session survives` |
| Refusal: id names nothing this identity currently holds | `404 · identity.sign_in_method_not_found` | `src/modules/identity/application/sign-in-methods/remove-sign-in-method.use-case.spec.ts › refuses an id that names nothing this identity currently holds, without locking anything else` |
| Idempotent repeat: removing an already-removed method by the same id again | `404 · identity.sign_in_method_not_found` | `src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › refuses removing an already-removed method a second time, exactly like an id that never existed` |
| Concurrency: two concurrent removals of different targets on the same identity serialize instead of deadlocking, both succeed | `204 No Content` (both) | `test/integration/sign-in-method-removal-lock-order.integration-spec.ts › serialises two concurrent removals of different targets on the same identity instead of deadlocking` |
| What a caller from another identity sees: an id belonging to another identity answers exactly like one that does not exist, leaving it untouched | `404 · identity.sign_in_method_not_found` | `src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › never lists, never removes, and leaves untouched a real stranger identity` |
| Last-way-in invariant: refuses removing the only verified identifier, leaving the password stranded | `409 · identity.last_sign_in_method` | `test/e2e/sign-in-methods.e2e-spec.ts › refuses to remove the last way in` |
| Last-way-in invariant: refuses removing the password when nothing else can prove the owner | `409 · identity.last_sign_in_method` | `src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › refuses to remove the password when nothing else can prove the owner` |
| Last-way-in invariant boundary: removing the password is allowed once a self-sufficient method (external provider/passkey) still proves the owner | `204 No Content` | `src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › removes the password once a self-sufficient method can still prove the owner` |
| Happy path: removes a linked Google/Apple provider, leaving another self-sufficient method reachable | `204 No Content` | `src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › removes a linked provider once another self-sufficient method can still prove the owner` |
| Last-way-in invariant: refuses removing the only linked provider, leaving no way in | `409 · identity.last_sign_in_method` | `src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › refuses removing the only linked provider, leaving no way in` |
| Last-way-in invariant boundary: unlinking Google from a password identity is allowed and still leaves email+password reachable, whichever kind is removed | `204 No Content`; `201 Created` afterward with email+password | `test/e2e/sign-in-method-providers.e2e-spec.ts › unlinks Google from a password identity, still signs in with email and password, and treats the unlinked Google subject as unclaimed everywhere else` |
| Last-way-in invariant boundary: unlinking one of two linked providers is allowed, unlinking the very last one is refused | `204 No Content`, then `409 · identity.last_sign_in_method` | `test/e2e/sign-in-method-providers.e2e-spec.ts › unlinks a provider once another self-sufficient method still proves the owner, but refuses removing the very last one` |
| Domain invariant: a way in remains exactly when a self-sufficient method survives, or a verified identifier and an active password both survive — evaluated the same way whichever kind (identifier, password or provider) is removed | `true`/`false` per table row | `src/modules/identity/domain/sign-in-methods/last-way-in-policy.spec.ts › $name` |
| Downstream effect: unlinking a provider frees that exact `(provider, subject)` — a step-up-with-provider from the original session is refused, since it is no longer linked to that identity | `403 · identity.step_up_confirmation_refused` | `test/e2e/sign-in-method-providers.e2e-spec.ts › unlinks Google from a password identity, still signs in with email and password, and treats the unlinked Google subject as unclaimed everywhere else` |
| Downstream effect: an unlinked provider subject is unclaimed, not permanently blocked — signing in with it again opens a session for a fresh identity, never the original one | `201 Created`, a different identity/session than the original | `test/e2e/sign-in-method-providers.e2e-spec.ts › unlinks Google from a password identity, still signs in with email and password, and treats the unlinked Google subject as unclaimed everywhere else` |
| Downstream effect: an unlinked provider subject can be linked again, to a different identity | `204 No Content` | `src/modules/identity/infrastructure/sessions/oauth-sign-in.repository.integration-spec.ts › lets a provider subject be relinked, to a different identity, once it was unlinked` |
| Downstream effect: an unlinked provider subject can be linked again, to the same identity that unlinked it | `204 No Content` | `test/e2e/sign-in-method-providers.e2e-spec.ts › lets the same Google subject be linked again after being unlinked` |
| Downstream effect: removing a linked provider, or marking a method compromised, revokes every pending second-factor ticket of that identity in the same transaction | `401 · identity.second_factor_refused` on completing it; no new session | `src/modules/identity/application/sessions/pending-sign-in-ticket-revocation.integration-spec.ts` › `a ticket opened before a linked provider is removed cannot be completed with an %s`; `src/modules/identity/application/sessions/pending-sign-in-ticket-revocation.integration-spec.ts` › `a ticket opened before a sign-in method is marked compromised cannot be completed with an %s` |
| Validation: a non-UUID `:id` fails before it reaches the domain | `400 · foundation.validation_failed` | `test/e2e/sign-in-methods.e2e-spec.ts › refuses a method id that is not a UUID as a validation failure, before it reaches the database` |
| Guard — no session presented | `401 · identity.session_required` | `src/foundation/auth/session.guard.spec.ts › refuses $what with the session-required failure, without asking the authenticator` |
| Guard — forged/unrecognized access token | `401 · identity.session_required` | `src/foundation/auth/session.guard.spec.ts › refuses a token the authenticator does not recognise, and leaves nobody on the request` |
| Guard — revoked session | `401 · identity.session_required` | `src/modules/identity/application/session-context/authenticate-caller.use-case.spec.ts › refuses a well-signed token whose liveness port answers not live, because $why` |
| Guard — missing elevation | `403 · identity.elevation_required` | `test/e2e/sign-in-methods.e2e-spec.ts › refuses the same removal without any elevation presented` |
| Guard — elevation minted for a different session | `403 · identity.elevation_required` | `test/e2e/sign-in-methods.e2e-spec.ts › refuses a removal backed by an elevation minted for a different session` |

### POST /sign-in-methods/providers

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: links a Google/Apple identity to a signed-in identity, listed and usable to sign in afterward | `204 No Content` | `test/e2e/sign-in-method-providers.e2e-spec.ts › lets a password identity link Google and sign in with it afterward` |
| Refusal: that provider subject already belongs to a different identity, never revealing whose | `409 · identity.provider_link_refused` | `test/e2e/sign-in-method-providers.e2e-spec.ts › refuses cleanly when that provider subject already belongs to a different identity, never revealing whose` |
| Refusal: that provider subject is already linked to the caller identity itself, the same way | `409 · identity.provider_link_refused` | `test/e2e/sign-in-method-providers.e2e-spec.ts › refuses relinking a provider subject already linked to the caller itself`; `src/modules/identity/infrastructure/sessions/oauth-sign-in.repository.integration-spec.ts › refuses re-linking a subject already linked to the caller identity itself` |
| Never links automatically by matching a provider email address; only the (provider, subject) pair decides | Linking never consults the presented email | `src/modules/identity/application/sign-in-methods/link-provider.use-case.spec.ts › links exactly the presented provider, verified subject and email, onto the caller identity` |
| Idempotent repeat / replay: the exact same ID token replayed after it already linked a provider | `409 · identity.provider_link_refused` (single-use nonce already spent) | `test/e2e/sign-in-method-providers.e2e-spec.ts › refuses the exact same token replayed after it already linked a provider` |
| Refusal: the verifier could not vouch for the token, without ever spending a nonce or reaching the repository | `409 · identity.provider_link_refused` | `src/modules/identity/application/sign-in-methods/link-provider.use-case.spec.ts › refuses a token the verifier could not vouch for, without ever spending a nonce or reaching the repository` |
| Cross-route invariant: the nonce is spent in the same transaction that links the identity, the same binding used by provider sign-in | `409 · identity.provider_link_refused` when unspendable, before the repository is ever reached | `src/modules/identity/application/sign-in-methods/link-provider.use-case.spec.ts › refuses when the nonce is unknown, expired or already spent, without ever reaching the repository` |
| Concurrency: two simultaneous links of the same provider subject onto two different identities; exactly one wins | One `linked`, one `already_linked` | `src/modules/identity/infrastructure/sessions/oauth-sign-in.repository.integration-spec.ts › lets exactly one of two simultaneous links of the same subject win` |
| Validation: a body naming a provider nobody supports | `400 · foundation.validation_failed` | `test/e2e/sign-in-method-providers.e2e-spec.ts › refuses a body naming a provider nobody supports` |
| Guard — missing elevation | `403 · identity.elevation_required` | `test/e2e/sign-in-method-providers.e2e-spec.ts › refuses without any elevation presented` |
| Guard — elevation minted for a different session | `403 · identity.elevation_required` | `test/e2e/sign-in-method-providers.e2e-spec.ts › refuses an elevation minted for a different session` |

## Second factors and recovery codes

`second-factors.controller.ts`, `second-factor-confirmations.controller.ts`, `second-factor-removals.controller.ts`, `recovery-codes.controller.ts`. `GET /second-factors`, `POST /second-factors/confirmations` and `GET /recovery-codes` carry `SessionGuard` only; `POST /second-factors`, `POST /second-factors/:id/removals` and `POST /recovery-codes` carry `SessionGuard` and `ElevationGuard`.

### GET /second-factors

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: lists only verified, non-revoked methods; secret/pending enrolment never surface | `200 OK` | `test/e2e/second-factors.e2e-spec.ts › lists a verified authenticator app without any secret material, and never a pending enrolment` |
| What a caller from another identity sees: identity B never sees identity A's verified methods | `200 OK` with an empty/foreign-free list | `src/modules/identity/infrastructure/second-factors/identity-second-factor.repository.integration-spec.ts › never lists another identity method` |
| Guard — no session / forged or unrecognised token, indistinguishable | `401 · identity.session_required` | `test/e2e/second-factors.e2e-spec.ts › refuses listing with $what` |
| Guard — revoked/dead session presenting an otherwise well-signed token | `401 · identity.session_required` | `test/e2e/second-factors.e2e-spec.ts › refuses listing with a session that has since signed out` |

### POST /second-factors

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: enrols, returns secret and otpauth URI once, confirmable with the current code | `201 Created` | `test/e2e/second-factors.e2e-spec.ts › enrols an authenticator app and confirms it with the current code computed from the returned secret` |
| Configuration: a configured eight-digit SHA256 policy is enrolled, confirmed and signed in with end to end, and the otpauth URI carries that exact algorithm/digits/period | `201 Created`; otpauth URI contains `algorithm=SHA256`, `digits=8`, `period=60` | `test/e2e/totp-policy.e2e-spec.ts › enrols, confirms and signs in with an authenticator app under a configured eight-digit SHA256 policy, and the otpauth URI carries that exact configuration` |
| Configuration: the default six-digit SHA1 policy keeps working end to end under an unset configuration | `201 Created`; otpauth URI contains `algorithm=SHA1`, `digits=6`, `period=30` | `test/e2e/totp-policy.e2e-spec.ts › leaves an authenticator app enrolled under the default six-digit SHA1 policy working end to end under an unset (default) configuration` |
| Configuration: a global TOTP policy change never invalidates an already-enrolled authenticator, and a new enrolment after the change gets the new policy | `201 Created` sign-in with the pre-change authenticator after the application restarts under a different policy; new enrolment's otpauth URI carries the new policy | `test/e2e/totp-policy.e2e-spec.ts › never locks out an authenticator enrolled before a global TOTP policy change, while a new enrolment after the change gets the new policy` |
| Idempotent repeat: enrolling again before confirming revokes the prior pending enrolment (documented rotation, not a distinct error) | `201 Created`; old secret's code no longer confirms | `test/e2e/second-factors.e2e-spec.ts › enrolling twice leaves only the newest secret confirmable, and the old secret no longer works` |
| Concurrency: the unique-pending invariant holds across repeated enrolments | Only the newest pending row survives | `src/modules/identity/infrastructure/second-factors/identity-second-factor.repository.integration-spec.ts › holds the unique pending index: re-enrolling three times in a row still leaves only one live pending row`; `src/modules/identity/infrastructure/second-factors/identity-second-factor.repository.integration-spec.ts › lets two simultaneous enrolments for the same identity serialize on the advisory lock, leaving one live pending row` |
| Refusal: no live elevation presented | `403 · identity.elevation_required` | `test/e2e/second-factors.e2e-spec.ts › refuses enrolment without any elevation presented` |
| Guard — no session / forged or unrecognised token | `401 · identity.session_required` | `test/e2e/second-factors.e2e-spec.ts › refuses enrolling with $what` |
| Guard — revoked/dead session | `401 · identity.session_required` | `test/e2e/second-factors.e2e-spec.ts › refuses enrolling with a session that has since signed out` |

### POST /second-factors/confirmations

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: proves a current code, verifies the pending method | `204 No Content` | `test/e2e/second-factors.e2e-spec.ts › enrols an authenticator app and confirms it with the current code computed from the returned secret` |
| Indistinguishable refusal: wrong code, no pending enrolment, and an already-verified pending step all collapse to the same refusal | `403 · identity.second_factor_confirmation_refused` | `test/e2e/second-factors.e2e-spec.ts › refuses a wrong code without consuming the pending enrolment, so the real code still confirms afterward` |
| Idempotent repeat: confirming again once already verified answers the same refusal as above | `403 · identity.second_factor_confirmation_refused` | `test/e2e/second-factors.e2e-spec.ts › refuses confirmation once the pending enrolment is already verified` |
| Attempt budget/lockout: the failure that reaches the shared per-identity threshold locks the account and audits it but still refuses normally; only a later wrong code on the locked account answers locked, without a second audit | `403 · identity.second_factor_confirmation_refused` on the crossing code, then `423 · identity.second_factor_locked` | `src/modules/identity/application/second-factors/confirm-second-factor.use-case.spec.ts › records the account as locked when a wrong second-factor code is the failure that locks it`; `src/modules/identity/application/second-factors/confirm-second-factor.use-case.spec.ts › throws SecondFactorLockedError once the account is already locked` |
| Lockout: a wrong code below the threshold refuses and records no lock | `403 · identity.second_factor_confirmation_refused`, nothing audited | `src/modules/identity/application/second-factors/confirm-second-factor.use-case.spec.ts › refuses a wrong code below the threshold without recording any lock` |
| Lockout does not gate a genuinely correct code; success never consults or clears the lock | `204 No Content` even while locked | `src/modules/identity/application/second-factors/confirm-second-factor.use-case.spec.ts › confirms a correct code while the account is locked, without consulting or clearing the lock` |
| Concurrency: double-confirming the same enrolment at once, via a compare-and-set on the last used step | Second attempt refused | `src/modules/identity/infrastructure/second-factors/identity-second-factor.repository.integration-spec.ts › the compare-and-set refuses a step equal to the one already spent`; `src/modules/identity/infrastructure/second-factors/identity-second-factor.repository.integration-spec.ts › lets exactly one of two simultaneous confirmations with the same step win the compare-and-set` |
| Guard — no session / forged or unrecognised token | `401 · identity.session_required` | `test/e2e/second-factors.e2e-spec.ts › refuses confirming with $what` |
| Guard — revoked/dead session | `401 · identity.session_required` | `test/e2e/second-factors.e2e-spec.ts › refuses confirming with a session that has since signed out` |

### POST /second-factors/:id/removals

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: removes a verified method with a live elevation; list becomes empty | `204 No Content` | `test/e2e/second-factors.e2e-spec.ts › removes a verified second factor once a live elevation proves it is safe, and the list becomes empty` |
| Every second factor can be removed freely; the primary sign-in method still exists, so there is no last-way-in gate here | `204 No Content` down to zero second factors | `src/modules/identity/application/second-factors/remove-second-factor.use-case.spec.ts › revokes the method scoped by both the caller identity and the target id, and records the removal` |
| Removing an authenticator ends every other live session while the calling session survives | `204 No Content`; the other session's `revoked_at` is set, the caller's stays `NULL` | `src/modules/identity/application/second-factors/remove-second-factor.use-case.spec.ts › ends every other live session of the identity while sparing the calling session`; `src/modules/identity/application/second-factors/remove-second-factor-sessions.integration-spec.ts › ends every other session while the calling session survives` |
| Indistinguishable refusal: an id belonging to another identity, an unknown id, and an already-removed id are all `404`, so this route is deliberately not an existence oracle | `404 · identity.second_factor_not_found` | `test/e2e/second-factors.e2e-spec.ts › answers 404 for a method id belonging to another identity, leaving it intact for its owner` |
| Idempotent repeat: removing an already-removed id | `404 · identity.second_factor_not_found` | `test/e2e/second-factors.e2e-spec.ts › answers the same 404 for an unknown id and for an id already removed` |
| Refusal: no live elevation | `403 · identity.elevation_required` | `test/e2e/second-factors.e2e-spec.ts › refuses removal without a live elevation` |
| Validation: id not a UUID, refused before the database is touched | `400 · foundation.validation_failed` | `test/e2e/second-factors.e2e-spec.ts › refuses a method id that is not a UUID as a validation failure, before it reaches the database` |
| Concurrency: removing the same factor twice at once, via a conditional update guarded by `revoked_at IS NULL` (proven sequentially, not as a true race) | One winner, one `404` | `src/modules/identity/infrastructure/second-factors/identity-second-factor.repository.integration-spec.ts › affects zero rows the second time it revokes the same method` |
| Guard — revoked/dead session (the only removal route with a route-specific proof of this sub-case) | `401 · identity.session_required` | `test/e2e/session-revocation.e2e-spec.ts › refuses an elevation token minted for a session that is later signed out, because ElevationGuard never runs without a session SessionGuard already admitted` |
| Guard — no session / forged or unrecognised token | `401 · identity.session_required` | `test/e2e/second-factors.e2e-spec.ts › refuses removal with $what` |

### GET /recovery-codes

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: reports the live batch's remaining count and issuedAt | `200 OK` | `test/e2e/recovery-codes.e2e-spec.ts › issues a batch of ten codes in the documented four-groups-of-four format, and GET reports ten remaining` |
| Zero/never-issued: `remaining: 0`, no `issuedAt`, before any batch was ever issued | `200 OK`, `{ remaining: 0 }`, no `issuedAt` key | `test/e2e/recovery-codes.e2e-spec.ts › answers remaining: 0 and no issuedAt before any batch was ever issued` |
| Live count reflects a spend | `200 OK`, `{ remaining: 9 }` | `test/e2e/recovery-codes.e2e-spec.ts › signs in with a recovery code when the authenticator app is unreachable, opening a working session, and GET now reports nine` |
| Guard — no session / forged or unrecognised token / revoked session | `401 · identity.session_required` | `test/e2e/recovery-codes.e2e-spec.ts › refuses reading the status with $what`; `test/e2e/recovery-codes.e2e-spec.ts › refuses reading the status with a session that has since signed out` |
| What a caller from another identity sees: does B's GET ever reflect A's batch/count | `200 OK`, never (scoped by `identity_id`) | `src/modules/identity/infrastructure/recovery-codes/identity-recovery-code.repository.integration-spec.ts › never reflects a stranger's live batch or its issuedAt` |

### POST /recovery-codes

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: issues 10 fresh codes in `xxxx-xxxx-xxxx-xxxx` shape, returned once | `201 Created`, 10 unique codes | `test/e2e/recovery-codes.e2e-spec.ts › issues a batch of ten codes in the documented four-groups-of-four format, and GET reports ten remaining` |
| Re-issuing is documented rotation (happy path, not idempotency-preservation): every issue revokes the prior live batch, retiring its codes | `201 Created`; old batch's codes stop redeeming | `test/e2e/recovery-codes.e2e-spec.ts › refuses a code from a retired batch once a fresh batch has been issued, and GET reports the new batch of ten` |
| A spent code from the retired batch stays untouched by a later re-issue | Spent code keeps `used_at` set, `revoked_at` null | `src/modules/identity/infrastructure/recovery-codes/identity-recovery-code.repository.integration-spec.ts › leaves a used code out of the replaced batch untouched by a later issuance` |
| What a caller from another identity sees: issuing for A never revokes or otherwise touches B's live batch | B's batch unaffected | `src/modules/identity/infrastructure/recovery-codes/identity-recovery-code.repository.integration-spec.ts › leaves a second identity completely untouched` |
| Concurrency: two concurrent issues for the same identity, via a per-identity advisory transaction lock | Last write wins, exactly one live batch survives | `src/modules/identity/infrastructure/recovery-codes/identity-recovery-code.repository.integration-spec.ts › lets two simultaneous issuances for the same identity serialize on the advisory lock, leaving exactly one live batch` |
| Refusal: no live elevation, and no session is ever opened for that request | `403 · identity.elevation_required` | `test/e2e/recovery-codes.e2e-spec.ts › refuses to issue a batch without a live elevation, and never opens a session for that request` |
| Guard — no session at all | `401 · identity.session_required` | `test/e2e/recovery-codes.e2e-spec.ts › refuses to issue a batch with no session at all` |
| Guard — forged/unrecognised token / revoked session | `401 · identity.session_required` | `test/e2e/recovery-codes.e2e-spec.ts › refuses to issue a batch with a token nobody ever issued`; `test/e2e/recovery-codes.e2e-spec.ts › refuses to issue a batch with a session that has since signed out` |

## Cross-route invariants

| Invariant | Expected answer | Proving test |
|---|---|---|
| Signing out closes renewal | `401 · identity.renewal_refused` | `test/e2e/sign-out.e2e-spec.ts › ends the session, so the refresh token it was holding stops working` |
| Changing the password closes other sessions, the calling one keeps working | `401 · identity.session_required` on every other session's access token; the calling session keeps working | `test/e2e/session-revocation.e2e-spec.ts › refuses every other access token once the password changes, while the calling one keeps working` |
| Recovering the password closes every session, with no exception for the one that requested it | `401 · identity.session_required` on every session's access token | `test/e2e/session-revocation.e2e-spec.ts › refuses every access token, including the one that requested it, once the password is recovered` |
| Removing the last way in is refused, and only on `POST /sign-in-methods/:id/removals` — `POST /second-factors/:id/removals` carries no equivalent guard, and every second factor can be removed freely | `409 · identity.last_sign_in_method` on the sign-in-methods route; `204 No Content` down to zero on the second-factors route | `test/e2e/sign-in-methods.e2e-spec.ts › refuses to remove the last way in`; `test/e2e/second-factors.e2e-spec.ts › removes a verified second factor once a live elevation proves it is safe, and the list becomes empty` |
| A lock set by one route refuses the next wrong guess on every route; a correct secret is still admitted everywhere, because a lock only ever stops guessing | `201 Created` for a correct credential/code on a locked account, on every route a test exercises; `423` on the very first wrong guess a different route sees afterward | `test/e2e/account-lockout.e2e-spec.ts › still opens a session for the correct password once the account is locked, because a lock only ever stops guessing`; `test/e2e/account-lockout.e2e-spec.ts › still mints the elevation for the correct code once the step-up account is locked, because the caller already proved who they are`; `test/e2e/account-lockout.e2e-spec.ts › locks a step-up on its very first wrong code once wrong passwords already locked the account on sign-in` |
| The failure that crosses the lock threshold locks the account and audits it exactly once but answers the route's ordinary refusal, on every route that records a lockout failure; only a later wrong guess answers `423`, and only on the four routes that expose a locked answer (sign-in second factor, step-up, sign-in method confirmation, second-factor confirmation) | Crossing failure: the route's ordinary refusal, one `accountLocked` audit; later wrong guess: `423` (or the ordinary refusal on the routes that never answer `423`), no second audit | `src/modules/identity/application/lockouts/record-lockout-failure.spec.ts › locks the account on the failure that crosses the threshold and audits it once, still answering the ordinary refusal`; `src/modules/identity/application/lockouts/record-lockout-failure.spec.ts › answers the lock expiry on every later attempt and never audits the lock a second time` |
| A revoked session's access token is refused on the next request | `401 · identity.session_required` | `test/e2e/session-revocation.e2e-spec.ts › refuses the same access token on the very next request once its session signs out` |
| A spent provider nonce cannot be spent on the other provider route | `403 · identity.step_up_confirmation_refused` when a nonce spent by `POST /sign-ins` is replayed at `POST /step-ups/providers` | `test/e2e/step-up-with-a-provider.e2e-spec.ts › refuses a nonce already spent by a sign-in when it is presented again to a step-up` |
| The same cross-route invariant holds in the other direction | `401 · identity.sign_in_refused` when a nonce spent by `POST /step-ups/providers` is replayed at `POST /sign-ins` | `test/e2e/step-up-with-a-provider.e2e-spec.ts › refuses a nonce already spent by a step-up when it is presented again to a sign-in` |
| An out-of-band code issued under one configured policy still confirms after the application restarts under a different one, before it expires | `204 No Content` | `test/e2e/verification-code-policy.e2e-spec.ts › confirms a code issued under the default policy after the application restarts under a different one, before it expires` |
| A challenge's attempt budget and the identity's account lockout counter are not reset by an out-of-band policy change across a restart | `422 · identity.verification_refused` for the correct code, unchanged even after the restart | `test/e2e/verification-code-policy.e2e-spec.ts › does not reset the challenge attempt budget or the account lockout counter across a restart that changes the policy` |

## Gaps

None open. Every case a proving test could reach names one; the two rows that still answer `—` describe a route with no failure mode to prove (an issuance that never refuses, and a route deliberately carrying no guard), not an untested case.
