---
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 | 26 |
| Per-route cases | 279 |
| Per-route cases proven | 277 (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 | 10 (10 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 sends the verification code | `201 Created` · `{ data: { identity, challengeDelivery: DELIVERED, challengeExpiresAt } }` | `test/e2e/sign-up.e2e-spec.ts › registers an email identity and answers 201 inside the envelope` |
| Response never carries the code it just sent | `201 Created`, body excludes the code | `test/e2e/sign-up.e2e-spec.ts › never answers with the verification code it just sent` |
| Idempotent repeat: registering the same still-unverified address again | `201 Created`, same `challengeDelivery`, a fresh code is sent again (no `409`) | `test/e2e/sign-up.e2e-spec.ts › answers a second registration for an unverified address the same way, and sends again` |
| Indistinguishable refusal: a squatter registers first, the real owner registers the same address with a different password | `201 Created` both times, byte-identical `challengeDelivery`, 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` |
| 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` |
| Degraded happy path: identity is created but the outbound notifier fails to send the code | `201 Created` · `challengeDelivery: { status: NOT_DELIVERED, resendable: true }`, registration still committed | `src/modules/identity/application/registration/register-identity.use-case.spec.ts › keeps the registration when the challenge never left, and says it can be resent` |
| Degraded happy path variant: delivery fails for an unexplained reason | `201 Created` · `challengeDelivery: { status: NOT_DELIVERED, resendable: false }` | `src/modules/identity/application/registration/register-identity.use-case.spec.ts › refuses to call an unexplained failure resendable, because nothing proves the code never left` |
| Validation: password too short | `400 · 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 · 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 · 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 sent | `204 No Content` | `test/e2e/sign-up-confirmation.e2e-spec.ts › verifies the identifier with the code it was sent` |
| 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` |
| Account lockout: trips once wrong guesses spread across this identity's several own challenges reach the threshold, but the owner still answers the correct code afterward | Account locks, the correct code still consumes the challenge normally | `src/modules/identity/application/verification/verify-identifier.use-case.integration-spec.ts › locks the account once wrong guesses spread across its own several challenges reach the threshold, but still lets the owner in with the correct code` |
| Validation: code is not exactly six digits, refused before touching the database | `400 · 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` |
| 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 · 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 · 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 · 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` |

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

### 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 · 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/presentation/guards/session.guard.spec.ts › refuses a well-signed token the same way SESSION_REQUIRED refuses a missing one, 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 delivers a code to the verified identifier | `201 Created` with `challengeExpiresAt` | `test/e2e/step-up.e2e-spec.ts › proves the code sent to 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: identifier type found but nothing can notify it | `422 · identity.identifier_channel_unavailable` | `src/modules/identity/application/step-ups/open-step-up.use-case.spec.ts › refuses when nothing can reach the identifier type it found, before opening a challenge` |
| 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/modules/identity/presentation/guards/session.guard.spec.ts › answers a dead session and a forged token with byte-identical refusals` |

### 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 sent to 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` | `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` |
| 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` |
| 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/modules/identity/presentation/guards/session.guard.spec.ts › answers a dead session and a forged token with byte-identical refusals` |
| 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/modules/identity/presentation/guards/session.guard.spec.ts › answers a dead session and a forged token with byte-identical refusals` |
| 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-changes.controller.ts`, `password-recoveries.controller.ts`, `password-recovery-confirmations.controller.ts`. Only `password-changes` carries a guard (`SessionGuard`, no `ElevationGuard`); the two recovery routes are public.

### 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` |
| 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/presentation/guards/session.guard.spec.ts › refuses a well-signed token the same way SESSION_REQUIRED refuses a missing one, because $why` |
| Validation: a body carrying a field the contract does not declare | `400 · 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` |
| 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 · 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 · 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` |
| 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` |
| 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`. `GET /sign-in-methods` carries `SessionGuard` only; the three 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` |
| 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/modules/identity/presentation/guards/session.guard.spec.ts › refuses a request carrying no authorization at all` |
| Guard — forged/unrecognized access token | `401 · identity.session_required` | `src/modules/identity/presentation/guards/session.guard.spec.ts › refuses a token the reader 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/presentation/guards/session.guard.spec.ts › refuses a well-signed token the same way SESSION_REQUIRED refuses a missing one, because $why` |
| Guard — missing token and forged token answer byte-identically | `401 · identity.session_required` | `src/modules/identity/presentation/guards/session.guard.spec.ts › answers a missing header and an unrecognised token with the same refusal` |
| Guard — revoked session and forged token answer byte-identically | `401 · identity.session_required` | `src/modules/identity/presentation/guards/session.guard.spec.ts › answers a dead session and a forged token with byte-identical refusals` |

### POST /sign-in-methods

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: claims a new identifier and sends its code | `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 resends a fresh code instead of erroring | `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` |
| 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/modules/identity/presentation/guards/session.guard.spec.ts › refuses a request carrying no authorization at all` |
| Guard — forged/unrecognized access token | `401 · identity.session_required` | `src/modules/identity/presentation/guards/session.guard.spec.ts › refuses a token the reader does not recognise, and leaves nobody on the request` |
| Guard — revoked session | `401 · identity.session_required` | `src/modules/identity/presentation/guards/session.guard.spec.ts › refuses a well-signed token the same way SESSION_REQUIRED refuses a missing one, 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: after `ACCOUNT_LOCKOUT_THRESHOLD` failed confirmations, the account locks and is audited exactly once | `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/modules/identity/presentation/guards/session.guard.spec.ts › refuses a request carrying no authorization at all` |
| Guard — forged/unrecognized access token | `401 · identity.session_required` | `src/modules/identity/presentation/guards/session.guard.spec.ts › refuses a token the reader does not recognise, and leaves nobody on the request` |
| Guard — revoked session | `401 · identity.session_required` | `src/modules/identity/presentation/guards/session.guard.spec.ts › refuses a well-signed token the same way SESSION_REQUIRED refuses a missing one, 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` |
| 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` |
| Validation: a non-UUID `:id` fails before it reaches the domain | `400 · 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/modules/identity/presentation/guards/session.guard.spec.ts › refuses a request carrying no authorization at all` |
| Guard — forged/unrecognized access token | `401 · identity.session_required` | `src/modules/identity/presentation/guards/session.guard.spec.ts › refuses a token the reader does not recognise, and leaves nobody on the request` |
| Guard — revoked session | `401 · identity.session_required` | `src/modules/identity/presentation/guards/session.guard.spec.ts › refuses a well-signed token the same way SESSION_REQUIRED refuses a missing one, 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` |

## 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 fifth consecutive failure (shared per-identity threshold) locks the account instead of refusing normally | `423 · identity.second_factor_locked` | `src/modules/identity/application/second-factors/confirm-second-factor.use-case.spec.ts › throws SecondFactorLockedError once the account is already locked` |
| 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` |
| 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 · 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` |
| 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.
