Appearance
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; 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 asevt_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— mirrorsevents.publication_attempts:attempt_no,transport_code,outcome,error_code,retry_after_at, sequence-checked against the message's ownattempt_countexactly likeevents.validate_publication_attempt_insertdoes.identity.security_outbox_dead_letters— mirrorsevents.publication_dead_letters.- Functions:
identity.enqueue_security_outbox_message(...),identity.claim_security_outbox_messages(...)(sameFOR UPDATE SKIP LOCKED+ lease shape asevents.claim_outbox_batch),identity.record_security_outbox_attempt(...)(same lease/attempt-count contract asevents.record_publication_attempt, plus ciphertext deletion on a terminal outcome), andidentity.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:
- Caller submits an identifier;
createVerificationCode/createUuidV7build the challenge and its code exactly as today. - 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_messagesrow (publication_state='pending'). - Commit. The response returns immediately — see OX-S9 for what it now says about delivery. Adversarial/edge cases:
- The enqueue
INSERTfails (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_EVENTlog 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:
RequestPasswordRecoveryUseCase.executecallsopenChallenge— unchanged.- If (and only if)
openedis true (a real identifier), the same transaction (or an immediately-following one, since enqueueing is local and cheap either way) inserts the outbox row; ifopenedis false, nothing is enqueued. - 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
201and logged aterrorlevel 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:
503withRetry-Afterfor a transient database failure,500otherwise, 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:
firstNotifiableCandidateresolves the reachable identifier — unchanged.- One transaction: open the challenge (unchanged shape) and enqueue the outbox row, together.
- 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_TAGmismatch), never returns a wrong-but-plausible code. - The security-outbox key rotates — this catalog does not design key rotation;
seal/open'sv1.envelope prefix leaves room for a future key-version scheme identical to howsecret-material-cipher.tswould 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
DELETEbut 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, UPDATEs 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
leasedby an active worker exactly when the sweep runs — the sweep's ownUPDATE ... WHERE publication_state IN ('pending', 'leased')would race the worker'srecord_security_outbox_attempt; this needs the sameSELECT ... FOR UPDATE-then-conditional-UPDATEdisciplineplatform.apply_grace_expiryuses againstrecord_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
pendingwith 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):
- A resend re-enqueues into
security_outbox_messagesfor the same challenge id, exactly as the previous draft already described. - Before inserting the new row, the same enqueue call marks any prior message for that challenge id still
pendingorleasedasdead_letteredwith reasonsuperseded_by_resend— a fresh terminal state, never an in-place resurrection, matchingevents.guard_outbox_update's "final Outbox state is immutable" rule the identity outbox copies verbatim. - 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
pendingorleasedwhen 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
leasedby 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'sUPDATEonly touches a row stillleasedunder aSELECT ... FOR UPDATE-then- conditional-UPDATEread, the same disciplinepaymentVersusGraceExpiryRacealready proves for Platform. If the worker's ownrecord_security_outbox_attemptcall wins the race first, the row is no longerpending/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 longerleasedunder 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
challengeDeliverytoday, 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 (onlychallengeExpiresAt) — 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:
- 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. - Both return the same shape: claimed row ids + enough identifying information to fetch the rest.
- Worker processes each claimed row independently (OX-W4+). Adversarial/edge cases:
- Two worker instances call claim concurrently —
SKIP LOCKEDguarantees disjoint claimed sets; neither blocks the other, and neither can pick a row the other already locked, identical to the propertyevents.claim_outbox_batchalready gives the tenant outbox today (unproven by a dedicated concurrency test yet, perOUTBOX_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_tokenno longer matches the row's current one, so if A tries to callrecord_..._attemptanyway, 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_secondscomfortably aboveSEND_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
publishedoutcome for the same message (should be structurally impossible if OX-W3 holds) — the second call would findpublication_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-Afteris in the past or malformed — fall back to OB4's own schedule for that attempt number, never accept a non-futureretry_after_at(the existingretryable_error requires a future retry_after_atcheck 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:
REJECTEDon attempt 1 — dead-lettered immediately, never reaching OB4's 5-attempt schedule at all; this is intentional (a permanent failure gains nothing from waiting).INDETERMINATEretried (OB9): unlike the API-layerresendablelogic today (which only callsUNAVAILABLEsafely resendable, treatingINDETERMINATEas not safely resendable, because the caller cannot prove the message never left), a worker-side retry of anINDETERMINATEoutcome 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 anINDETERMINATEoutcome 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
REJECTEDfailure 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 bysrc/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 fallbackandsrc/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_faileddead 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_workerattempts toSELECTfromidentity.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_runtimeattemptsclaim_security_outbox_messagesdirectly (a regression, or an attacker who compromised the API process) — refused,42501, before touching any row, the same proof shapeorganizationRuntimeCannotProvisionOrganizationsalready gives forplatform.provision_organization.ninaku_runtimeattempts a rawSELECTonsecurity_outbox_ciphertexts— refused; it never receives that grant, unlikeninaku_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:
GET /platform-admin/security-outbox/backlog— one row perpublication_state(pending,leased,published,dead_lettered, zero-filled) and the age in seconds of the oldest already-due pending message.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, inidentity.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 (
42501at the database,platform.staff_requiredat the route);ninaku_workercannot 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):
- The staff member signs in and steps up, as SP16/SP17/SP19 require.
POST /platform-admin/security-outbox/dead-letters/{messageId}/reissueswith anIdempotency-Keyand 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.- 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_resendorchallenge_expireddead 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_membersrow, without a fresh step-up, the affected identity itself, orninaku_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_letteredrow 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/leasedvs.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 in0330_notifications.sql) carries no lease/lock columns at all — nothing stops two processes from both inserting anattempt_numberfor the samedelivery_idconcurrently. Out of scope for this iteration, with reason: the four Identity security flows never touchnotifications.*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 producesnotifications.intents, at which point it needs the sameSKIP LOCKED+lease treatmentevents.outbox_recordsalready has — not designed here, deferred to that module's own work. - Event-type ownership is not validated.
events. validate_domain_event_insertchecks that the named contract version is published and the event type is not retired, but never checks thatNEW.producer_module_codematchesevent_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 callappend_domain_eventfor a real event type should add this check (aBEFORE INSERTcomparison againstevent_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 matchingnotifications.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 onnotifications.*at all, so this gap cannot affect the four Identity flows; it blocks only a futurenotifications.*consumer, which must add that trigger before treating receipts as authoritative. delivery_attempts.attempt_numberhas no sequence-enforcing trigger. Unlikeevents.publication_attempts(whosevalidate_publication_attempt_inserttrigger requiresattempt_no = outbox.attempt_count + 1under the active lease),notifications.delivery_attemptsonly has a bare uniqueness constraint (ntf_attempts_delivery_number_uq) — nothing stops an out-of-order or gappedattempt_numberfrom 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 |