---
url: /reference/OUTBOX_CASE_MATRIX.md
---
# Outbox Case Matrix

One row per behavioral case for every use case named in
[`OUTBOX_USE_CASES.md`](OUTBOX_USE_CASES.md). Each case names the test that
would prove it, quoted verbatim as a spec file path, then the separator
"›", then the exact test title, exactly like `IDENTITY_CASE_MATRIX.md`,
`ORGANIZATION_CASE_MATRIX.md`, and `SUBSCRIPTION_PLANS_CASE_MATRIX.md`. Until
group G1 (below), no outbox code existed, so every row answered `—` by
construction, not by omission. G1 (`database/migrations/0030_identity_security_outbox.sql`,
`database/canonical/0040_identity.sql`) ships the identity security outbox's
storage shape, its `FOR UPDATE SKIP LOCKED` claim/lease, its attempt
recording, and the least-privilege split between `ninaku_runtime` (enqueue
only) and the new `ninaku_worker` role (claim/record only, no direct table
privilege at all) — proven by `database/tests/contracts/security-outbox-contracts.ts`
and `database/tests/concurrency/security-outbox-concurrency.ts`. Every other row — the four Identity use cases actually calling the enqueue
path (G3), the worker process dispatching a real send (G4's application
layer), and the rest — is still a gap. G4 (`database/migrations/0032_the_worker_claim_respects_expiry_and_send_caps.sql`,
`database/migrations/0033_the_worker_claim_drops_the_send_cap.sql`)
adds the challenge-expiry-aware claim/record pair (OX-W7's database-level
backstop) and the two sweep functions (OX-S7, OB11/OX-O3) at the database
layer; migration 0032 also added a per-destination send cap that migration
0033 removed after review found it reopened the OB7 denial of service (see
[Worker (OB1/OB6)](#worker-ob1ob6) below). The worker process that calls
all of this in a loop is application-layer code the matrix below still
marks open.

Five rows that previously had no settled "expected answer" — OX-S8's
resend-supersede behavior, OX-S9's response-DTO shape, OX-W5's
`INDETERMINATE`-retry classification, OX-O2's authorized-reissue actor, and
OX-O3's retention period — are now settled by owner decisions OB7–OB11
(`odd/tasks/outbox-module.md`, 2026-09-25) and read as ordinary expected
answers below, called out again in [Gaps](#gaps) only for which layer would
prove them, never for being undecided.

***

## Totals

| | |
|---|---:|
| Use cases | 25 |
| Per-case scenario rows | 128 |
| Per-case scenario rows proven | 89 |
| Cross-cutting invariants | 5 (1 proven) |
| Gaps | 43 |

***

## Security outbox (Identity-owned, OB2/OB3)

### OX-S1 — Enqueue the sign-up verification code atomically with its challenge

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: sign-up submits a reachable identifier | Challenge row and outbox message row both committed in one transaction | `src/modules/identity/application/registration/register-identity.use-case.integration-spec.ts › leaves exactly one pending outbox message bound to the challenge it queued the code for`; `src/modules/identity/application/registration/register-identity.use-case.spec.ts › enqueues inside the same transaction, right after the challenge is claimed` |
| Enqueue insert fails (constraint violation, disk full) | Whole transaction rolls back; no challenge left with nothing to notify it | `src/modules/identity/application/registration/register-identity.use-case.integration-spec.ts › rolls the challenge and the login identifier back when the outbox enqueue fails`; `src/modules/identity/application/registration/register-identity.use-case.spec.ts › lets an enqueue failure escape so the transaction rolls the challenge back too` |
| Retry of the same registration before the first challenge expires | Governed unchanged by the existing pending-challenge-reuse repository logic (PR #401's fix included); this case only changes how the code is delivered | `src/modules/identity/application/registration/register-identity.use-case.integration-spec.ts › never supersedes the first registrant pending code when a different registrant claims the same unconfirmed address (OX-S8)` |

### OX-S2 — Enqueue the add-sign-in-method verification code atomically with its challenge

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: signed-in caller adds a reachable identifier | Challenge and outbox message committed together; the old `abandonAddition` compensating transaction is retired | `src/modules/identity/application/sign-in-methods/add-sign-in-method.use-case.spec.ts › enqueues the sealed code inside the same transaction as the claim, and never notifies synchronously`; `test/e2e/sign-in-methods.e2e-spec.ts › adds a phone to an email account and confirms it, listing every way in` |
| Concurrent second add-sign-in-method request for the same caller before the first confirms | Governed unchanged by the existing claim-uniqueness invariant | `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` |
| Enqueue insert fails | Whole transaction rolls back; no orphaned challenge, no compensating transaction needed | `src/modules/identity/application/sign-in-methods/add-sign-in-method.use-case.spec.ts › lets an enqueue failure escape so the transaction rolls the claim back too` |

### OX-S3 — Enqueue the password-recovery code atomically with its challenge, preserving anti-enumeration

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: known identifier | Challenge opened, outbox message enqueued, response `{ challengeExpiresAt }` | `src/modules/identity/application/password-recoveries/request-password-recovery.use-case.integration-spec.ts › leaves exactly one pending outbox message bound to the challenge it opened`; `src/modules/identity/application/password-recoveries/request-password-recovery.use-case.spec.ts › enqueues inside the same transaction, right after the challenge opens` |
| Unknown identifier | Nothing opened, nothing enqueued, response identical in shape and timing to the known-identifier case | `src/modules/identity/application/password-recoveries/request-password-recovery.use-case.integration-spec.ts › enqueues nothing for an identifier nobody registered`; › `answers a known and an unknown identifier with the exact same shape`; `test/e2e/password-recovery.e2e-spec.ts › enqueues nothing for an address nobody registered` |
| Enqueue insert fails for a real identifier | Hidden behind the same `201` outcome as the unknown-identifier case (surfacing it would reveal that the account exists) and logged at `error` level with only the error name and code, never the message, the identifier or the code (the challenge itself rolls back with the failed enqueue, inside the same transaction) | `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`; › `raises the hidden enqueue failure to an error carrying only its name and code`; › `never carries a message, an identifier or a code into the error log`; `src/modules/identity/application/password-recoveries/request-password-recovery.use-case.spec.ts › logs a swallowed enqueue failure with its name and code but never its message, whatever the message carries`; `src/modules/identity/application/password-recoveries/request-password-recovery.use-case.integration-spec.ts › rolls the recovery challenge back when the outbox enqueue fails, but still answers accepted (anti-enumeration)` |
| Challenge insert failure, or commit failure of a transaction that wrote a challenge, for a real identifier | Hidden behind the same `201` outcome and logged at `error` level with only the error name and code, for the same reason; the use case marks the account as known as soon as the lookup answers, before the insert runs | `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`; › `raises a failed challenge insert to an error carrying only its name and code`; › `hides a commit failure of a transaction that wrote a challenge, and raises it to an error` |
| Failure that does not depend on whether the account exists: the transaction cannot begin, the account lookup throws, or the commit of a transaction that found no account fails | Reported, never answered `201`: the same `503` with `Retry-After` for a transient database failure and the same `500` for any other, for a registered and an unregistered address alike | `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`; › `reports a transaction that cannot begin instead of answering accepted`; › `reports a commit failure of a transaction that found no account, because it says nothing about existence`; `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`; › `answers any other database failure with the same 500 for a registered and an unregistered address` |

### OX-S4 — Enqueue the step-up verification code atomically with its challenge (fixes notify-before-commit)

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: a reachable candidate exists | Challenge and outbox message committed together; nothing sent synchronously | `src/modules/identity/application/step-ups/open-step-up.use-case.integration-spec.ts › leaves exactly one pending outbox message bound to the challenge it opened`; `src/modules/identity/application/step-ups/open-step-up.use-case.spec.ts › enqueues a code of the length the policy sets, inside the same transaction as the challenge (fixes notify-before-commit)` |
| No notifiable candidate at all | Refused before any transaction opens (`StepUpUnreachableError`/`ChallengeChannelUnavailableError`) | `src/modules/identity/application/step-ups/open-step-up.use-case.integration-spec.ts › refuses when the identity has no verified identifier at all, without opening a transaction`; `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` |
| Database failure after the code is generated but before commit | Whole transaction rolls back; nothing was sent, so no live code exists outside the database — the bug OX-S4 exists to close | `src/modules/identity/application/step-ups/open-step-up.use-case.integration-spec.ts › rolls the step-up challenge back when the outbox enqueue fails, committing neither`; `src/modules/identity/application/step-ups/open-step-up.use-case.spec.ts › lets an enqueue failure escape so the transaction rolls the challenge back too` |

### OX-S5 — Encrypted OTP custody in the security outbox

G2 binds the AAD context to the outbox message's own `id` and `purpose`
(not the challenge id the catalog above originally proposed): `id` is the
ciphertext row's own primary key, so it is unique by construction, and
`purpose` adds a second independent binding on top, the same
belt-and-suspenders reasoning OX-S5's own narrative already applies to the
challenge id. `security-outbox-otp-cipher.service.ts` also carries the
`otp_key_version` column's key-version check, rejecting any version other
than the one currently configured before attempting decryption at all —
key rotation itself stays out of scope, unchanged from the catalog's own
call. `foundation/crypto/secret-material-cipher.ts` gained `sealParts`/
`openParts` underneath (nonce and ciphertext returned/accepted as separate
buffers, matching `otp_ciphertext`/`otp_nonce`) without changing `seal`/
`open`'s behavior for TOTP, which now delegate to the new pair.

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: seal under the security-outbox key with the message id and purpose as AAD context, then open with the same context | Round-trips to the original code | `src/modules/identity/infrastructure/security-outbox/security-outbox-otp-cipher.service.spec.ts` › `round-trips a sealed OTP back to the original bytes` |
| Two seals of the same OTP | Different nonce each time | `src/modules/identity/infrastructure/security-outbox/security-outbox-otp-cipher.service.spec.ts` › `produces a different nonce for two seals of the same OTP` |
| `open()` called with a ciphertext copied onto a different message id | Throws (authentication-tag mismatch); never returns a wrong-but-plausible code | `src/modules/identity/infrastructure/security-outbox/security-outbox-otp-cipher.service.spec.ts` › `fails to open when the ciphertext is copied onto a different message id` |
| `open()` called with a ciphertext copied onto a different purpose | Throws, same as the message id case | `src/modules/identity/infrastructure/security-outbox/security-outbox-otp-cipher.service.spec.ts` › `fails to open when the ciphertext is copied onto a different purpose` |
| `open()` called against a tampered ciphertext byte | Throws | `src/modules/identity/infrastructure/security-outbox/security-outbox-otp-cipher.service.spec.ts` › `fails to open with a tampered ciphertext byte` |
| `open()` called with a key different from the one used to seal | Throws | `src/modules/identity/infrastructure/security-outbox/security-outbox-otp-cipher.service.spec.ts` › `fails to open with the wrong encryption key` |
| `open()` called with a key version other than the one currently configured | Throws a distinct, clearly-named error before any decryption attempt | `src/modules/identity/infrastructure/security-outbox/security-outbox-otp-cipher.service.spec.ts` › `fails to open with an unsupported key version, rejected before any decryption attempt` |
| Any decryption failure | Never includes the plaintext OTP or the configured key in its message | `src/modules/identity/infrastructure/security-outbox/security-outbox-otp-cipher.service.spec.ts` › `never includes the plaintext OTP in a decryption failure message`; › `never includes the configured encryption key in a decryption failure message` |

### OX-S6 — Delete OTP ciphertext immediately after the worker sends it

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: provider accepts the send | `publication_state='published'` and the ciphertext row deleted, in one transaction | `database/tests/contracts/security-outbox-contracts.ts` › `OX-S6/OX-W6 published outcome empties the ciphertext and stamps published_at` |
| Provider call succeeds but the process crashes before recording the attempt | Ciphertext not deleted; lease eventually expires and another worker resends the same code — an accepted at-least-once duplicate | — |
| Crash between the delete and the caller observing success | Cannot happen partially; delete and state flip are the same transaction | — |

### OX-S7 — Delete unsent OTP ciphertext when its challenge expires

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: sweep finds an expired-challenge message still `pending`/`leased` | Dead-lettered (`reason='challenge_expired'`), ciphertext deleted | `database/tests/contracts/security-outbox-contracts.ts` › `OX-S7 the expiry sweep dead-letters an expired, never-claimed message` |
| Sweep runs while a worker actively holds the message's lease | Worker's in-flight send wins; sweep only claims rows no worker is touching | `database/tests/contracts/security-outbox-contracts.ts` › `OX-S7 the expiry sweep leaves a live-leased message for the worker holding it to finish` |
| Challenge expires, sweep has not run yet | Message sits `pending` with a stale, decryptable ciphertext until the next tick — the maximum window is not bounded by any owner decision | — |

### OX-S8 — Resend a security verification code

The supersede key is `purpose` + `destination_kind` + `destination_snapshot`
(normalized the same way `identity.login_identifiers.normalized_value` is),
never `challenge_id`: a resend always opens a **new** challenge (proven by
`identity-registration.repository.integration-spec.ts` ›
`gives a second claim its own code without touching the first`, which leaves
both the old and the new challenge live with different ids), so anchoring on
`challenge_id` can never find the older message to cancel. Supersede only
ever touches a `pending` row; a `leased` row is being sent right now and is
never rewritten by a newer enqueue.

**The `sign_up` exception (revision 40).** `sign_up` messages are never
superseded, by either supersede path (the enqueue-time `UPDATE` and the
retryable-error-yields-to-a-newer-resend check in
`record_security_outbox_attempt`), and the partial unique index
(`security_outbox_messages_one_pending_per_purpose_destination_uq`) excludes
`purpose = 'sign_up'` so more than one `pending` row can coexist for the same
destination. Confirming a `sign_up` challenge requires the registrant's own
candidate password (`VerifyIdentifierUseCase.claimsThatAreTheirs`), so an
attacker's code is never usable against the victim's challenge — but before
this revision, an attacker registering a victim's address would supersede
and dead-letter the victim's own still-undelivered code, denying the victim
their registration outright (a resend-triggered denial of service). The
other three purposes keep superseding unchanged: `add_sign_in_method` and
`step_up` are only ever requested by the identity's own authenticated
caller, and any `password_recovery` code works for the account regardless of
who requested it, so superseding there never locks anyone out.

**The `invitation` exception (revision 63).** An invitation is a fifth purpose
(`D27`, J1-1). Like `sign_up`, it is never superseded, by either supersede
path, and the partial unique index excludes it: the key is `purpose` +
destination, and two different organizations may legitimately invite the same
address, so a newer invitation message from organization B must never
dead-letter the pending one from organization A. A message of this purpose
names an invitation instead of a challenge (`security_outbox_messages_source_chk`),
the claim and the purge read its expiry from the invitation, and staff cannot
reissue it. Since revision 64 (J1-1b) the claim and the attempt also read the
invitation: a message of a revoked, accepted or expired invitation is never
leased and never requeued, and the staff dead-letter list includes invitation
messages. The four existing purposes keep the supersede behaviour they already had.

| Case | Expected answer | Proving test |
|---|---|---|
| Resend requested while the previous message for the same `purpose`+destination is still `pending` (a different challenge — resends never reuse one; `add_sign_in_method`/`password_recovery`/`step_up` only, never `sign_up`) | Superseded: the older message is marked `dead_lettered` (reason `superseded_by_resend`) before the new one is inserted; only the newest is ever sent (OB7) | `database/tests/contracts/security-outbox-contracts.ts` › `OB7 a resend for the same purpose and destination supersedes the older pending message even though it belongs to a different challenge` |
| A second `sign_up` claim for the same destination arrives while the first registrant's message is still `pending` (a different registrant, a different challenge) | Never superseded: both messages stay `pending`, so each registrant's own code keeps its own row and neither ever reaches the other's candidate password (the sign\_up exception) | `database/tests/contracts/security-outbox-contracts.ts` › `OB7 a sign-up for an address with a still-pending challenge never supersedes it, so the first registrant still gets their own code` |
| Same `purpose`, a different destination | Both messages stay `pending`; supersede never crosses destinations | `database/tests/contracts/security-outbox-contracts.ts` › `OB7 same purpose but a different destination leaves both messages pending` |
| Resend requested after the previous message already reached `published` | Fresh enqueue, no ambiguity | — |
| An invitation message from organization B arrives while organization A's invitation message to the same address is still `pending` | Never superseded: A's message stays `pending` with its ciphertext, and two `pending` invitation messages to one address coexist | `database/tests/contracts/invitation-delivery-contracts.ts` › `J1-1 organization B's invitation message does not supersede A's pending one`; `database/tests/contracts/invitation-delivery-contracts.ts` › `J1-1 two pending invitation messages to one address coexist at the table level` |
| An in-flight invitation message fails with a retryable error while another organization's invitation message to the same address is pending | Returns to `pending` with its failure class, never dead-lettered as `superseded_by_resend` | `database/tests/contracts/invitation-delivery-contracts.ts` › `J1-1 a retryable failure of A's message is not a supersede by B's pending message` |
| The claim meets an invitation message whose invitation was revoked, accepted or has expired (pending, or leased with its lease expired) | Dead-lettered as `invitation_revoked`, `invitation_accepted` or `invitation_expired` with the ciphertext wiped and never leased, so no new delivery attempt exists; an open invitation's message is leased as before | `database/tests/contracts/invitation-delivery-review-closed-contracts.ts` › `J1-1b the claim dead-letters and wipes the message of an invitation that is ${message.kind} instead of leasing it`; `database/tests/contracts/invitation-delivery-review-closed-contracts.ts` › `J1-1b a revoked invitation whose lease expired is dead-lettered by the next claim, never delivered again`; `database/tests/contracts/invitation-delivery-review-closed-contracts.ts` › `J1-1b the claim still leases the message of an open invitation` |
| An in-flight invitation message fails with a retryable error after its invitation was revoked or expired | Fails closed: `dead_lettered` with the invitation's failure class and the ciphertext erased, never `pending`. A message the worker already handed to the provider cannot be recalled (the revoked invitation's token is refused at acceptance); what is guaranteed is that no new attempt is offered and no ciphertext survives | `database/tests/contracts/invitation-delivery-review-closed-contracts.ts` › `J1-1b a retryable error on an invitation that is ${how} dead-letters and wipes instead of returning to pending`; `database/tests/contracts/invitation-delivery-review-closed-contracts.ts` › `J1-1b no new delivery attempt is offered after an invitation that is ${how} failed` |
| An in-flight invitation message is reported `published` or `failed` after its invitation was revoked | Recorded as reported: `published` stays `published` and wiped, an explicit failure keeps the worker's class | `database/tests/contracts/invitation-delivery-review-closed-contracts.ts` › `J1-1b a message already handed to the provider stays published and wiped after the revocation`; `database/tests/contracts/invitation-delivery-review-closed-contracts.ts` › `J1-1b an explicit failure keeps the class the worker reported` |
| The purge meets the pending message of an accepted invitation | Dead-lettered as `invitation_accepted`, ciphertext erased | `database/tests/contracts/invitation-delivery-review-closed-contracts.ts` › `J1-1b the purge retires and wipes the message of an accepted invitation` |
| The claim returns an invitation message | Two columns are appended after the existing ones, `organization_name` and `invitation_id`, `NULL` for a challenge message; the worker's explicit column list still reads it | `database/tests/contracts/invitation-delivery-review-closed-contracts.ts` › `J1-1b the claim returns the organization name and the invitation id of an invitation message`; `database/tests/contracts/invitation-delivery-review-closed-contracts.ts` › `J1-1b the claim returns no organization name and no invitation id for a challenge message`; `database/tests/contracts/invitation-delivery-review-closed-contracts.ts` › `J1-1b the existing claim columns keep their order and the new ones come last`; `database/tests/contracts/invitation-delivery-review-closed-contracts.ts` › `J1-1b the worker claim query, which names its columns, still reads the extended claim` |
| Staff lists an invitation dead letter | Listed with its failure class, masked destination and the invitation expiry in the unchanged shape, without the address, token or ciphertext | `database/tests/contracts/invitation-delivery-review-closed-contracts.ts` › `J1-1b the staff dead-letter listing shows an invitation dead letter with its class and the invitation expiry`; `database/tests/contracts/invitation-delivery-review-closed-contracts.ts` › `J1-1b the staff listing keeps its shape and never carries the address, the token or the ciphertext`; `src/modules/identity/application/security-outbox-operations/security-outbox-operations.invitation.integration-spec.ts` › `lists an invitation dead letter in the same shape as any other, masked, without a token, a ciphertext or the address`; `test/e2e/platform-admin-security-outbox-invitation.e2e-spec.ts` › `lists an invitation dead letter with a 200, the invitation purpose and no secret` |
| The worker sends an invitation message (J1-W) | The sealed token is opened with the message id and the `invitation` purpose as context, the message is built from the organization name in the claim, the expiry and a link `<INVITATION_LINK_BASE_URL>/join#token=<token>` (the token in the fragment, never in the path or the query), sent by email with a subject or by WhatsApp without one, then recorded `published` with the ciphertext erased by `identity.record_security_outbox_attempt`; the opened plaintext is zeroed once the send settles. The sender is faked in every test | `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.integration-spec.ts` › `sends the exact link with the token in the fragment, publishes the message and erases the ciphertext`; `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.use-case.spec.ts` › `opens the sealed token bound to the message id and the invitation purpose`; `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.use-case.spec.ts` › `sends the organization name, the link with the token in the fragment and the expiry, then publishes`; `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.use-case.spec.ts` › `sends the same invitation to a phone number as a subject-less text`; `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.use-case.spec.ts` › `zeroes the opened token once the send settles`; `src/modules/identity/domain/security-outbox/security-outbox-invitation-copy.spec.ts` › `carries the token in the URL fragment and never in the path or the query, so no server or proxy log sees it` |
| The token reaches no log, record call or failure class (J1-W) | Not on success, not when the provider error echoes the whole message or the link, not in a malformed-row log | `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.use-case.spec.ts` › `never puts the token or the link in a log line, a record call or a failure class when the provider error echoes them`; `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.use-case.spec.ts` › `never logs the token on the happy path either`; `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.integration-spec.ts` › `never logs the token even when the provider error echoes the whole message, and keeps the message for a retry`; `src/modules/identity/infrastructure/security-outbox/security-outbox-dispatch.repository.invitation.spec.ts` › `logs a malformed invitation row without its organization name, destination, ciphertext or nonce` |
| The worker meets an invitation that was revoked before the claim, or revoked while its message is leased (J1-W) | Nothing is sent for the first; the message is dead-lettered as `invitation_revoked` with the ciphertext erased. For the second, a failed send is dead-lettered by the SQL attempt record instead of returning to `pending`. An invitation that expired while the message waited is dead-lettered by the worker as `invitation_expired` without a send; a claim that returns no rows sends nothing | `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.integration-spec.ts` › `never sends the message of an invitation revoked before the claim, and dead-letters it with the ciphertext erased`; `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.integration-spec.ts` › `fails closed when the invitation is revoked while its message is leased: a failed send is dead-lettered, never requeued`; `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.use-case.spec.ts` › `dead-letters an invitation that expired while it waited, as invitation_expired, without sending anything`; `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.use-case.spec.ts` › `returns an empty tally and sends nothing when the claim returns no rows` |
| The worker has no invitation link base URL (J1-W) | Optional in every environment: the worker boots in `production` and `staging` without `INVITATION_LINK_BASE_URL` and logs one startup warning naming the variable (no value). Without it, an invitation message is not opened and not sent: it is reported as a retryable failure with the class `invitation_link_unconfigured` (SQL accepts any non-empty class), retries with the normal bounded backoff and is dead-lettered after the last attempt; the four OTP purposes are unaffected. Development and test fall back to `http://localhost:3000` | `src/foundation/config/schema/invitation-link-base-url.schema.spec.ts` › `lets the worker boot in %s without it, so OTP delivery never depends on an invitation setting`; `src/foundation/config/app-config.service.spec.ts` › `reports no invitation link base URL in %s when none is set, never a localhost one`; `src/foundation/config/app-config.service.spec.ts` › `falls back to the local machine for the invitation link base URL in %s when none is set`; `src/modules/identity/presentation/jobs/security-outbox-dispatch.job.invitation-link.spec.ts` › `logs exactly one warning that names the variable and carries no value when it is not configured`; `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.use-case.spec.ts` › `neither opens the token nor sends, and reports a retryable failure with its own class and the normal backoff`; `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.use-case.spec.ts` › `dead-letters the message once the bounded attempts are exhausted`; `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.use-case.spec.ts` › `leaves every OTP purpose completely unaffected`; `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.use-case.spec.ts` › `still sends an invitation once the variable is configured`; `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.integration-spec.ts` › `without the base URL sends nothing, opens nothing, keeps the sealed token and retries with its own failure class` |
| The claim returns a row the worker cannot map as an invitation or a challenge message (J1-W) | A row with neither a challenge nor an invitation, an invitation without its organization name, or a row mixing both is dead-lettered as `malformed_claim_row` and never reaches a sender; an unknown purpose still is | `src/modules/identity/infrastructure/security-outbox/security-outbox-dispatch.repository.invitation.spec.ts` › `dead-letters as malformed a row that $what, and never lets it reach a sender`; `src/modules/identity/infrastructure/security-outbox/security-outbox-dispatch.repository.invitation.spec.ts` › `still dead-letters an unknown purpose as malformed`; `src/modules/identity/infrastructure/security-outbox/security-outbox-dispatch.repository.invitation.spec.ts` › `maps a challenge row with a null organization name and a null invitation id unchanged` |
| Happy path (application layer): `add_sign_in_method`/`password_recovery`/`step_up` resend by simply calling the same use case again for a still-pending challenge/claim | The older pending message is superseded; only the newest is ever decryptable | `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)`; `src/modules/identity/application/password-recoveries/request-password-recovery.use-case.integration-spec.ts › supersedes the older pending message when the same address requests recovery again (OX-S8)`; `src/modules/identity/application/step-ups/open-step-up.use-case.integration-spec.ts › supersedes the older pending message when the same identity opens a second step-up (OX-S8)` |
| Happy path (application layer): a different registrant claims `sign_up` again for the same still-unconfirmed address | Never superseded — both codes stay pending and decryptable, and the first registrant still confirms with their own code and their own password | `src/modules/identity/application/registration/register-identity.use-case.integration-spec.ts › never supersedes the first registrant pending code when a different registrant claims the same unconfirmed address (OX-S8)` |
| A resend arrives while the previous message is `leased` (a worker is sending it right now) | Never superseded — the leased row is left untouched, the new message is enqueued `pending` alongside it, and the worker can still record the leased one as `published` afterward. Both codes can reach the recipient in this window; accepted as the same at-least-once trade-off OX-S6/OB9 already name | `database/tests/contracts/security-outbox-contracts.ts` › `OB7 a leased message in flight is not superseded by a newer enqueue; it stays leased alongside the new pending one`; › `the worker can still record the in-flight message as sent after the resend` |
| The in-flight message fails with a retryable error after a newer resend is already pending (`add_sign_in_method`/`password_recovery`/`step_up` only, never `sign_up`) | Retired as `dead_lettered` with `superseded_by_resend` and its ciphertext erased; it never returns to `pending`, so the partial unique index is never violated and only the newest code can go out | `database/tests/contracts/security-outbox-contracts.ts` › `OB7 a retryable failure of an in-flight message that a newer resend already replaced retires it instead of queueing a second code` |
| An in-flight `sign_up` message fails with a retryable error while a different registrant's message is pending for the same destination | Never treated as superseded: it requeues to `pending` with the next retry time like any ordinary retryable failure, because the other pending row belongs to a different registrant, not a resend of this one | `database/tests/contracts/security-outbox-contracts.ts` › `a sign-up retryable failure requeues even while a different registrant has a pending message for the same address, never mistaking it for a supersede` |

### OX-S9 — API response contract once delivery is asynchronous

| Case | Expected answer | Proving test |
|---|---|---|
| Sign-up response, once enqueueing replaces the synchronous provider call | `challengeDelivery` is removed outright; the response reports `{ challengeExpiresAt }` only, no compatibility field (OB8) | `test/e2e/sign-up.e2e-spec.ts › registers an email identity and answers 201 inside the envelope, accepted with only an expiry`; `src/modules/identity/presentation/controllers/sign-ups.controller.spec.ts › answers accepted with the identity and an ISO expiry, and nothing about delivery` |
| Add-sign-in-method response | Unaffected — it never reported synchronous delivery status | `test/e2e/sign-in-methods.e2e-spec.ts › adds a phone to an email account and confirms it, listing every way in` |
| Password-recovery and step-up responses | Unaffected — both already answered `{ challengeExpiresAt }` only, before OX-S1 touched anything | `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`; `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` |

***

## Worker (OB1/OB6)

### OX-W1 — Claim a batch of due outbox rows with `SKIP LOCKED` and a lease

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: due messages exist in both the identity outbox and one or more organizations' tenant outbox | Claimed with a fresh lease in both, same shape as `events.claim_outbox_batch` | — |
| Two worker instances (or two ticks) call claim concurrently | Disjoint claimed sets; neither blocks the other | `database/tests/concurrency/security-outbox-concurrency.ts` › `concurrentClaimersNeverLeaseTheSameRow` (`NINAKU_SECURITY_OUTBOX_CONCURRENCY_OK`) |
| Empty backlog | Zero rows claimed, not an error | — |

**The worker never throttles a destination (removed after review, revision
42 — not named in OB1–OB11 or `OUTBOX_USE_CASES.md`, so not counted in
[Totals](#totals)).** Revision 41 had given `identity.claim_security_outbox_messages`
a per-destination send cap (`p_send_cap_per_destination`/`p_send_cap_window_seconds`,
defaults 5 per hour), meant to bound a `sign_up` flood against one address
now that `sign_up` is never superseded (OB7, revision 40). Review found it
made the flood worse, not better: it counted only already-`published` rows
(never `leased` ones or rows the same claim was about to pick), was
evaluated once before the whole batch was chosen — so one claim could still
lease every `pending` `sign_up` row for a capped destination in a single
call — and its 3600s window vastly outlives a challenge's own lifetime of
minutes, so a message the cap deferred could never be sent before its
challenge expired and the expiry sweep (OX-S7) dead-lettered it. An
attacker filling the cap with five throwaway sign-ups could permanently
block the victim's own code from ever being sent — the exact denial of
service OB7 exists to prevent, reopened from another angle. The cap was
removed in migration 0033 rather than patched: any cap the worker enforces
on itself can be exhausted by whoever controls how many messages exist for
a destination, which is exactly the attacker. The worker now claims every
due row for a destination without exception; flood protection belongs at
the HTTP edge (rate limiting per IP and per destination), tracked as a
separate follow-up, not built here.

| Case | Expected answer | Proving test |
|---|---|---|
| Several `pending` `sign_up` messages exist for one destination, even after messages already `published` to it would have reached the old cap | All claimable in the same call; the worker never defers or drops one for its destination alone | `database/tests/contracts/security-outbox-contracts.ts` › `the worker never throttles a destination: even after 5 messages already published to it (the old cap), several more pending messages for the same destination are all still claimable` |

### OX-W2 — Worker crashes mid-send; the lease expires and the message is reclaimed

| Case | Expected answer | Proving test |
|---|---|---|
| Worker A claims, crashes before recording; lease expires | Worker B's next claim reclaims the same row, `attempt_count` unchanged from A's claim | `database/tests/contracts/security-outbox-contracts.ts` › `OX-W2 an expired lease is reclaimed` |
| Worker A recovers after B already reclaimed and is mid-send | A's stale lease token no longer matches; a late recording attempt from A is refused | — |
| Lease duration shorter than a genuinely slow (not crashed) provider call | Message reclaimed and sent twice even though A was still alive — bounded by choosing `lease_seconds` well above `SEND_TIMEOUT_MS` | — |

### OX-W3 — Two workers never send the same message twice under a live lease

| Case | Expected answer | Proving test |
|---|---|---|
| Worker holds a live, unexpired lease and records a result | Succeeds | `database/tests/contracts/security-outbox-contracts.ts` › `OX-S6/OX-W6 published outcome empties the ciphertext and stamps published_at` |
| Worker's lease expired moments before it records a result, no one else has claimed it yet | Refused ("lease expired before result recording"), even though nobody raced it | — |
| A second attempt to record a terminal outcome for an already-`published`/`dead_lettered` message | Refused — the row is no longer `leased` | `database/tests/contracts/security-outbox-contracts.ts` › `OX-W3 a second attempt result for an already-published message is refused`; › `OX-W3 a dead-lettered message cannot be recorded again` |

### OX-W4 — Retry with backoff and honor the provider's `Retry-After`

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: retryable failure, no `Retry-After` from the provider | `available_at` set per OB4's schedule for that attempt number (5 s / 30 s / 2 min / 10 min / 30 min) | — |
| Retryable failure, provider returns a `Retry-After` | That value overrides OB4's own schedule for that attempt | — |
| Provider's `Retry-After` is in the past or malformed | Falls back to OB4's own schedule; a non-future `retry_after_at` is never accepted | — |
| Attempt 5 fails retryably | Dead-lettered (OX-W6), never a 6th scheduled retry | — |

### OX-W5 — Classify provider failures into a retry decision

| Case | Expected answer | Proving test |
|---|---|---|
| `THROTTLED` | Retryable, honoring `Retry-After` if given | — |
| `UNAVAILABLE` | Retryable | — |
| `INDETERMINATE` | Retryable like `THROTTLED`/`UNAVAILABLE`; at-least-once delivery accepts the duplicate-send risk over the strictly worse alternative of locking the person out (OB9), bounded by OB4's 5-attempt cap and OX-W7's challenge-expiry check | — |
| `REJECTED` | Not retryable — dead-lettered immediately, even on attempt 1 | — |
| `REJECTED` reaching attempt 1, never entering the retry loop | Dead-lettered without ever computing a `retry_after_at` | — |

### OX-W6 — Exhausted retries dead-letter the message

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: attempt 5 fails (retryable or not) | Terminal `dead_lettered`, one dead-letter row inserted | — |
| `REJECTED` on attempt 1 | Reaches the same terminal state immediately, skipping the retry loop | `database/tests/contracts/security-outbox-contracts.ts` › `OX-W6 a permanent failure dead-letters the message and empties the ciphertext` |
| A worker bug retries past attempt 5 | Not caught by any table constraint — `attempt_count` past 5 is not itself refused by the schema; only worker-code correctness prevents it | — |
| The worker's own code throws unexpectedly while handling one claimed message (a sender or OTP-opener bug, anything that is not a provider failure) | Recorded through the same retry path as a provider failure: `attempt_count` +1, `available_at` per OB4's schedule, `last_failure_class='unexpected_worker_error'`; the rest of the batch is still processed, and the message no longer cycles through lease expiry without counting | `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.unexpected-failure.use-case.spec.ts › records an unexpected send exception as a retryable attempt on the first backoff step and keeps going with the batch`; `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.unexpected-failure.use-case.spec.ts › follows the documented backoff for every attempt number instead of a fixed delay`; `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.unexpected-failure.use-case.spec.ts › treats an OTP opener that throws as an unexpected worker failure and never sends`; `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.unexpected-failure.integration-spec.ts › counts every unexpected failure as an attempt with the documented backoff and dead-letters on the fifth, erasing the ciphertext, while a healthy message of the same batch is published` |
| That unexpected failure happens on attempt 5 | Dead-lettered with the same class and the ciphertext erased, exactly as a provider failure on attempt 5; a poison message cannot retry forever | `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.unexpected-failure.use-case.spec.ts › dead-letters an unexpected exception on the fifth attempt with the same safe class and no retry time`; `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.unexpected-failure.integration-spec.ts › counts every unexpected failure as an attempt with the documented backoff and dead-letters on the fifth, erasing the ciphertext, while a healthy message of the same batch is published` |
| The failure text and the log of an unexpected failure | The stored class is the fixed code, never the exception text; the log carries the message id, the purpose and the error name and code, never the exception text, OTP, ciphertext, nonce or destination; the recovered OTP is wiped from memory | `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.unexpected-failure.use-case.spec.ts › stores only the fixed class, never the exception text, the OTP or the destination`; `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.unexpected-failure.use-case.spec.ts › logs the unexpected failure with the message id but never the exception text, the ciphertext, the nonce, the OTP or the destination`; `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.unexpected-failure.use-case.spec.ts › wipes the recovered OTP from memory even when the send throws` |
| Recording that unexpected failure itself fails (database unavailable), or recording a message that was already sent fails | Logged without secrets and the batch continues; lease expiry stays the fallback; a message already sent is never turned into a second attempt that would resend the code | `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.unexpected-failure.use-case.spec.ts › logs and carries on when recording the unexpected failure itself fails, leaving the lease expiry as the fallback`; `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.unexpected-failure.use-case.spec.ts › does not turn a failed recording of a sent message into a second attempt that would resend the code` |
| A provider failure after this change | Unchanged: its own class and its own backoff | `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.unexpected-failure.integration-spec.ts › keeps a provider failure on its own class and its first backoff step` |

### OX-W7 — An OTP message's own expiry stops its retries

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: claimed message's challenge already expired before the send attempt | Send skipped; dead-lettered with `error_code='challenge_expired'`, distinct from a provider failure | — |
| Challenge expires in the window between the expiry check and the provider call | Code sent for an already-useless challenge; accepted as a narrow, harmless race | — |
| Challenge already consumed (person entered the code correctly) before the worker sends | Worker still attempts to send — "cancel on early success" is a named, unresolved gap, not designed by OB1–OB6 | — |
| Database-level backstop: `record_security_outbox_attempt` is asked to record a `retryable_error` for a message whose challenge already expired (the race between the worker's own pre-send check and the actual provider call, or a worker that skips the pre-send check entirely) | Converted to `failed`/`challenge_expired` in the same call, never requeued with a scheduled `available_at` for a challenge nobody can complete any more | `database/tests/contracts/security-outbox-contracts.ts` › `OB4/OX-W7 a retryable outcome for an already-expired challenge is dead-lettered, not requeued` |

### OX-W8 — OTP ciphertext decryption failure

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: `open()` throws (corrupted envelope or context mismatch) | Immediately dead-lettered (`error_code='decryption_failed'`), retry loop skipped entirely | — |
| Logged severity | Reaches security monitoring, not only ordinary operational logs | — |
| Every message under a since-rotated-away key | Spike of `decryption_failed` dead letters — the intended signal of an out-of-step key rotation (rotation itself is out of scope) | — |
| A single corrupted ciphertext row (storage bit-rot, manual data-fix mistake) | Dead-lettered alone; does not affect any other message's decryption | — |

### OX-W9 — Worker role least privilege

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: `ninaku_worker` calls a claim/record function within its granted set | Succeeds | `database/tests/contracts/security-outbox-contracts.ts` › `OX-W1 ninaku_worker claims a due batch` |
| `ninaku_worker` attempts `SELECT` on `identity.password_credentials`/`totp_methods`/`recovery_codes` | Refused — no grant exists | `database/tests/contracts/security-outbox-contracts.ts` › `OX-W9 ninaku_worker cannot select password credentials`; › `OX-W9 ninaku_worker cannot select TOTP methods`; › `OX-W9 ninaku_worker cannot select recovery codes` |
| `ninaku_worker`'s Railway credential for the separate worker service | Requires explicit owner approval at deploy time, per AGENTS.md §10; this catalog does not itself authorize provisioning it | — |
| G4: `ninaku_worker` calls `purge_expired_security_outbox_ciphertexts`/`purge_security_outbox_retention` (the OX-S7/OX-O3 sweeps), and, even with the retention sweep's own `app.security_outbox_retention_sweep` escape hatch set, still cannot raw-`DELETE` the table directly | Both sweep calls succeed within the granted set; a raw `DELETE` is refused, `42501`, before it ever reaches the guard trigger, because `ninaku_worker` has no direct table privilege at all | `database/tests/contracts/security-outbox-contracts.ts` › `OX-S7 the expiry sweep dead-letters an expired, never-claimed message`; › `OB11/OX-O3 retention deletes only published rows past 30 days and dead letters past 90 days`; › `ninaku_worker has no direct table privilege, so it cannot raw-DELETE either` |

### OX-W10 — The API role never dispatches

| Case | Expected answer | Proving test |
|---|---|---|
| `ninaku_runtime` attempts `claim_security_outbox_messages`/`events.claim_outbox_batch` directly | Refused, `42501`, before touching any row | `database/tests/contracts/security-outbox-contracts.ts` › `OX-W10 ninaku_runtime cannot claim a batch`; › `OX-W10 ninaku_runtime cannot record an attempt outcome` (`events.claim_outbox_batch` itself unproven — no tenant-outbox producer exists yet) |
| `ninaku_runtime` attempts a raw `SELECT`/`DELETE` on `security_outbox_ciphertexts` | Refused — it only ever receives `INSERT` | `database/tests/contracts/security-outbox-contracts.ts` › `OX-W10 ninaku_runtime cannot select the security outbox table` (this catalog's storage shape folds the ciphertext into `security_outbox_messages` itself, so the same refusal covers the whole row, not a separate ciphertext table) |
| G4: `ninaku_runtime` attempts `purge_expired_security_outbox_ciphertexts`/`purge_security_outbox_retention` directly | Refused, `42501` — neither sweep function is ever granted to `ninaku_runtime` | `database/tests/contracts/security-outbox-contracts.ts` › `OX-W9 ninaku_runtime cannot run the expiry sweep`; › `OX-W9 ninaku_runtime cannot run the retention sweep` |

***

## Operability

### OX-O1 — Observe backlog and dead letters

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: counts per publication state for the security outbox | One row per state (`pending`, `leased`, `published`, `dead_lettered`), each equal to the table's own count | `database/tests/contracts/security-outbox-backlog-contracts.ts` › `OX-O1 the backlog reports one row per publication state`; › `OX-O1 the reported pending count equals the table's`; `src/modules/identity/application/security-outbox-operations/security-outbox-operations.integration-spec.ts` › `reports every publication state, counting a seeded dead letter`; `test/e2e/platform-admin-security-outbox.e2e-spec.ts` › `shows staff one row per state and the oldest pending age` |
| Oldest pending message age | Seconds the oldest already-due pending message has waited; a retry scheduled in the future never counts | `database/tests/contracts/security-outbox-backlog-contracts.ts` › `OX-O1 the oldest pending age counts only messages already due` |
| No messages exist | Zero for every state and a null age, not an error | `database/tests/contracts/security-outbox-backlog-contracts.ts` › `OX-O1 an empty outbox reports zero for every state and no oldest age` |
| Dead-letter list shows what staff need and nothing secret | Purpose, destination kind, masked destination, attempt count, failure class, dead-lettered-at; never the address, ciphertext, nonce or code | `database/tests/contracts/security-outbox-backlog-contracts.ts` › `OX-O1 a listed dead letter shows purpose, kind, masked destination, attempts and class`; › `OX-O1 the full address never appears in the listing`; › `OX-O1 the listing has no ciphertext, nonce, key version or code column`; `src/modules/identity/application/security-outbox-operations/security-outbox-operations.integration-spec.ts` › `lists a dead letter with a masked destination and never its address`; `test/e2e/platform-admin-security-outbox.e2e-spec.ts` › `lists a dead letter masked, pages by keyset and never leaks the address or a secret` |
| Destination masking | Defined once in `identity.mask_security_outbox_destination`: `j***@e***.com` for email, `***67` for phone, `***` for anything malformed | `database/tests/contracts/security-outbox-backlog-contracts.ts` › `OX-O1 masking ${kind} ${expected}`; › `OX-O1 masking email x***@l***`; › `OX-O1 masking email ***`; › `OX-O1 masking phone ***67` |
| Dead letters page newest first by keyset in both directions | `after`/`before` cursors with `pageInfo`, no `OFFSET`; no dead letter repeated or skipped | `database/tests/contracts/security-outbox-backlog-contracts.ts` › `OX-O1 the first page returns the newest dead letters first`; › `OX-O1 the page after a position returns only older dead letters`; › `OX-O1 the page before a position returns the newer ones`; `src/modules/identity/application/security-outbox-operations/security-outbox-operations.integration-spec.ts` › `pages dead letters by keyset without repeating or skipping one`; `test/e2e/platform-admin-security-outbox.e2e-spec.ts` › `rejects both cursors at once and a tampered cursor` |
| A caller with no staff grant (signed in or not), a revoked staff grant, or no actor at all | Refused with `42501` at the database and `platform.staff_required` (403) at the route; an anonymous caller gets 401 | `database/tests/contracts/security-outbox-backlog-contracts.ts` › `OX-O1 a runtime session with no staff grant cannot read the backlog`; › `OX-O1 a runtime session with no staff grant cannot list dead letters`; › `OX-O1 a runtime session with no actor at all cannot read the backlog`; › `OX-O1 a revoked staff grant cannot read the backlog`; `src/modules/identity/application/security-outbox-operations/security-outbox-operations.integration-spec.ts` › `refuses the backlog and the dead-letter list to an identity with no staff grant`; `test/e2e/platform-admin-security-outbox.e2e-spec.ts` › `refuses the backlog and the dead-letter list to a signed-in non-staff caller`; › `refuses an anonymous caller` |
| `ninaku_worker` attempts either read | Refused, `42501` — neither function is granted to it | `database/tests/contracts/security-outbox-backlog-contracts.ts` › `OX-O1 ninaku_worker cannot execute the backlog read`; › `OX-O1 ninaku_worker cannot execute the dead-letter list` |
| Backlog of the tenant outbox (`events.outbox_records`) | Same shape, tenant-scoped; no producer or runtime grant exists yet, so it is built with the first business event | — |

### OX-O2 — Reissue a dead-lettered message

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: staff reissue a dead letter that failed delivery, challenge still open | A new one-time code is minted for the same challenge, the challenge's code hash is replaced so the old code stops working, and one new pending message carries the new code sealed; the terminal dead letter is never resurrected | `database/tests/contracts/security-outbox-reissue-contracts.ts` › `OX-O2 rule 4: staff reissues a dead-lettered delivery failure`; › `OX-O2 rule 4: the challenge secret hash is replaced so the old code stops working`; › `OX-O2 rule 4: a new pending message carries the sealed code for the same challenge, purpose and destination`; › `OX-O2 rule 4: the terminal dead letter is never resurrected`; `src/modules/identity/application/security-outbox-operations/reissue-security-outbox-message.use-case.spec.ts` › `mints one new code, hashes it for the challenge and seals the same code bound to the new message and purpose`; `src/modules/identity/application/security-outbox-operations/security-outbox-operations.integration-spec.ts` › `queues exactly one new sealed message for the same challenge when staff reissue a rejected dead letter`; `test/e2e/platform-admin-security-outbox-reissue.e2e-spec.ts` › `gives the person a new working code, kills the old one, keeps their password and audits the staff member` |
| The plaintext code is never returned, logged, audited or persisted outside its sealed envelope | The response carries only the new message id; the repository receives only the hash and the sealed envelope | `src/modules/identity/application/security-outbox-operations/reissue-security-outbox-message.use-case.spec.ts` › `never hands the plaintext code to the repository`; `test/e2e/platform-admin-security-outbox-reissue.e2e-spec.ts` › `gives the person a new working code, kills the old one, keeps their password and audits the staff member` |
| Rule 1: only a `dead_lettered` message that failed delivery | `superseded_by_resend`, `challenge_expired`, a pending or published message and an unknown id are each refused, nothing hashed or enqueued | `database/tests/contracts/security-outbox-reissue-contracts.ts` › `OX-O2 rule 1: a ${failureClass} dead letter is refused`; › `OX-O2 rule 1: a challenge_expired dead letter is refused`; › `OX-O2 rule 1: a pending message is refused`; › `OX-O2 rule 1: a published message is refused`; › `OX-O2 rule 1: an unknown message id is refused`; `src/modules/identity/application/security-outbox-operations/reissue-security-outbox-message.use-case.spec.ts` › `mints and hashes nothing when the reissue is refused`; `src/modules/identity/application/security-outbox-operations/security-outbox-operations.integration-spec.ts` › `refuses a superseded dead letter with its own reason, and a second reissue once a newer message is live`; › `reports an unknown message and refuses an identity with no staff grant`; `test/e2e/platform-admin-security-outbox-reissue.e2e-spec.ts` › `refuses a message that was superseded rather than failed, and one that is not a dead letter`; › `answers 404 for a message that does not exist` |
| A refused reissue is audited | The refused attempt leaves one Audit Record with the same action code `platform.security_outbox_message.reissued`, `outcome = denied`, `reason_code` equal to the refusal (`not_a_delivery_failure`, `challenge_not_open`, ...), the staff member as actor and the message as target; the refusal rolls the command back, so the record is written in a second transaction by `PlatformRefusalAuditInterceptor` | `test/e2e/platform-admin-security-outbox-reissue.e2e-spec.ts` › `refuses a message that was superseded rather than failed, and one that is not a dead letter`; › `refuses once the person has already confirmed, because the challenge is gone`; `src/modules/platform/presentation/audit/platform-refusal-audit.interceptor.spec.ts` |
| Rule 2: the challenge is expired, consumed, revoked or out of attempts | Refused; a code for a challenge nobody can complete is pointless | `database/tests/contracts/security-outbox-reissue-contracts.ts` › `OX-O2 rule 2: an expired challenge is refused`; › `OX-O2 rule 2: a consumed challenge is refused`; › `OX-O2 rule 2: a revoked challenge is refused`; › `OX-O2 rule 2: an exhausted challenge is refused`; › `OX-O2 rule 2: completing against an expired challenge is refused too`; `test/e2e/platform-admin-security-outbox-reissue.e2e-spec.ts` › `refuses once the person has already confirmed, because the challenge is gone` |
| Rule 3: a newer pending, leased or published message already exists for the same purpose and destination | Refused, because the person already has or is about to get a code; a newer dead letter does not block | `database/tests/contracts/security-outbox-reissue-contracts.ts` › `OX-O2 rule 3: a newer ${state} message for the same purpose and destination blocks the reissue`; › `OX-O2 rule 3: a newer leased message for the same purpose and destination blocks the reissue`; › `OX-O2 rule 3: a newer published message for the same purpose and destination blocks the reissue`; › `OX-O2 rule 3: a newer dead letter does not block the reissue`; › `OX-O2 rule 3: a second reissue of the same message is refused because a newer live message now exists` |
| Rule 5: a sign-up challenge keeps its candidate password hash; a purpose with no open challenge cannot be reissued | The candidate hash is byte-for-byte unchanged and the person still confirms with their own password; a closed challenge is refused (rule 2) | `database/tests/contracts/security-outbox-reissue-contracts.ts` › `OX-O2 rule 5: a sign-up candidate password hash is untouched`; `test/e2e/platform-admin-security-outbox-reissue.e2e-spec.ts` › `gives the person a new working code, kills the old one, keeps their password and audits the staff member` |
| Rule 6: idempotency and concurrency | The same `Idempotency-Key` returns the stored result and enqueues one message; the same key for another dead letter is refused; two concurrent reissues of one dead letter enqueue at most one; the header is required | `test/e2e/platform-admin-security-outbox-reissue.e2e-spec.ts` › `replays the same Idempotency-Key with the same result and exactly one new message`; › `enqueues at most one message when the same dead letter is reissued twice at once`; › `requires an Idempotency-Key`; `src/modules/platform/application/security-outbox/reissue-security-outbox-message.use-case.spec.ts` › `replays the same Idempotency-Key with the stored result, one message and one audit record`; › `refuses the same key reused for a different dead letter without reissuing`; `database/tests/contracts/security-outbox-reissue-contracts.ts` › `OX-O2 rule 6: exactly one new message exists after the repeated attempts`; › `OX-O2 rule 6: two concurrent reissues of the same message enqueue at most one` |
| Rule 7: who may reissue | Only an active `platform.staff_members` row with a fresh step-up (OB10); a signed-in non-staff caller, the affected person themselves, a staff member with no elevation and `ninaku_worker` are refused with the shape the other staff commands use | `database/tests/contracts/security-outbox-reissue-contracts.ts` › `OX-O2 a runtime session with no staff grant cannot ${label} a reissue`; › `OX-O2 the affected identity itself cannot open a reissue`; › `OX-O2 ninaku_worker cannot open a reissue`; › `OX-O2 a runtime session with no staff grant cannot complete a reissue`; › `OX-O2 the affected identity itself cannot complete a reissue`; › `OX-O2 ninaku_worker cannot complete a reissue`; › `OX-O2 a refused reissue changed nothing`; `test/e2e/platform-admin-security-outbox-reissue.e2e-spec.ts` › `refuses a staff member with no fresh elevation`; › `refuses a signed-in non-staff caller, even fully elevated, and the affected person themselves` |
| Staff reissue an invitation dead letter (J1-W) | Refused with the staff refusal `invitation_message` (`422 · platform.security_outbox_reissue_refused`): the SQL `23514 · security_outbox_reissue_invitation_message` is mapped by constraint name; nothing is queued and the denial is audited | `src/modules/identity/application/security-outbox-operations/security-outbox-operations.invitation.integration-spec.ts` › `refuses staff the reissue of an invitation dead letter with the invitation reason and queues nothing`; `test/e2e/platform-admin-security-outbox-invitation.e2e-spec.ts` › `refuses the reissue of an invitation dead letter with a clean 422, audits the denial and queues nothing` |
| Audit | Exactly one platform audit record per executed reissue naming the staff member and the dead letter; a refusal or a replay writes none | `src/modules/platform/application/security-outbox/reissue-security-outbox-message.use-case.spec.ts` › `reissues once and writes one audit record naming the staff member and the dead letter`; › `lets a refused reissue reach the caller and audits nothing`; `test/e2e/platform-admin-security-outbox-reissue.e2e-spec.ts` › `gives the person a new working code, kills the old one, keeps their password and audits the staff member` |
| Nobody but the reissue path can rewrite a challenge's code hash | The challenge trigger still refuses `secret_hash` changes for every role, including a role that may update challenges and a session that sets the reissue flag itself | `database/tests/contracts/security-outbox-reissue-contracts.ts` › `OX-O2 ninaku_runtime cannot rewrite a challenge secret directly`; › `OX-O2 ninaku_runtime cannot rewrite it even after setting the reissue flag itself`; › `OX-O2 a role that can update challenges still cannot rewrite the secret`; › `OX-O2 the reissue flag alone never lets any other role rewrite the secret`; › `OX-O2 the challenge secret is unchanged after the refused rewrites` |

### OX-O3 — Bounded retention of sent outbox rows

| Case | Expected answer | Proving test |
|---|---|---|
| `published` rows (and their attempt history) older than 30 days | Deleted by the scheduled sweep (OB11) | `database/tests/contracts/security-outbox-contracts.ts` › `OB11/OX-O3 retention deletes only published rows past 30 days and dead letters past 90 days` |
| `dead_lettered` rows (and their attempt/dead-letter history) older than 90 days | Deleted by the scheduled sweep (OB11) | `database/tests/contracts/security-outbox-contracts.ts` › `OB11/OX-O3 retention deletes only published rows past 30 days and dead letters past 90 days` |

***

## Business events / notifications (OB5 — catalogued, implemented later)

### OX-E1 — Append a tenant business domain event atomically with its business transaction

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: a future module calls `events.append_domain_event` inside its own transaction | Domain event and exactly one outbox row committed together | — |
| Idempotency-key replay with changed semantics | Refused — already proven by the function's own comparison logic, unexercised for lack of a producer | — |
| Insert against a retired event type | Refused — already enforced by `validate_domain_event_insert`, unexercised for lack of a producer | — |

### OX-E2 — A consumer processes a business domain event exactly-once

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: claim → process → record, per consumer | Duplicate transport never becomes a duplicate canonical effect, per the existing per-`(organization_id, consumer_id, domain_event_id)` uniqueness | — |
| Contract version outside the consumer's subscribed range | Refused — already enforced, unexercised for lack of a consumer | — |
| Inactive consumer attempts to claim | Refused — already enforced, unexercised for lack of a consumer | — |

### OX-E3 — Notify a customer of a business event through the notifications catalog

| Case | Expected answer | Proving test |
|---|---|---|
| Two simultaneous delivery attempts for the same `delivery_id` | **Out of scope for this iteration** — `notifications.delivery_attempts` has no lease/lock columns at all; deferred to whichever module first produces `notifications.intents`, which must add the same `SKIP LOCKED`+lease treatment `events.outbox_records` already has | — |
| A module inserts a domain event under another module's event type | **Out of scope for this iteration** — `validate_domain_event_insert` never compares `producer_module_code` against `event_types.owner_module_code`; deferred to the first real producer (OX-E1) | — |
| A delivery receipt is recorded | **Out of scope for this iteration** — no trigger updates `notifications.deliveries.status`/`delivered_at` from a `delivery_receipts` insert; deferred to whichever module first consumes receipts | — |
| An out-of-order or gapped `attempt_number` is inserted | **Out of scope for this iteration** — `notifications.delivery_attempts` only has a bare uniqueness constraint, not a sequence-enforcing trigger like `events.publication_attempts`; deferred to the same future producer | — |

***

## Cross-cutting invariants

| Invariant | Expected answer | Proving test |
|---|---|---|
| Publication order is best-effort, never guaranteed | A retried message can be sent after a later message that never needed a retry; accepted, since none of the four Identity security flows have a cross-message ordering requirement | — |
| "Published"/"sent" means provider-accepted, not confirmed end-user delivery | `outcome='published'` records provider custody, not recipient receipt; a stronger fact needs a delivery receipt, unconsumed by this catalog's scope (OX-E3) | — |
| Retries are capped by worker-side policy, not a database constraint | OB4's 5-attempt cap is enforced by the worker counting `attempt_count`; no `CHECK` on `events.outbox_records`/the identity outbox stops a worker bug from retrying past it | — |
| The security outbox never carries `organization_id` and never joins into `events.*` | Structural — `identity.security_outbox_messages` has no `organization_id` column and no foreign key into any tenant table | — |
| `ninaku_runtime` can never claim or record an outbox outcome | Refused, `42501`, for every claim/record function named in OX-W9/OX-W10, even if request-handling code accidentally called one | `database/tests/contracts/security-outbox-contracts.ts` › `OX-W10 ninaku_runtime cannot claim a batch`; › `OX-W10 ninaku_runtime cannot record an attempt outcome` (proven for the identity outbox's own functions; `events.claim_outbox_batch`/`record_publication_attempt` unproven, no tenant-outbox producer exists yet) |

***

## Gaps

Grouped by family, each line names the lowest layer that could prove it
honestly once the corresponding code exists; none of these rows are blocked
on a product decision any more, since OB7–OB11 settled the five that were.
G1 (this revision) shipped the identity security outbox's storage shape,
claim/lease, attempt recording, and least-privilege split; the lines below
reflect what G1 proved at the database layer and what still needs the
application code (G2 encryption, G3 the four Identity flows, G4 the worker
process) that G1 does not touch.

* **OX-S1–OX-S4 (enqueue atomicity)** — G3 wired the enqueue call into all four Identity flows, inside the same transaction as the challenge write; the happy path, the atomic-rollback-on-enqueue-failure guarantee, and (for OX-S1/OX-S8) the resend-supersede behavior are now proven per flow, both at the unit level (fake ports) and against real PostgreSQL (`*.use-case.integration-spec.ts`, extending each flow's own file). Password recovery's anti-enumeration property is proven separately (OX-S3's own row): an enqueue failure for a real identifier is hidden behind the same answer and logged at error level, while a failure that does not depend on the account's existence is reported, so the response never differs between a real and an unknown identifier.
* **OX-S5 (encrypted custody)** — storage shape (ciphertext/nonce/key-version columns, emptied only on a terminal state) and the cipher wrapper's own round-trip/binding/key-version behavior are both now proven; G3 wired `SecurityOutboxOtpCipherPort.seal()` into all four enqueue use cases (proven end to end: the outbox row's ciphertext decrypts back to the exact code that verifies the challenge). The worker's own `open()` call (OX-W1/OX-W8) is still G4's job.
* **OX-S6 (delete after send)** — the same-transaction delete-and-flip is now proven (`OX-S6/OX-W6 published outcome empties the ciphertext and stamps published_at`); the crash-before-recording row still needs a fault-injection test, not just a happy-path one.
* **OX-S7 (delete on expiry)** — G4 shipped `identity.purge_expired_security_outbox_ciphertexts`, proven both for the happy path and for never touching a live lease (single-session); still open: a dedicated two-connection concurrency test for the sweep-versus-active-lease race, the same pattern `paymentVersusGraceExpiryRace` already uses for Platform.
* **OX-S8 (resend)** — the supersede key is now `purpose`+`destination_kind`+`destination_snapshot` (never `challenge_id`, since a resend always opens a new challenge) and scoped to `pending` only (a `leased` row is left alone); all three are proven single-session at the database layer, and G3 additionally proves the application-layer happy path for `add_sign_in_method`/`password_recovery`/`step_up` (calling the same use case again for a still-pending challenge/claim supersedes the older message end to end, against real PostgreSQL). Revision 40 fixed a real regression: `sign_up` is never superseded, at either supersede path (enqueue-time and the retryable-error requeue check) and in the partial unique index, because superseding it let an attacker registering a victim's address deny the victim their own still-undelivered code — confirmation for `sign_up` requires the registrant's own password, so an attacker's code can never stand in for the victim's, unlike the other three purposes. Proven at both layers: `security-outbox-contracts.ts` (two `sign_up` messages for the same destination from different challenges both stay `pending`; a `sign_up` retryable failure is never mistaken for a supersede) and `register-identity.use-case.integration-spec.ts` (a second registrant never supersedes the first, who still confirms with their own code and password). Still open: the resend-after-`published` row, and a dedicated two-connection concurrency test for the enqueue-versus-claim race on the same `pending` row (the single-session tests above prove the leased-row case deterministically, not the exact-simultaneous race).
* **OX-S9 (response contract)** — done for all four routes: `challengeDelivery` is removed outright, `RegistrationOutcome` (and the other three outcomes, unaffected since they never carried a delivery field) report only `{ challengeExpiresAt }` (OB8), proven by unit, e2e and the OpenAPI contract.
* **OX-W1–OX-W3 (claim/lease/no-double-send)** — the concurrent-claim race is now proven by `database/tests/concurrency/security-outbox-concurrency.ts`, and the expired-lease-reclaim/stale-lease-recording-refused rows are proven by `security-outbox-contracts.ts`; the empty-backlog row and the stale-token-after-reclaim narrative are still open, and the tenant-outbox (`events.claim_outbox_batch`) side of OX-W1's happy path has no producer yet to exercise it against.
* **OX-W4/OX-W5 (retry/backoff/classification)** — the `retryable_error` write path (state back to `pending`, `available_at` advanced, ciphertext untouched) is now proven; the worker-side classification mapping and OB4's actual 5-attempt schedule (unit, including OB9's `INDETERMINATE`-retries-like-`THROTTLED`/`UNAVAILABLE` rule) are G4's job, still open.
* **OX-W6 (dead letter)** — the `REJECTED`-style immediate dead-letter path is now proven (`OX-W6 a permanent failure dead-letters the message and empties the ciphertext`); the attempt-5-exhaustion row and the worker-bug-retries-past-5 row are still open (the latter is a worker-code unit test, not a database-layer one). The unexpected-worker-exception rows (a worker exception is recorded as a failed attempt with the `unexpected_worker_error` class, backs off, dead-letters on attempt 5, and recording that failure is fail-open) are proven at unit and real-PostgreSQL integration layers, with no database change: the class column accepts any non-blank text.
* **OX-W7 (OTP expiry stops retries)** — the worker's own pre-send skip is still G4 application-layer work (open); the database-level backstop inside `record_security_outbox_attempt` (converting an already-expired-challenge retry into `failed`/`challenge_expired` instead of requeuing it) is now proven. The cancel-on-early-success gap has no test until it has a design.
* **OX-W8 (decryption failure)** — unit (the `open()` failure path) + integration (the dead-letter write); the security-monitoring-log-level row is an observability assertion, not a database one.
* **OX-W9/OX-W10 (least privilege)** — proven against real PostgreSQL by `database/tests/contracts/security-outbox-contracts.ts` for every function/table this group ships, including G4's two new sweep functions (worker-only EXECUTE, barred for `ninaku_runtime`, and a raw `DELETE` still refused even with the retention flag set), the same pattern `database/tests/prepare/prepare-database-contracts.ts` already uses for `platform.provision_organization`'s own bar (also mirrored there as `WORKER_PRIVILEGE_EXPECTATIONS`, extended for the same two functions); the Railway-credential-approval row remains a deploy-time, non-database concern.
* **OX-O1 (observe backlog)** — done for the security outbox: two staff-gated reads (`GET /platform-admin/security-outbox/backlog` and `/dead-letters`) over `SECURITY DEFINER` functions, proven at the database contract, repository integration and e2e layers. Still open: the tenant outbox's own backlog, which waits for its first producer.
* **OX-O2 (reissue)** — done: the owner replaced the impossible "replay" (a dead letter's ciphertext is erased and the challenge keeps only an argon2 hash) with a reissue of a new code for the same open challenge, proven at the database contract (including the concurrent double reissue), use-case unit, repository integration and e2e layers.
* **OX-O3 (retention)** — G4 shipped `identity.purge_security_outbox_retention` and proved both thresholds (30-day `published`, 90-day `dead_lettered`) at the database layer; the scheduled job itself (mirroring `SubscriptionLifecycleSweepJob`'s own test pattern) is application-layer work, still open.
* **OX-E1/OX-E2 (business events)** — already provable at the database layer today (the underlying functions are unchanged), but no test exists because no producer/consumer calls them yet; the first real module to do so inherits `database/tests/prepare/prepare-database-contracts.ts`-style coverage as a prerequisite, not an afterthought.
* **OX-E3 (notifications)** — every row is out of scope for this iteration by design; each needs its own schema change (a lease, an ownership check, a receipt-advances-delivery trigger, a sequence check) before it can even be tested, let alone proven.
* **Cross-cutting invariants** — `ninaku_runtime` can never claim or record for the identity outbox's own functions is now proven; its `events.claim_outbox_batch`/`record_publication_attempt` half and the remaining 4 rows still need integration. The ordering and "published-means-accepted" rows are documentation of an accepted property rather than something a passing test can ever fail, and should be asserted as an explicit non-guarantee (e.g., a test that deliberately reorders delivery and asserts no code depends on order) rather than left purely narrative.
