---
url: /reference/OUTBOX_USE_CASES.md
---
# Outbox Use Cases

Every external message (email, WhatsApp) and every durable cross-module
consequence must leave the HTTP request boundary: the request commits one
outbox row in the same transaction as the business change it reports, and a
separate worker delivers it with retries. This catalog starts from what a
real actor needs — an identity finishing sign-up, a worker draining a queue,
an operator investigating a stuck message — and derives the minimal schema
and API from that, never from a table list. Every case names the invariant
it protects, its main path, and its adversarial/edge cases. The
proving-test matrix for these cases lives in
[`OUTBOX_CASE_MATRIX.md`](OUTBOX_CASE_MATRIX.md); nothing here is
implemented yet, so read this catalog as frozen intent, not shipped
behavior — exactly the same posture `ORGANIZATION_USE_CASES.md` and
`SUBSCRIPTION_PLANS_USE_CASES.md` take for their own unshipped catalogs.

**Owner decisions this catalog builds against** (`odd/tasks/outbox-module.md`,
2026-09-25, OB1–OB11; fixed, not reopened here):

| # | Decision |
|---|---|
| OB1 | The queue lives in PostgreSQL; a separate Railway service (same codebase, its own entrypoint) dispatches it. No Redis/BullMQ. |
| OB2 | Identity's four security-message flows (sign-up code, add sign-in method, password recovery, step-up) get an **Identity-owned outbox without `organization_id`**. `events.*` stays tenant-scoped, untouched. One worker dispatches both. |
| OB3 | The OTP travels AES-GCM encrypted, under its own key, separate from the TOTP key. The worker decrypts, sends, and deletes the ciphertext immediately; an unsent ciphertext is deleted when its challenge expires. |
| OB4 (default) | Up to 5 attempts at 5 s, 30 s, 2 min, 10 min, 30 min; honor the provider's `Retry-After`; an OTP message stops retrying once its own challenge expires; exhaustion → dead letter. |
| OB5 (default) | First delivery scope is the 4 Identity security flows. Business events/notifications are catalogued here, implemented once a producing module exists. |
| OB6 (default) | A dedicated role, `ninaku_worker`, holds only claim/record privileges; `ninaku_runtime` (the API role) never dispatches. Railway credentials for it need owner approval at deploy time. |
| OB7 | A resend re-enqueues for the same challenge id; a newer message for the same challenge purpose cancels (`dead_lettered`, reason `superseded_by_resend`) any older message still unsent for it, guarding against an active worker's in-flight send the same way OX-S7 guards its own sweep-versus-lease race. Only the newest message is ever sent. |
| OB8 | The response for the four security-message flows drops `delivered`/`resendable` outright; it reports acceptance and `challengeExpiresAt` only. No compatibility shim, because no client consumes the old shape today. |
| OB9 | `INDETERMINATE` provider outcomes are retried like `THROTTLED`/`UNAVAILABLE`: delivery is at-least-once, so a duplicate send is a harmless duplicate code, while dead-lettering on the first ambiguous outcome risks locking the person out of a code that may have actually arrived. Still bounded by OB4's 5-attempt schedule and OX-W7's own challenge-expiry check. |
| OB10 | Reissuing a dead-lettered message (OX-O2) is restricted to the platform super admin (`platform.staff_members`) with a fresh step-up, the same gating SP16/SP17/SP19's staff commands already use; every reissue is audited. A dead letter cannot be replayed as written, so the command mints a new code for the same open challenge. |
| OB11 | `published` rows are retained 30 days and `dead_lettered` rows 90 days, then deleted by a bounded scheduled sweep, mirroring `SubscriptionLifecycleSweepJob`'s own advisory-lock discipline. |
| OB12 | An invitation (D27) is a fifth purpose of the same outbox. Its message names the invitation instead of a challenge, is never superseded (two organizations may invite one address), takes its expiry from the invitation in the claim and the purge, is dead-lettered with `invitation_revoked` when its invitation is revoked or reissued, and cannot be reissued by staff. Its enqueue is the SECURITY DEFINER `identity.enqueue_invitation_message`, which requires `organization.account.manage` organization-wide and applies the D29 limits. Since J1-1b (revision 64) the claim never leases, and the attempt never requeues, the message of a revoked, accepted or expired invitation (it is dead-lettered with the ciphertext wiped; a message already handed to the provider cannot be recalled, its token is dead at acceptance), and the claim returns `organization_name` and `invitation_id` after its existing columns. Since J1-W the worker delivers it: the claimed row is an invitation message (`invitationId` and `organizationName`, no challenge) validated by the row mapper, the token is opened with the outbox cipher at send time and only to build the link `<INVITATION_LINK_BASE_URL>/join#token=<token>`, and the staff listing and the staff refusal of a reissue (`invitation_message`) handle the purpose without throwing. `INVITATION_LINK_BASE_URL` is optional: without it in `staging` or `production` the worker logs one startup warning and reports each invitation message as a retryable failure `invitation_link_unconfigured` without opening its token, so OTP delivery never depends on it. Since J1-2 the producer is `POST /invitations`: in the same transaction as the invitation row, `SecurityOutboxInvitationDeliveryService` (the adapter of the application port `InvitationDeliveryPort`) seals the token with the outbox cipher (AAD `<messageId>:invitation`), calls `identity.enqueue_invitation_message` and maps its refusals by constraint name (pending limit, organization and per-address rate limits) to Problem Details, and wipes the plaintext buffer. Revoking an expired open invitation calls the port's `withdraw` (`identity.withdraw_invitation_message`). |

**Ground truth for every case below** comes from
`database/canonical/0360_events.sql` (the tenant outbox already shipped:
`domain_events`, `outbox_records` with `claim_outbox_batch`'s
`FOR UPDATE SKIP LOCKED` + lease, `record_publication_attempt`, dead
letters, consumer delivery/checkpoint machinery — proven, unused
infrastructure), `database/canonical/0330_notifications.sql` (the
provider-neutral notification/delivery/receipt catalog, also unused),
`database/access/runtime_grants.sql` (confirms `ninaku_runtime` has **no
grant at all** on `events.*` or `notifications.*` — not even
`GRANT USAGE ON SCHEMA`), `src/foundation/messaging/**` (`SesConnector`,
`WasenderConnector`, `MessageDispatcherService`, and the shared failure
taxonomy `MESSAGE_DELIVERY_FAILURE = { REJECTED, THROTTLED, UNAVAILABLE,
INDETERMINATE }`), `src/foundation/crypto/secret-material-cipher.ts`
(the existing AES-256-GCM `seal`/`open` pair, parameterized by a configured
key and bound to an AAD `context` string — the wrapper OX-S5 reuses under a
new, separate key), the four Identity direct-send call sites
(`register-identity.use-case.ts`, `add-sign-in-method.use-case.ts`,
`request-password-recovery.use-case.ts`, `open-step-up.use-case.ts`),
`src/modules/platform/presentation/jobs/subscription-lifecycle-sweep.job.ts`
(the `@nestjs/schedule` + advisory-lock template a future dispatcher job
would copy), and `odd/reviews/CODEX_REVIEW_2026-09-24.md` ("Segunda
revisión de Codex"), whose eight open design items are each resolved below
as a use case, an invariant, or an explicit out-of-scope line with a
reason — never silently dropped.

**Decision-row legend.** Same as `ORGANIZATION_USE_CASES.md`: `HTTP?` —
public route, internal contract, or none (background/worker-only). `Command/
Read?` — state-changing command vs. a read model. `Module` — the owning
NestJS module. `DB objects` — canonical or proposed schema objects touched;
a name in *italics* below does not exist yet and is this catalog's own
proposal, exactly like `SUBSCRIPTION_PLANS_USE_CASES.md` SP13's
`platform.staff_members` proposal. `Missing grants` — whether
`ninaku_runtime`/`ninaku_worker` can reach those objects today.

**Proposed schema (frozen intent, not built).** OB2 rules out reusing
`events.outbox_records` for Identity's pre-tenant security messages
(`organization_id NOT NULL`), so this catalog proposes a second,
Identity-owned outbox that copies the proven shape of `events.*` almost
verbatim, minus the tenant column:

* *`identity.security_outbox_messages`* — one row per queued security
  message: `id`, `login_identifier_id`, `challenge_id`, `delivery_channel`,
  `destination_snapshot`, `publication_state` (`pending`/`leased`/
  `published`/`dead_lettered`, same shape as `evt_outbox_lease_shape_chk`),
  lease columns, `attempt_count`, `available_at`, `published_at`.
* *`identity.security_outbox_ciphertexts`* — one row per message, `1:1`,
  holding the AES-GCM envelope (`seal()`'s output) and nothing else, so
  deleting it (OX-S6/OX-S7) never touches the message's own audit trail.
* *`identity.security_outbox_attempts`* — mirrors `events.publication_attempts`:
  `attempt_no`, `transport_code`, `outcome`, `error_code`, `retry_after_at`,
  sequence-checked against the message's own `attempt_count` exactly like
  `events.validate_publication_attempt_insert` does.
* *`identity.security_outbox_dead_letters`* — mirrors `events.publication_dead_letters`.
* Functions: *`identity.enqueue_security_outbox_message(...)`*,
  *`identity.claim_security_outbox_messages(...)`* (same `FOR UPDATE SKIP
  LOCKED` + lease shape as `events.claim_outbox_batch`),
  *`identity.record_security_outbox_attempt(...)`* (same lease/attempt-count
  contract as `events.record_publication_attempt`, plus ciphertext
  deletion on a terminal outcome), and
  *`identity.purge_expired_security_outbox_ciphertexts()`* for OX-S7.

***

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

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

**Actor:** an unauthenticated person submitting `POST /sign-ups`.
**Intent:** the registration challenge and the message that will carry its
code either both exist or neither does — never a challenge with no way to
ever notify its owner, and never a message with no challenge behind it.
**Preconditions:** the identifier (email/phone) is reachable by some
channel (`ChallengeNotifierPort.canNotify`, unchanged).
**Invariants protected:** today `RegisterIdentityUseCase.execute` commits
the challenge first (`claimRegistration`), then calls `deliverChallenge`,
which awaits `challengeNotifier.notify(...)` **outside** that transaction —
already the correct commit-before-send order, but "send" today means a
real, synchronous SES/Wasender call. This case keeps the same order
(challenge row and outbox row committed together) but replaces the
synchronous call with one `INSERT` into
*`identity.security_outbox_messages`* + *`...ciphertexts`* inside the same
`SystemTransactionRunnerService.run` call that already writes the
challenge — so a rollback of one rolls back the other, the same guarantee
`events.assert_domain_event_has_outbox()`'s deferred trigger gives the
tenant outbox, applied here without an equivalent trigger because Identity
owns both tables directly and can enforce it in one function.
**Main path:**

1. Caller submits an identifier; `createVerificationCode`/`createUuidV7`
   build the challenge and its code exactly as today.
2. Inside one transaction: insert the identity/challenge rows (unchanged),
   AES-GCM-seal the code under the security-outbox key with the challenge
   id as AAD context, and enqueue one *`security_outbox_messages`* row
   (`publication_state='pending'`).
3. Commit. The response returns immediately — see OX-S9 for what it now
   says about delivery.
   **Adversarial/edge cases:**

* The enqueue `INSERT` fails (constraint violation, disk full) — the whole
  transaction rolls back; no orphaned challenge with nothing to notify it.
* Retrying the same registration submission before the first challenge
  expires — governed by the existing "reuse a pending challenge" repository
  logic (unchanged by this catalog, and by the PR #401 fix that stops it
  reusing a challenge held by another identity); this case only changes
  *how* the code reaches the person, never *whether* a resend reuses the
  challenge — see OX-S8.
  **Decision:**

| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Yes — `POST /sign-ups` (existing route) | Command | — | Identity | `identity.identities`, `identity.login_identifiers`, `identity.authentication_challenges` (existing); *`identity.security_outbox_messages`*, *`...ciphertexts`* (proposed) | The proposed tables/functions do not exist; `ninaku_runtime` needs `EXECUTE` on *`enqueue_security_outbox_message`* only, never `SELECT`/`UPDATE` on the outbox tables themselves |

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

**Actor:** a signed-in identity calling `POST /sign-in-methods`.
**Intent:** same atomicity guarantee as OX-S1, for a caller who already has
a session.
**Invariants protected:** today `AddSignInMethodUseCase.execute` commits the
challenge (`claimAddition`) first, then calls `deliver`, and on a delivery
failure it runs a **second transaction** (`abandonAddition`) to undo the
claim and rethrows. That compensating-transaction pattern exists only
because sending is synchronous and can fail after the challenge is already
committed. Once enqueueing (not sending) happens inside the same
transaction as `claimAddition`, an enqueue failure rolls back the challenge
for free — `abandonAddition` and its dedicated failure-logging path
(`ABANDON_FAILED_EVENT`) become dead code this case retires.
**Main path:** identical shape to OX-S1, folded into the same transaction
as `claimAddition`; the separate `abandon()` compensating call is removed.
**Adversarial/edge cases:**

* A concurrent second "add sign-in method" request for the same caller
  before the first challenge is confirmed — governed by the existing
  sign-in-method-addition claim uniqueness (unchanged); this case does not
  touch that invariant, only how the code is delivered.
* Enqueue failure — rolls back the whole transaction; no orphaned challenge,
  no compensating transaction needed, no `ABANDON_FAILED_EVENT` log line to
  ever emit again.
  **Decision:**

| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Yes — `POST /sign-in-methods` (existing route) | Command | — | Identity | `identity.login_identifiers`, `identity.authentication_challenges`; *`identity.security_outbox_messages`*, *`...ciphertexts`* | Same as OX-S1 |

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

**Actor:** anyone submitting `POST /password-recoveries`, whether or not
the identifier belongs to a real account.
**Intent:** queue the code when (and only when) a real challenge was
opened, without ever letting the caller distinguish "identifier exists" from
"identifier doesn't exist" through response shape or timing.
**Invariants protected:** Codex's item #5 (`request-password-recovery.
use-case.ts:93`) is explicitly a design decision, not a bug: the code is
sent in the background and the API responds before delivery resolves, so
timing never reveals existence. Today "background" means
`void this.recoveryNotifier.notify(...).catch(...)` — a real, unawaited
network call still fired from inside the request process. This case keeps
the *response* behavior identical (always the same shape, same latency
regardless of match) but replaces the fire-and-forget network call with a
fire-and-forget **enqueue**, which is synchronous, local, and fast enough
that the response never needs to wait for it — the anti-enumeration
property survives unchanged, and the coordinator's own review already
credits the outbox with resolving delivery here "without changing this"
(`odd/reviews/CODEX_REVIEW_2026-09-24.md`, item #5's note).
**Main path:**

1. `RequestPasswordRecoveryUseCase.execute` calls `openChallenge` — unchanged.
2. If (and only if) `opened` is true (a real identifier), the same
   transaction (or an immediately-following one, since enqueueing is local
   and cheap either way) inserts the outbox row; if `opened` is false,
   nothing is enqueued.
3. The response returns `{ challengeExpiresAt }` unconditionally, exactly as
   today, whether or not anything was enqueued.
   **Adversarial/edge cases:**

* Unknown identifier — `opened=false`, nothing enqueued, response identical
  in shape and timing to the known-identifier case; this is the whole
  point and must be proven with a timing-insensitive equality test, not
  just a shape comparison.
* Enqueue failure for a real identifier — must **not** surface as a
  different response than the unknown-identifier case; the failure is
  hidden behind the same `201` and logged at `error` level with only the
  error name and code. The same holds for a failure of the challenge insert,
  which runs after the account lookup has answered, and for a commit
  failure of a transaction that wrote a challenge.
* A failure that does not depend on whether the account exists (the
  transaction cannot begin, the account lookup throws, or a transaction that
  found no account fails to commit) is reported through the ordinary
  foundation mapping: `503` with `Retry-After` for a transient database
  failure, `500` otherwise, identical for a registered and an unregistered
  address.
  **Decision:**

| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Yes — `POST /password-recoveries` (existing route) | Command | — | Identity | `identity.login_identifiers`, `identity.authentication_challenges`; *`identity.security_outbox_messages`*, *`...ciphertexts`* | Same as OX-S1 |

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

**Actor:** a signed-in identity calling `POST /step-ups`.
**Intent:** stop sending a code for a challenge that might never exist.
**Invariants protected:** **this is the bug OB2 exists to fix.**
`OpenStepUpUseCase.execute` today calls `this.stepUpNotifier.notify(...)`
**before** `this.transactionRunner.run(...)` opens the challenge — the
reviewer's #6 defended this as intentional ("if the database write fails,
the user requests another code; the alternative leaves a stored code that
never arrived"), but that reasoning assumed the only failure mode was a
send failure, not a *database* failure after a successful send: today, if
the code is sent and then the `INSERT` fails, the person has a working code
for a challenge that was never persisted, and no way to confirm it. Once
enqueueing happens *inside* the same transaction that opens the challenge,
that failure mode disappears the same way it does for OX-S1/S2/S3 — the
enqueue and the challenge either both commit or neither does, and nothing
is sent until they do.
**Main path:**

1. `firstNotifiableCandidate` resolves the reachable identifier — unchanged.
2. One transaction: open the challenge (unchanged shape) **and** enqueue the
   outbox row, together.
3. Commit. Nothing is sent synchronously; the worker sends it.
   **Adversarial/edge cases:**

* No notifiable candidate at all — refused before any transaction opens
  (`StepUpUnreachableError`/`ChallengeChannelUnavailableError`), unchanged.
* Database failure after the code was generated but before commit — now
  refused cleanly; nothing was sent, so there is no orphaned live code
  anywhere outside the database, closing the exact gap the reviewer's
  original defense of the old ordering did not consider.
  **Decision:**

| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Yes — `POST /step-ups` (existing route) | Command | — | Identity | `identity.login_identifiers`, `identity.authentication_challenges`; *`identity.security_outbox_messages`*, *`...ciphertexts`* | Same as OX-S1 |

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

**Actor:** none directly — a property the four enqueue cases above and the
worker (OX-W1+) both depend on.
**Intent:** the plaintext code exists only as long as strictly necessary:
in memory while it is generated and hashed for the challenge, then only as
an AES-GCM ciphertext at rest, never as plaintext in any table.
**Preconditions:** a security-outbox encryption key, distinct from the TOTP
secret key (`identity/infrastructure/second-factors/secret-material-cipher.
service.ts`'s key), configured the same way (`AppConfigService`, redacted
from logs per `src/foundation/logging/redaction/known-secrets.ts`).
**Invariants protected:** OB3, verbatim. This reuses
`src/foundation/crypto/secret-material-cipher.ts`'s existing `seal`/`open`
pair — already AES-256-GCM, already HKDF-derived per call, already
AAD-bound — under a **new key**, with the **challenge id** as the AAD
`context` string. Binding the AAD to the challenge id is load-bearing, not
decorative: it means a ciphertext produced for one challenge can never be
decrypted "successfully" under a different challenge's context, so a bug
that mixed up two messages' ciphertext rows fails closed (`open()` throws)
instead of silently delivering the wrong code to the wrong recipient.
**Main path:** each OX-S1–S4 enqueue calls `seal(securityOutboxKey,
codeBuffer, challengeId)` and stores only the returned envelope string in
*`security_outbox_ciphertexts`*; the worker (OX-W1) calls `open(...)` with
the same challenge id immediately before sending, then deletes the row
(OX-S6).
**Adversarial/edge cases:**

* A ciphertext row is read with the wrong challenge id as context (a
  programming error, not an attacker) — `open()` throws
  (`GCM_AUTH_TAG` mismatch), never returns a wrong-but-plausible code.
* The security-outbox key rotates — this catalog does not design key
  rotation; `seal`/`open`'s `v1.` envelope prefix leaves room for a future
  key-version scheme identical to how `secret-material-cipher.ts` would
  need to evolve for the same reason, but rotation itself is out of scope
  here (no owner decision requires it yet).
  **Decision:**

| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| No — a property of OX-S1–S4 and OX-W1 | Neither — a shared invariant | — | Identity (`foundation/crypto` wrapper reused, not modified) | *`identity.security_outbox_ciphertexts`* | Table does not exist; `ninaku_runtime` gets `INSERT` only (it writes the ciphertext, never reads it back); `ninaku_worker` gets `SELECT`+`DELETE` |

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

**Actor:** the worker (OX-W1+).
**Intent:** minimize the window during which a readable-once-decrypted
secret sits in the database at all.
**Invariants protected:** OB3's second half, verbatim — "the worker
decrypts, sends, and deletes the ciphertext immediately." This is folded
into *`identity.record_security_outbox_attempt`*'s `outcome='published'`
branch, the same transaction that flips `security_outbox_messages.
publication_state` to `published`, exactly mirroring how
`events.record_publication_attempt`'s `published` branch is one atomic
`UPDATE` — except here the same transaction also runs
`DELETE FROM identity.security_outbox_ciphertexts WHERE message_id = ...`.
**Main path:** worker claims (OX-W1) → decrypts → calls
`MessageDispatcherService.send(...)` → on success, calls
*`record_security_outbox_attempt(outcome='published', ...)`*, which marks
the message published **and** deletes its ciphertext row, in one
transaction.
**Adversarial/edge cases:**

* The provider call succeeds but the process crashes before calling
  *`record_security_outbox_attempt`* — the lease eventually expires (OX-W2)
  and another worker reclaims the message, decrypts the **same** code
  again (the ciphertext was never deleted, because the recording
  transaction never ran) and sends it a second time. This is an accepted
  at-least-once duplicate-send, identical in kind to the tenant outbox's
  own at-least-once contract (`0360_events.sql`'s header: "Publication is
  at-least-once; consumers must be idempotent") — a duplicate OTP SMS/email
  is a UX nuisance, not a security gap, since the code is still single-use
  at the challenge layer.
* A crash *after* the `DELETE` but before the caller observes success —
  irrelevant; the delete and the state flip are the same transaction, so
  this cannot happen partially.
  **Decision:**

| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| No — worker-internal | Command | Same transaction as OX-W1's attempt recording | Identity | *`identity.security_outbox_ciphertexts`*, *`...messages`* | Table/function do not exist; only `ninaku_worker` gets `DELETE` |

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

**Actor:** a scheduled sweep (same `@nestjs/schedule` + advisory-lock
template as `SubscriptionLifecycleSweepJob`), not a person.
**Intent:** a code that will never be usable again (its challenge expired)
should not keep sitting encrypted in the database waiting for a worker that
has nothing left to accomplish by sending it.
**Preconditions:** `identity.authentication_challenges.expires_at` (or the
equivalent column per flow) has passed, and the outbox message is still
`pending` or `leased` (not already `published`/`dead_lettered`).
**Invariants protected:** OB3's third clause, verbatim. This is the
identity-outbox-specific twin of OX-W7 (the worker's own per-claim expiry
check) — OX-S7 is the janitor that catches messages nobody ever claims at
all (e.g., the whole worker fleet is down for longer than the challenge's
10-minute lifetime), while OX-W7 catches the case where a claim happens
after expiry.
**Main path:** *`identity.purge_expired_security_outbox_ciphertexts()`*
runs on an interval, `UPDATE`s every message whose challenge has expired
and whose state is still `pending`/`leased` to `dead_lettered` (reason
`challenge_expired`) and deletes its ciphertext row, in one transaction per
batch — the same conditional-`UPDATE`-is-idempotent pattern
`platform.apply_trial_lapse`/`apply_period_lapse`/`apply_grace_expiry`
already use, so re-running it after everything already settled is a
guaranteed no-op.
**Adversarial/edge cases:**

* A message is `leased` by an active worker exactly when the sweep runs —
  the sweep's own `UPDATE ... WHERE publication_state IN ('pending',
  'leased')` would race the worker's `record_security_outbox_attempt`; this
  needs the same `SELECT ... FOR UPDATE`-then-conditional-`UPDATE`
  discipline `platform.apply_grace_expiry` uses against
  `record_subscription_payment_as_staff` (`paymentVersusGraceExpiryRace`)
  so the worker's in-flight send always wins and the sweep only claims rows
  no worker is actively touching.
* The challenge expires but was never claimed and the sweep has not run yet
  — the message sits `pending` with a stale, still-decryptable ciphertext
  until the next sweep tick; this window is bounded by the challenge's own
  lifetime, not left open-ended: the sweep interval is configured to run at
  least once per challenge lifetime (the shortest of the four security
  flows' own expiry windows), so no expired-and-unclaimed ciphertext can
  outlive its own challenge by more than one sweep tick.
  **Decision:**

| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| No — scheduled job | Command | — | Identity | *`identity.security_outbox_messages`*, *`...ciphertexts`*, *`...dead_letters`* | Tables/function do not exist; the job process needs the same `ninaku_worker` privileges as OX-W1, or a narrower `EXECUTE`-only grant on the purge function alone |

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

**Actor:** the same caller as OX-S1–S4, asking for a fresh code because the
first one did not arrive or expired.
**Intent:** give the person a working code again without creating an
ambiguous state where two codes for the same challenge are both "live" in
the outbox at once.
**Preconditions:** the existing "reuse a pending challenge" repository
behavior (unchanged by this catalog; already fixed against cross-identity
hijacking by PR #401) decides whether a resend reuses the same challenge
row or opens a new one.
**Invariants protected:** whichever challenge row is reused or created, it
must have **at most one** outbox message that can still be sent for it at
any instant — never two independently-retrying messages racing to deliver
two different ciphertexts (encrypted with two different nonces, per
`seal()`'s random IV) for what the person experiences as "one code."
**Main path (OB7):**

1. A resend re-enqueues into *`security_outbox_messages`* for the same
   challenge id, exactly as the previous draft already described.
2. Before inserting the new row, the same enqueue call marks any prior
   message for that challenge id still `pending` or `leased` as
   `dead_lettered` with reason `superseded_by_resend` — a fresh terminal
   state, never an in-place resurrection, matching
   `events.guard_outbox_update`'s "final Outbox state is immutable" rule
   the identity outbox copies verbatim.
3. The new row is inserted `pending`, exactly like OX-S1–S4. Only it can
   still be sent for this challenge going forward.
   **Adversarial/edge cases:**

* If the previous message for the same challenge is still `pending` or
  `leased` when a resend arrives, it is superseded before the new message
  exists — never left to send independently, so the person is never handed
  two live codes for one challenge (OB7).
* If the previous message already reached `published` (sent, ciphertext
  already deleted per OX-S6) when a resend arrives, there is nothing to
  supersede — the new enqueue is simply a fresh message, no ambiguity.
* The previous message is `leased` by an active worker exactly when the
  resend's supersede runs — the same race OX-S7 already names for its own
  sweep, and it resolves the same way: the supersede's `UPDATE` only
  touches a row still `leased` under a `SELECT ... FOR UPDATE`-then-
  conditional-`UPDATE` read, the same discipline `paymentVersusGraceExpiryRace`
  already proves for Platform. If the worker's own
  `record_security_outbox_attempt` call wins the race first, the row is no
  longer `pending`/`leased`, so the supersede finds nothing to cancel and
  simply inserts the new message; if the supersede wins first, the worker's
  own lease-token check (OX-W3) refuses to record a result against a row no
  longer `leased` under its token, failing safely instead of silently
  overwriting the newer message's state.
  **Decision:**

| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Reuses OX-S1–S4's own routes (resend is not a distinct route in any of the four flows today) | Command | — | Identity | *`identity.security_outbox_messages`* | Same as OX-S1 |

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

**Actor:** the same four callers as OX-S1–S4, and any client code reading
their responses today.
**Intent:** the response must never claim to know something the request
process can no longer observe.
**Invariants protected:** today `RegistrationOutcome.challengeDelivery`
(`{ status: DELIVERED | NOT_DELIVERED, resendable? }`,
`registration-response.ts`) is built from **awaiting a real provider
call** inside the request. Once OX-S1 replaces that call with a local
`INSERT`, the request process learns nothing about whether the message was
ever sent — it only learns that a message was durably queued. Continuing to
answer `DELIVERED` would be a false claim; there is no synchronous fact to
report anymore.
**Main path (OB8):** `RegistrationOutcome.challengeDelivery`
(`{ status: DELIVERED | NOT_DELIVERED, resendable? }`) is removed outright,
not redefined. `registration-response.ts` reports acceptance and
`challengeExpiresAt` only — the same `{ challengeExpiresAt }` shape
`AddSignInMethodUseCase`, `RequestPasswordRecoveryUseCase`, and
`OpenStepUpUseCase` already return today. All four Identity security flows
now share one uniform "accepted" contract: the request enqueued a message
and opened/extended a challenge that expires at this time; delivery outcome
is never reported synchronously, because the request process no longer has
that fact to report. Visibility into whether a message actually sent stays
on OX-O1's operator-only surface, never on the ordinary caller's response.
**Adversarial/edge cases:**

* No compatibility field, deprecated alias, or dual response shape ships
  alongside the removal — OB8 is explicit that no client consumes
  `challengeDelivery` today, so there is nothing to keep working during a
  transition; the OpenAPI schema and DTO drop the field in the same change
  that removes the synchronous provider call.
* `AddSignInMethodUseCase`'s response today carries no delivery status at
  all (only `challengeExpiresAt`) — unaffected by OB8, since it never
  claimed synchronous delivery in the first place.
  **Decision:**

| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| Affects `POST /sign-ups`'s response shape; possibly `POST /sign-in-methods`'s | Read (of the outcome DTO) | — | Identity | None — a DTO/contract question, not a schema one | N/A; **the DTO contract question above is unresolved** |

***

## 2. Worker (OB1/OB6)

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

**Actor:** the dispatcher worker process (its own Railway service, per
OB1), running as `ninaku_worker`.
**Intent:** pull a batch of sendable messages without two worker instances
(or two ticks of the same instance) ever pulling the same row.
**Preconditions:** the worker holds a live database connection as
`ninaku_worker`.
**Invariants protected:** this case is **already proven** for the tenant
outbox by `events.claim_outbox_batch`'s exact mechanism — `FOR UPDATE SKIP
LOCKED` on the candidate set, then an `UPDATE ... FROM picked` that assigns
a fresh `lease_owner`/`lease_token`/`lease_expires_at` — and this case
reuses that mechanism verbatim for *`identity.claim_security_outbox_messages`*
against *`security_outbox_messages`*, plus a second, unmodified call to
`events.claim_outbox_batch` itself per organization for the tenant side
(OB2: "one worker dispatches both").
**Main path:**

1. Worker calls *`claim_security_outbox_messages(lease_owner, lease_token,
   limit, lease_seconds)`* and, separately, `events.claim_outbox_batch(...)`
   per organization with outstanding tenant messages.
2. Both return the same shape: claimed row ids + enough identifying
   information to fetch the rest.
3. Worker processes each claimed row independently (OX-W4+).
   **Adversarial/edge cases:**

* Two worker instances call claim concurrently — `SKIP LOCKED` guarantees
  disjoint claimed sets; neither blocks the other, and neither can pick a
  row the other already locked, identical to the property
  `events.claim_outbox_batch` already gives the tenant outbox today
  (unproven by a dedicated concurrency test yet, per
  `OUTBOX_CASE_MATRIX.md`'s Gaps).
* Empty backlog — returns zero rows, not an error.
  **Decision:**

| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| No — worker-internal | Command (mutates lease state) | — | Identity (new function) + Events (existing, unmodified) | *`identity.security_outbox_messages`*; `events.outbox_records` (existing) | New function does not exist; `events.claim_outbox_batch` exists but has **no `EXECUTE` grant to anyone** — `ninaku_worker` needs it, `ninaku_runtime` must never get it (OX-W10) |

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

**Actor:** none directly — a failure mode, not an action.
**Intent:** a crashed worker never permanently strands a message.
**Invariants protected:** `events.claim_outbox_batch`'s own claim predicate
already covers this — `(o.publication_state='leased' AND
o.lease_expires_at<=now())` is claimable exactly like a `pending` row — and
the identity-outbox twin copies the same predicate. No message is
permanently lost to a dead worker; it becomes claimable again the instant
its lease's `lease_seconds` elapses.
**Main path:** worker A claims a message, crashes before calling
`record_..._attempt`; once `lease_expires_at` passes, worker B's next claim
call picks it up (same `attempt_count` as worker A left it, since only
`record_..._attempt` advances that counter).
**Adversarial/edge cases:**

* Worker A recovers *after* worker B already reclaimed and is mid-send —
  worker A's stale `lease_token` no longer matches the row's current one,
  so if A tries to call `record_..._attempt` anyway, it is refused
  ("Publication result requires the active Outbox lease" /
  its identity-outbox equivalent) — exactly the mechanism OX-W3 proves in
  detail.
* The lease duration is too short for a genuinely slow (not crashed)
  provider call — the message gets reclaimed and sent twice even though
  worker A was still alive; this is the same at-least-once tradeoff OX-S6
  already names, bounded by choosing `lease_seconds` comfortably above
  `SEND_TIMEOUT_MS` (`src/foundation/messaging/send-timeout.ts`, 10 000 ms
  today) so a healthy in-flight call almost never outlives its own lease.
  **Decision:**

| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| No — a property of OX-W1's claim predicate | Neither — an invariant | Shares its mechanism with OX-W3 | Identity + Events | Same as OX-W1 | Same as OX-W1 |

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

**Actor:** none directly — an invariant.
**Intent:** while a lease is live and unexpired, exactly one worker may
successfully record a terminal outcome for it.
**Invariants protected:** `events.record_publication_attempt`'s own guard —
`IF v_outbox.publication_state<>'leased' OR v_outbox.lease_token IS
DISTINCT FROM p_lease_token THEN RAISE EXCEPTION` and the separate
`lease_expires_at <= now()` check — proves this today for the tenant
outbox; the identity-outbox twin (*`record_security_outbox_attempt`*) must
copy both checks verbatim. A worker that lost its lease (reclaimed per
OX-W2, or simply expired) cannot record a result at all, terminal or
retryable — it gets an exception, not a silent no-op, so the caller code
must treat "lease lost" as its own outcome (log and stop, never retry the
recording call with the same stale token).
**Main path:** worker A holds a live lease, calls
`record_security_outbox_attempt` with its own `lease_token` — succeeds.
**Adversarial/edge cases:**

* Worker A's lease expired one second before it calls
  `record_..._attempt` (a slow provider response that just barely outlived
  the lease) — refused ("lease expired before result recording"), even
  though no other worker has claimed it yet; A must not silently succeed
  just because nobody else raced it.
* Two attempts to record a `published` outcome for the same message
  (should be structurally impossible if OX-W3 holds) — the second call
  would find `publication_state='published'` already, which is not
  `'leased'`, so it is refused by the same guard, never double-recorded.
  **Decision:**

| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| No — worker-internal | Neither — an invariant | Same mechanism as OX-W2 | Identity + Events | *`identity.security_outbox_attempts`*; `events.publication_attempts` (existing) | New table/function do not exist; `events.record_publication_attempt` exists with **no `EXECUTE` grant to anyone** — `ninaku_worker` needs it |

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

**Actor:** the worker, after a retryable send failure.
**Intent:** back off instead of hammering a struggling provider, and defer
to the provider's own stated recovery time when it gives one.
**Preconditions:** the send attempt classified as retryable (OX-W5).
**Invariants protected:** OB4's schedule — 5 s, 30 s, 2 min, 10 min, 30
min for attempts 1–5 — computed by the worker and passed as
`p_retry_after_at` to `record_..._attempt(outcome='retryable_error', ...)`,
which already requires `retry_after_at` to be non-null and in the future
for that outcome (`evt_publication_attempts_retry_chk`'s twin). When the
provider itself returns a `Retry-After` header (SES/Wasender throttling),
that value overrides OB4's own schedule for that attempt, never the other
way — the provider knows its own recovery time better than a fixed table
does.
**Main path:** worker computes `retry_after_at = max(OB4's scheduled delay,
provider's Retry-After if present)`, calls `record_..._attempt
(outcome='retryable_error', retry_after_at=...)`, which sets the message
back to `pending` with `available_at = retry_after_at` — unclaimable until
then, exactly like `events.record_publication_attempt`'s own
`retryable_error` branch already does.
**Adversarial/edge cases:**

* Provider's `Retry-After` is in the past or malformed — fall back to OB4's
  own schedule for that attempt number, never accept a non-future
  `retry_after_at` (the existing `retryable_error requires a future
  retry_after_at` check already refuses it structurally).
* Attempt 5 fails retryably — OX-W6 takes over (dead letter, not a 6th
  scheduled retry).
  **Decision:**

| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| No — worker-internal | Command | — | Identity + Events | Same as OX-W3 | Same as OX-W3 |

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

**Actor:** the worker, immediately after a send attempt returns.
**Intent:** treat a provider failure the way its own nature demands,
never uniformly.
**Invariants protected:** the existing, already-tested taxonomy in
`src/foundation/messaging/delivery/message-delivery-failure.ts` and each
connector's own classifier (`ses.connector.ts`'s `classifyFailure`,
`wasender.connector.ts`'s equivalent) is reused as-is, mapped to a retry
decision:

| Failure | Meaning | Retry? |
|---|---|---|
| `THROTTLED` | Provider rate-limited this request (HTTP 429) | Yes — OX-W4, honoring `Retry-After` if given |
| `UNAVAILABLE` | Transient provider/network failure, proven never to have left the provider | Yes — OX-W4 |
| `INDETERMINATE` | Unknown outcome (timeout, aborted request, ambiguous status) — may or may not have reached the recipient | Yes — retried like `THROTTLED`/`UNAVAILABLE` (OB9), bounded by OB4's 5-attempt schedule and OX-W7's own challenge-expiry check |
| `REJECTED` | Provider explicitly refused the destination (bad address/number, no connector configured for the channel) | No — permanent; dead-letter immediately (OX-W6), retrying a rejection wastes attempts on a failure that will not change |

**Main path:** worker inspects the thrown `MessageDeliveryError.failure`
(or an equivalent identity-outbox error) and calls `record_..._attempt`
with `outcome='retryable_error'` (THROTTLED/UNAVAILABLE/INDETERMINATE) or
`outcome='failed'` (REJECTED) accordingly.
**Adversarial/edge cases:**

* `REJECTED` on attempt 1 — dead-lettered immediately, never reaching
  OB4's 5-attempt schedule at all; this is intentional (a permanent failure
  gains nothing from waiting).
* **`INDETERMINATE` retried (OB9):** unlike the API-layer `resendable`
  logic today (which only calls `UNAVAILABLE` safely resendable, treating
  `INDETERMINATE` as *not* safely resendable, because the caller cannot
  prove the message never left), a worker-side retry of an `INDETERMINATE`
  outcome risks sending a **duplicate** message to the same recipient if
  the first attempt actually did succeed. OB9 accepts that risk explicitly:
  delivery is at-least-once, so a duplicate OTP is the same harmless
  nuisance OX-S6 already names for its own crash-before-recording case,
  while the alternative — dead-lettering on the first ambiguous outcome —
  risks the strictly worse failure of locking a person out of a code that
  may have actually arrived. Still bounded by OB4's 5-attempt cap and
  OX-W7's per-message challenge-expiry check, so an `INDETERMINATE` outcome
  can neither retry forever nor outlive its own challenge.
  **Decision:**

| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| No — worker-internal | Command | — | Identity/Events + Foundation (`messaging/delivery`, reused unmodified) | Same as OX-W3/W4 | Same as OX-W3/W4 |

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

**Actor:** the worker, on a message's final failing attempt.
**Intent:** stop retrying forever; hand the message to a human-reviewable
dead-letter queue instead.
**Invariants protected:** `events.record_publication_attempt`'s own
`outcome='failed'` branch, mirrored in
*`record_security_outbox_attempt`*: requires a dead-letter row id, inserts
into *`...dead_letters`*, and flips `publication_state='dead_lettered'` —
a **terminal** state (`Final Outbox state is immutable`,
`events.guard_outbox_update`'s own check, copied verbatim). OB4's 5-attempt
cap is enforced by the worker counting `attempt_count` before deciding to
retry vs. fail; the database does not itself enforce "no more than 5
attempts" as a constraint, matching how `events.consumers.max_attempts` is
also an application-read column, not a trigger-enforced ceiling on
`events.outbox_records` — so a worker bug that retried forever would not be
caught by the schema alone. This is worth naming explicitly, since
Codex's "reintentos de publicación sin máximo" concern is resolved by OB4's
policy and this worker-side counting, not by a new database constraint.
**Main path:** attempt 5 fails (retryable or not) → worker calls
`record_..._attempt(outcome='failed', dead_letter_id=...)` → message
terminally `dead_lettered`.
**Adversarial/edge cases:**

* A `REJECTED` failure on attempt 1 also reaches this path immediately
  (OX-W5) — dead-lettered without ever entering OX-W4's retry loop.
* A worker bug increments past 5 attempts without dead-lettering — not
  caught by any table constraint (see above); this is a worker-code
  correctness requirement, not a schema-enforced invariant, and should be
  covered by a unit test on the worker's own retry-counting logic once
  built.
* The worker's own code throws unexpectedly while handling one message
  (not a provider failure) — recorded through the same retry path as a
  provider failure, with the fixed class `unexpected_worker_error`: the
  attempt is counted, backed off per OB4 and dead-lettered on attempt 5, so
  a poison message cannot cycle through lease expiry forever. The stored
  text is the fixed class, never the exception text. If recording itself
  fails, the failure is logged without secrets, the batch continues and
  lease expiry remains the fallback. No database change: the failure-class
  column accepts any non-blank text. Proven by
  `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 › 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.use-case.spec.ts › logs and carries on when recording the unexpected failure itself fails, leaving the lease expiry as the fallback` and `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`.
* Every failure the worker path logs (a failed delivery, a failed recording of an outcome, a failed dead-lettering of a malformed claim row, a failed dispatch, expiry or retention tick) carries only the error name and code, never the exception message, so a message that quotes the OTP, the destination or a bearer token never reaches a log line. Proven by `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.unexpected-failure.use-case.spec.ts › logs a failed recording with the message id, purpose, error name and code but never the exception text, the OTP, the destination or a bearer token`, `src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.unexpected-failure.use-case.spec.ts › logs the unexpected delivery failure with its name and code under a hostile exception message`, `src/modules/identity/infrastructure/security-outbox/security-outbox-dispatch.repository.spec.ts › logs a failed dead-lettering with the message id, error name and code but never the exception text`, `src/modules/identity/presentation/jobs/security-outbox-dispatch.job.spec.ts › logs and swallows a failed tick without ever carrying the exception text`, `src/modules/identity/presentation/jobs/security-outbox-sweep-jobs.spec.ts › logs and swallows a failed tick without ever carrying the exception text`, `src/foundation/errors/describe-failure-cause-without-message.spec.ts › keeps the name and the code and drops the message, whatever it says`.
  **Decision:**

| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| No — worker-internal | Command | — | Identity + Events | *`identity.security_outbox_dead_letters`*; `events.publication_dead_letters` (existing) | New table does not exist; existing table has no grant to anyone yet |

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

**Actor:** the worker, immediately before attempting to send a claimed
message.
**Intent:** never spend a send attempt (or a provider dollar) on a code
whose challenge has already expired and can never be confirmed again.
**Preconditions:** the message is claimed (OX-W1) and its associated
challenge's `expires_at` has already passed.
**Invariants protected:** OB4's explicit carve-out, verbatim — "an OTP
message stops retrying once its challenge expires." This is a
**pre-send check**, distinct from OX-S7's sweep: OX-S7 catches messages
nobody ever claims; OX-W7 catches a message that *is* claimed (perhaps
after sitting `pending` for a while) whose challenge expired in the
meantime.
**Main path:** worker, after claiming, joins to the challenge's own
expiry column before calling the provider; if already expired, skips the
send entirely and calls `record_..._attempt(outcome='failed',
error_code='challenge_expired', dead_letter_id=...)` — a dead letter with a
distinct, honest reason code, not conflated with a provider failure.
**Adversarial/edge cases:**

* The challenge expires in the small window between the worker's expiry
  check and the actual provider call — a code is sent for an
  already-useless challenge; accepted as a narrow, harmless race (the
  person simply cannot use the code they receive, same outcome as if the
  check had run a second later).
* A message whose challenge was **already consumed** (the person entered
  the code correctly before the worker got to it) — this catalog does not
  design a "cancel on success" path; the worker still attempts to send,
  wasting a provider call on an already-satisfied challenge. This is a real
  gap this catalog names but does not close, since none of OB1–OB6
  addresses "cancel an outbox message when its underlying challenge
  resolves" — flagged here rather than silently assumed away.
  **Decision:**

| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| No — worker-internal | Command | — | Identity | *`identity.security_outbox_messages`*, `identity.authentication_challenges` (existing, read-only join) | Same as OX-W6; **"cancel on early success" is a named, unresolved gap** |

### OX-W8 — OTP ciphertext decryption failure

**Actor:** the worker, immediately after claiming a message and before
sending it.
**Intent:** never retry a failure that retrying cannot fix.
**Preconditions:** `open(securityOutboxKey, envelope, challengeId)` throws
— a corrupted envelope, a context/challenge-id mismatch (OX-S5), or a key
misconfiguration.
**Invariants protected:** a decryption failure is **never** transient the
way a network failure is — the ciphertext will not decrypt any differently
on a second attempt with the same key and context. Retrying it (per OX-W4's
schedule) would waste all 5 attempts for certain failure, and could mask a
real security incident (tampering, a corrupted deploy of the wrong key) as
an ordinary flaky-provider retry loop.
**Main path:** worker's `open()` call throws → immediately calls
`record_..._attempt(outcome='failed', error_code='decryption_failed',
dead_letter_id=...)`, skipping OX-W4's retry loop entirely, and logs at a
level that reaches security monitoring (not just ordinary operational
logs), since a decryption failure is a signal worth a human looking at,
unlike an ordinary provider hiccup.
**Adversarial/edge cases:**

* Every message enqueued under a since-rotated-away key fails decryption
  identically — this would show up as a spike of `decryption_failed` dead
  letters, the intended signal that a key rotation was not done in step
  with in-flight messages (rotation itself remains out of scope, per
  OX-S5).
* A single corrupted row (storage bit-rot, a manual data-fix mistake) —
  dead-lettered alone, does not affect any other message's decryption.
  **Decision:**

| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| No — worker-internal | Command | — | Identity + Foundation (`crypto` wrapper reused) | Same as OX-W6 | Same as OX-W6 |

### OX-W9 — Worker role least privilege

**Actor:** the database administrator applying the migration that creates
`ninaku_worker` (via `ninaku_migrator`, never at runtime).
**Intent:** a compromised or buggy worker process can claim and record
outbox outcomes and nothing else — never read a password hash, a TOTP
secret, or any business table.
**Preconditions:** none — this is how the role comes to exist.
**Invariants protected:** OB6, verbatim. `ninaku_worker` gets exactly:
`EXECUTE` on *`claim_security_outbox_messages`*,
*`record_security_outbox_attempt`*, *`purge_expired_security_outbox_ciphertexts`*,
`events.claim_outbox_batch`, `events.record_publication_attempt`; `SELECT`+
`DELETE` on *`security_outbox_ciphertexts`* (needed to decrypt, then
delete); and read access to whatever minimal join OX-W7's expiry check
needs (`identity.authentication_challenges.expires_at` alone, not the whole
table's other columns, if column-level grants are practical). It gets
**no** access to `identity.password_credentials`, `identity.totp_methods`,
`identity.recovery_codes`, or any `organization.*`/`platform.*` business
table — the same "narrow, purpose-built" discipline
`SUBSCRIPTION_PLANS_USE_CASES.md` SP13 already applies to
`platform.staff_members`'s functions, applied here to a role instead of a
row-level check.
**Main path:** a migration creates `ninaku_worker` (`LOGIN`, no
superuser/`BYPASSRLS`, per the existing startup guard in
`database-runtime-role-guard.service.ts` that already refuses those flags
for `ninaku_runtime` and should refuse them for `ninaku_worker` too), grants
exactly the privileges above.
**Adversarial/edge cases:**

* `ninaku_worker` attempts to `SELECT` from `identity.password_credentials`
  — refused, no grant exists.
* Railway holds `ninaku_worker`'s credentials for the separate worker
  service (OB1) — per AGENTS.md §10, this needs explicit owner approval at
  deploy time; this catalog does not itself authorize provisioning that
  credential, only names the role it belongs to.
  **Decision:**

| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| N/A — a role, not a route | N/A | — | Database administration (`ninaku_migrator`) | The role itself, plus every grant named above | The role does not exist; every grant above is currently absent for everyone |

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

**Actor:** none directly — a negative invariant about `ninaku_runtime`.
**Intent:** even if the API process were compromised or a future change
accidentally called a claim/record function from request-handling code, it
must fail, not succeed.
**Invariants protected:** OB6's other half. `ninaku_runtime` never receives
`EXECUTE` on *`claim_security_outbox_messages`*,
*`record_security_outbox_attempt`*, *`purge_expired_security_outbox_ciphertexts`*,
`events.claim_outbox_batch`, or `events.record_publication_attempt` — only
`EXECUTE` on the **enqueue** function (*`enqueue_security_outbox_message`*,
`events.append_domain_event` already exists ungranted today) and `INSERT`
on the ciphertext table, never `SELECT`/`DELETE` on it. This is exactly the
same structural pattern `platform.provision_organization` vs.
`provision_organization_for_caller` already establishes: the wide,
dangerous function stays barred; a narrow function scoped to the caller's
own write is what gets granted.
**Main path:** `ninaku_runtime` calls only the enqueue path, inside the
same transaction as the business write, exactly as OX-S1–S4 describe.
**Adversarial/edge cases:**

* `ninaku_runtime` attempts `claim_security_outbox_messages` directly (a
  regression, or an attacker who compromised the API process) — refused,
  `42501`, before touching any row, the same proof shape
  `organizationRuntimeCannotProvisionOrganizations` already gives for
  `platform.provision_organization`.
* `ninaku_runtime` attempts a raw `SELECT` on `security_outbox_ciphertexts`
  — refused; it never receives that grant, unlike `ninaku_worker`.
  **Decision:**

| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| N/A — a negative invariant about the API role | N/A | — | Database administration | Same objects as OX-W9 | Same as OX-W9; specifically, `ninaku_runtime` must **never** appear in the grants OX-W9 lists |

***

## 3. Operability

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

**Actor:** an operator/engineer investigating delivery health, not an
ordinary API caller.
**Intent:** see how many messages are pending, leased, dead-lettered, and
how old the oldest unclaimed message is, without needing direct database
access for routine monitoring.
**Preconditions:** the caller is an active platform staff member
(`platform.staff_members`), signed in with an ordinary session; no step-up is
required because the read changes nothing.
**Invariants protected:** the read-only, no-tenant-context posture the
`platform.staff_members`-gated functions use — never a tenant-scoped read (the
security outbox has no `organization_id`). No ciphertext, nonce or code ever
leaves the database, and a destination is only ever shown masked.
**Main path:** two staff-gated routes over `SECURITY DEFINER` functions granted
only to `ninaku_runtime`:

1. `GET /platform-admin/security-outbox/backlog` — one row per
   `publication_state` (`pending`, `leased`, `published`, `dead_lettered`,
   zero-filled) and the age in seconds of the oldest already-due pending
   message.
2. `GET /platform-admin/security-outbox/dead-letters` — a keyset page
   (`after`/`before`, `pageInfo`, newest first) of dead letters with id,
   purpose, destination kind, masked destination, attempt count, failure
   class, dead-lettered-at and challenge expiry. The masking is defined once,
   in `identity.mask_security_outbox_destination`.
   **Adversarial/edge cases:**

* No dead letters exist — zeros and an empty list, not an error.
* A caller with no staff grant, a revoked grant, or no actor — refused before
  any row is read (`42501` at the database, `platform.staff_required` at the
  route); `ninaku_worker` cannot execute either function.
  **Decision:**

| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| `GET /platform-admin/security-outbox/backlog`, `GET /platform-admin/security-outbox/dead-letters`, staff-gated | Read | — | Platform (route, use cases) over an Identity contract (`SecurityOutboxOperationsContract`) | `identity.security_outbox_backlog_as_staff`, `identity.list_security_outbox_dead_letters_as_staff`, `identity.mask_security_outbox_destination`; the tenant outbox's own backlog waits for its first producer | None: `ninaku_runtime` receives `EXECUTE` on the two read functions only, never a table grant |

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

**Actor:** the platform super admin (`platform.staff_members`, OB10), after
investigating a dead letter whose delivery failed (a provider outage that
has since resolved, a phone number the person corrected) — never Identity's
own callers, and never automatic.
**Intent:** give a person who never received their code a new one, deliberately
and audibly. A dead letter cannot be replayed as written: its ciphertext is
erased when it dead-letters (OX-S6) and the challenge stores only an argon2
hash of the code, so the original code can never be recovered or resent. The
command therefore reissues: it mints a new one-time code for the same
still-open challenge.
**Preconditions:** the message is `dead_lettered` with a delivery failure
class (never `superseded_by_resend` or `challenge_expired`); its challenge is
open (not expired, consumed, revoked or out of attempts); no newer `pending`,
`leased` or `published` message exists for the same purpose and destination;
the staff member has a fresh step-up.
**Invariants protected:** `Final security outbox state is immutable` — the
dead letter is never resurrected in place; the reissue is a fresh message with
a new id and `attempt_count=0`. The challenge's code hash is the only thing
that changes on the challenge, and only through this path: the challenge
trigger still refuses a `secret_hash` change for every role, honoring a
transaction-local flag only when the executing role owns the reissue function.
A sign-up challenge keeps its candidate password hash untouched. The plaintext
code is never returned, logged, audited or persisted outside its sealed
envelope.
**Main path (OB10):**

1. The staff member signs in and steps up, as SP16/SP17/SP19 require.
2. `POST /platform-admin/security-outbox/dead-letters/{messageId}/reissues`
   with an `Idempotency-Key` and the elevation token. In one transaction the
   command claims the idempotency key, validates and locks the dead letter and
   its challenge, generates a code with the verification-code policy, hashes it
   with the password hasher, replaces the challenge's code hash so the old code
   stops working, seals the new code (bound to the new message id and purpose)
   and enqueues the new message with the existing enqueue function.
3. The command writes one platform audit record naming the staff member and the
   dead letter. A replay of the same key returns the same result and enqueues
   nothing more.
   **Adversarial/edge cases:**

* A `superseded_by_resend` or `challenge_expired` dead letter — refused; those
  are not failures to fix.
* The challenge is expired, consumed, revoked or out of attempts — refused,
  not silently allowed to send a code nobody can use.
* A newer live message already exists — refused: the person already has, or is
  about to get, a code.
* Two concurrent reissues of the same dead letter — the row lock serializes
  them; at most one message is enqueued and the loser is refused.
* A caller without a `platform.staff_members` row, without a fresh step-up, the
  affected identity itself, or `ninaku_worker` — refused, the same shape
  SP16/SP17's staff-only commands use.
  **Decision:**

| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| `POST /platform-admin/security-outbox/dead-letters/{messageId}/reissues`, staff-gated with step-up and `Idempotency-Key` | Command | — | Platform (route, idempotency, audit) over an Identity contract (`SecurityOutboxOperationsContract.reissueDeadLetter`) that owns the code generation, hashing and sealing | `identity.open_security_outbox_reissue_as_staff`, `identity.complete_security_outbox_reissue_as_staff`, `identity.lock_reissuable_security_outbox_message`; audit action `platform.security_outbox_message.reissued` | None: `ninaku_runtime` receives `EXECUTE` on the two staff functions only |

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

**Actor:** a scheduled cleanup job, not a person.
**Intent:** `published` rows (their ciphertext already deleted per OX-S6)
do not accumulate forever as dead weight in an otherwise hot, frequently
`SKIP LOCKED`-scanned table.
**Preconditions:** a row has been `published` (or `dead_lettered`) for
longer than some retention window.
**Invariants protected:** OB11 names the retention period directly:
`published` rows (and their attempt history) are retained 30 days,
`dead_lettered` rows (and theirs) 90 days, then deleted. The eventual
implementation is a scheduled `DELETE` (or partition-drop) of terminal
rows older than these thresholds, run with the same advisory-lock
discipline as `SubscriptionLifecycleSweepJob`, so it never contends with an
active worker's claim scan (`evt_outbox_claim_idx`'s own partial index
already excludes `published`/`dead_lettered` rows from the claim path, so
retention cleanup and claiming never compete for the same index range
regardless of timing).
**Main path (OB11):** a scheduled job (the same `@nestjs/schedule` +
advisory-lock template as `SubscriptionLifecycleSweepJob`) runs on an
interval and deletes, for both the identity outbox and `events.*`: every
`published` row (plus its attempt history) more than 30 days past
`published_at`, and every `dead_lettered` row (plus its attempt and
dead-letter history) more than 90 days past its dead-letter timestamp.
Rows still `pending`/`leased` are never touched by this sweep, only by
OX-S7's own expiry-driven cleanup.
**Adversarial/edge cases:**

* A `dead_lettered` row is reissued (OX-O2) shortly before its 90-day
  window would have deleted it — the reissue creates a brand-new message
  (OX-O2's own "fresh message, never an in-place resurrection" rule), so
  the original dead letter's own retention clock is untouched by the
  reissue; it is still deleted on schedule once it reaches 90 days.
* The retention sweep runs while OX-S7's expiry sweep is also due — they
  target disjoint row sets (`pending`/`leased` vs. `published`/
  `dead_lettered`) and never compete for the same row.
  **Decision:**

| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| No — scheduled job, not built | Command | — | Identity + Events | *`identity.security_outbox_messages`*, `...attempts`, `...dead_letters`; equivalently for `events.*` | Not built; grants mirror `SubscriptionLifecycleSweepJob`'s own service-identity shape once implemented |

***

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

OB5's default scope defers every case in this section until a producing
module exists. They are catalogued now, per the owner's own instruction,
so a future module does not have to rediscover the schema or its gaps from
scratch. None of these are authorized for implementation by this catalog.

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

**Actor:** a future tenant-facing module (Sales, Inventory, a vertical) —
never Identity, never an HTTP caller directly.
**Intent:** the business fact and its event envelope commit together, or
neither does.
**Invariants protected:** already fully proven by the schema, unused only
for lack of a producer — `events.append_domain_event` is idempotent per
`(organization_id, producer_module_code, idempotency_key)`, and the
deferred `evt_domain_events_outbox_complete_trg` refuses to let a
transaction commit a `domain_events` row without exactly one matching
`outbox_records` row, structurally impossible to get wrong once a module
calls the function as designed.
**Main path:** the future module calls `events.append_domain_event(...)`
inside its own business transaction, exactly as the function's header
comment already instructs.
**Adversarial/edge cases:** already named in the schema's own validation
(idempotency replay with changed semantics is refused; a retired event
type is refused) — nothing new to add until a real producer exists to
exercise them.
**Decision:**

| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| No — internal, per-producer-module contract | Command | — | Whichever module produces it (not yet built) | `events.domain_events`, `events.outbox_records` | No grant to `ninaku_runtime` on any `events.*` object today — not even `GRANT USAGE ON SCHEMA events` |

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

**Actor:** a future consuming module.
**Intent:** duplicate transport (at-least-once publication) never becomes a
duplicate canonical effect.
**Invariants protected:** already fully proven by the schema —
`events.consumer_deliveries`'s per-`(organization_id, consumer_id,
domain_event_id)` uniqueness, `claim_consumer_deliveries`'s identical
`SKIP LOCKED`+lease shape, `record_consumer_processing_attempt`'s identical
lease/attempt-sequence guard.
**Main path:** a future consumer registers via `events.consumers`/
`consumer_subscriptions`, then runs the same claim → process → record loop
OX-W1/W3 describe for the outbox side.
**Adversarial/edge cases:** already named in the schema (subscription
contract-version range checks, inactive-consumer refusal) — nothing new
until a real consumer exists.
**Decision:**

| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| No — internal, per-consumer-module contract | Command | — | Whichever module consumes it (not yet built) | `events.consumers`, `consumer_subscriptions`, `consumer_deliveries`, `consumer_processing_attempts` | Same as OX-E1 |

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

**Actor:** a future tenant-facing module, driving `notifications.intents`
from a business fact (a sales order, a reservation, an invoice).
**Intent:** resolve a recipient, a channel, a template, and CRM/consent
policy once, snapshot that decision, then deliver against the snapshot —
never re-deriving policy at send time in a way that could disagree with
what was decided when the notification was requested.
**Invariants protected:** already modeled by the schema
(`notifications.recipient_channel_resolutions`'s frozen policy/consent
snapshot, `notifications.deliveries`'s immutable content snapshot) but
**not yet safe to build on as-is** — see the explicit gaps below, each one
of the Codex review's remaining open items this catalog must not silently
drop.
**Main path (once a producer exists):** intent → recipient resolution →
channel/template/policy resolution (frozen) → delivery → delivery attempts.
**Adversarial/edge cases, and explicit out-of-scope items with reasons**
(the four remaining Codex "Segunda revisión" items not already resolved as
a use case or a cross-cutting invariant above):

* **Two simultaneous delivery attempts for the same delivery.** Unlike
  `events.outbox_records`/the identity outbox, `notifications.
  delivery_attempts` (as shipped in `0330_notifications.sql`) carries no
  lease/lock columns at all — nothing stops two processes from both
  inserting an `attempt_number` for the same `delivery_id` concurrently.
  **Out of scope for this iteration, with reason:** the four Identity
  security flows never touch `notifications.*` at all (they use the
  proposed identity-owned outbox instead, which does have a proper lease).
  This gap is real but only matters once a module actually produces
  `notifications.intents`, at which point it needs the same `SKIP
  LOCKED`+lease treatment `events.outbox_records` already has — not
  designed here, deferred to that module's own work.
* **Event-type ownership is not validated.** `events.
  validate_domain_event_insert` checks that the named contract version is
  published and the event type is not retired, but never checks that
  `NEW.producer_module_code` matches `event_types.owner_module_code` — any
  module could insert an event under another module's event type today.
  **Out of scope for this iteration, with reason:** no module produces any
  domain event yet (OX-E1 is unbuilt), so there is no live misuse to guard
  against; the first module to call `append_domain_event` for a real event
  type should add this check (a `BEFORE INSERT` comparison against
  `event_types.owner_module_code`) as part of shipping OX-E1 for real, not
  as a prerequisite to cataloguing it here.
* **A delivery receipt never advances delivery state.** `notifications.
  delivery_receipts` (a provider callback/webhook confirmation) is a
  standalone insert-only table with no trigger that updates the matching
  `notifications.deliveries.status`/`delivered_at` — recording a receipt
  today would not actually mark anything "delivered." **Out of scope for
  this iteration, with reason:** the security outbox's own "published"
  semantics (OX-S6) do not depend on `notifications.*` at all, so this gap
  cannot affect the four Identity flows; it blocks only a future
  `notifications.*` consumer, which must add that trigger before treating
  receipts as authoritative.
* **`delivery_attempts.attempt_number` has no sequence-enforcing trigger.**
  Unlike `events.publication_attempts` (whose `validate_publication_attempt_insert`
  trigger requires `attempt_no = outbox.attempt_count + 1` under the active
  lease), `notifications.delivery_attempts` only has a bare uniqueness
  constraint (`ntf_attempts_delivery_number_uq`) — nothing stops an
  out-of-order or gapped `attempt_number` from being inserted. **Out of
  scope for this iteration, with reason:** same as above, this table is
  unused by the current scope; a future producer should copy the
  events-schema pattern (lease + sequence check) rather than rely on the
  bare uniqueness constraint alone.
  **Decision:**

| HTTP? | Command/Read? | Merged with | Module | DB objects | Missing grants |
|---|---|---|---|---|---|
| No — internal, per-producer-module contract | Command | — | Whichever module produces it (not yet built) | `notifications.intents`, `recipients`, `recipient_channel_resolutions`, `deliveries`, `delivery_attempts`, `delivery_receipts` | No grant to `ninaku_runtime` on any `notifications.*` object today — not even `GRANT USAGE ON SCHEMA notifications`; the four gaps above are additionally unresolved by any trigger, grant, or lease |

***

## Cross-cutting invariants

Matching the "Cross-tenant and concurrency invariants" section of
`ORGANIZATION_USE_CASES.md`/`SUBSCRIPTION_PLANS_USE_CASES.md`, these are
properties that hold across the whole outbox, not any single use case —
including two of Codex's "Segunda revisión" items that are invariants to
state plainly, not gaps to close:

| Invariant | Statement | Resolves |
|---|---|---|
| Publication order is best-effort, never guaranteed | `claim_outbox_batch`/`claim_security_outbox_messages` order candidates by `(available_at, created_at, id)`, but two messages claimed in the same batch, or across retried batches, may be sent out of that order (a retried message can be reclaimed and sent after a later message that never needed a retry). None of the four Identity security flows have a cross-message ordering requirement — each OTP is independent — so this is accepted, not designed around. A future tenant business-event consumer that needs strict ordering must build its own per-entity sequence, exactly as `events.domain_event_publications` already gives tenant-wide **publication** order (not delivery order) today. | Codex: "orden de publicación no garantizado" |
| "Published"/"sent" means provider-accepted, not confirmed end-user delivery | `outcome='published'` records that `MessageDispatcherService.send(...)` returned an `AcceptedMessage` — the provider took custody of the message. It does not mean the recipient's inbox/phone ever received it; that stronger fact requires a delivery **receipt** (SES bounce/complaint webhooks, Wasender delivery callbacks), which neither the tenant outbox nor this catalog's security outbox consumes today (`notifications.delivery_receipts` exists for this purpose but is unused and itself gapped — OX-E3). Nobody should read `published` as "the person got the code." | Codex: "'delivered' = solo aceptado" |
| Retries are capped; exhaustion always dead-letters, never retries forever | OB4's 5-attempt schedule, enforced by worker-side counting (OX-W6), not a database `CHECK`; a worker bug could in principle retry past 5, which the schema alone would not catch. | Codex: "reintentos de publicación sin máximo" (resolved by policy + worker discipline, not a new constraint) |
| The security outbox never carries `organization_id` and never joins into `events.*` | OB2's tenant/pre-tenant boundary is structural, not just documented: *`identity.security_outbox_messages`* has no `organization_id` column at all, and no foreign key from it into any `events.*`/`organization.*` table exists in this catalog's proposed schema. A bug that tried to treat a security-outbox message as a tenant event would fail at the schema level (no column to populate), not just at review time. | OB2 |
| `ninaku_runtime` can never claim or record an outbox outcome, even by guessing a function name | Structural, not defensive — OX-W10's grant table names every function `ninaku_runtime` must never receive `EXECUTE` on; a request-handling code path that accidentally called one would fail with `42501` before touching a row, the same proof shape `organizationRuntimeCannotProvisionOrganizations` already gives for `platform.provision_organization`. | OB6 |
