Skip to content

Outbox Case Matrix ​

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

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


Totals ​

Use cases25
Per-case scenario rows128
Per-case scenario rows proven89
Cross-cutting invariants5 (1 proven)
Gaps43

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

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

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

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

CaseExpected answerProving test
Happy path: signed-in caller adds a reachable identifierChallenge and outbox message committed together; the old abandonAddition compensating transaction is retiredsrc/modules/identity/application/sign-in-methods/add-sign-in-method.use-case.spec.ts › enqueues the sealed code inside the same transaction as the claim, and never notifies synchronously; test/e2e/sign-in-methods.e2e-spec.ts › adds a phone to an email account and confirms it, listing every way in
Concurrent second add-sign-in-method request for the same caller before the first confirmsGoverned unchanged by the existing claim-uniqueness invariantsrc/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › lets exactly one of two identities simultaneously claiming the same identifier win, serialized by the per-identifier advisory lock
Enqueue insert failsWhole transaction rolls back; no orphaned challenge, no compensating transaction neededsrc/modules/identity/application/sign-in-methods/add-sign-in-method.use-case.spec.ts › lets an enqueue failure escape so the transaction rolls the claim back too

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

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

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

CaseExpected answerProving test
Happy path: a reachable candidate existsChallenge and outbox message committed together; nothing sent synchronouslysrc/modules/identity/application/step-ups/open-step-up.use-case.integration-spec.ts › leaves exactly one pending outbox message bound to the challenge it opened; src/modules/identity/application/step-ups/open-step-up.use-case.spec.ts › enqueues a code of the length the policy sets, inside the same transaction as the challenge (fixes notify-before-commit)
No notifiable candidate at allRefused before any transaction opens (StepUpUnreachableError/ChallengeChannelUnavailableError)src/modules/identity/application/step-ups/open-step-up.use-case.integration-spec.ts › refuses when the identity has no verified identifier at all, without opening a transaction; src/modules/identity/application/step-ups/open-step-up.use-case.spec.ts › refuses an identity with no verified identifier to reach, without hashing anything
Database failure after the code is generated but before commitWhole transaction rolls back; nothing was sent, so no live code exists outside the database — the bug OX-S4 exists to closesrc/modules/identity/application/step-ups/open-step-up.use-case.integration-spec.ts › rolls the step-up challenge back when the outbox enqueue fails, committing neither; src/modules/identity/application/step-ups/open-step-up.use-case.spec.ts › lets an enqueue failure escape so the transaction rolls the challenge back too

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

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

CaseExpected answerProving test
Happy path: seal under the security-outbox key with the message id and purpose as AAD context, then open with the same contextRound-trips to the original codesrc/modules/identity/infrastructure/security-outbox/security-outbox-otp-cipher.service.spec.ts › round-trips a sealed OTP back to the original bytes
Two seals of the same OTPDifferent nonce each timesrc/modules/identity/infrastructure/security-outbox/security-outbox-otp-cipher.service.spec.ts › produces a different nonce for two seals of the same OTP
open() called with a ciphertext copied onto a different message idThrows (authentication-tag mismatch); never returns a wrong-but-plausible codesrc/modules/identity/infrastructure/security-outbox/security-outbox-otp-cipher.service.spec.ts › fails to open when the ciphertext is copied onto a different message id
open() called with a ciphertext copied onto a different purposeThrows, same as the message id casesrc/modules/identity/infrastructure/security-outbox/security-outbox-otp-cipher.service.spec.ts › fails to open when the ciphertext is copied onto a different purpose
open() called against a tampered ciphertext byteThrowssrc/modules/identity/infrastructure/security-outbox/security-outbox-otp-cipher.service.spec.ts › fails to open with a tampered ciphertext byte
open() called with a key different from the one used to sealThrowssrc/modules/identity/infrastructure/security-outbox/security-outbox-otp-cipher.service.spec.ts › fails to open with the wrong encryption key
open() called with a key version other than the one currently configuredThrows a distinct, clearly-named error before any decryption attemptsrc/modules/identity/infrastructure/security-outbox/security-outbox-otp-cipher.service.spec.ts › fails to open with an unsupported key version, rejected before any decryption attempt
Any decryption failureNever includes the plaintext OTP or the configured key in its messagesrc/modules/identity/infrastructure/security-outbox/security-outbox-otp-cipher.service.spec.ts › never includes the plaintext OTP in a decryption failure message; › never includes the configured encryption key in a decryption failure message

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

CaseExpected answerProving test
Happy path: provider accepts the sendpublication_state='published' and the ciphertext row deleted, in one transactiondatabase/tests/contracts/security-outbox-contracts.ts › OX-S6/OX-W6 published outcome empties the ciphertext and stamps published_at
Provider call succeeds but the process crashes before recording the attemptCiphertext not deleted; lease eventually expires and another worker resends the same code — an accepted at-least-once duplicate—
Crash between the delete and the caller observing successCannot happen partially; delete and state flip are the same transaction—

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

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

OX-S8 — Resend a security verification code ​

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

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

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

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

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

CaseExpected answerProving test
Sign-up response, once enqueueing replaces the synchronous provider callchallengeDelivery is removed outright; the response reports { challengeExpiresAt } only, no compatibility field (OB8)test/e2e/sign-up.e2e-spec.ts › registers an email identity and answers 201 inside the envelope, accepted with only an expiry; src/modules/identity/presentation/controllers/sign-ups.controller.spec.ts › answers accepted with the identity and an ISO expiry, and nothing about delivery
Add-sign-in-method responseUnaffected — it never reported synchronous delivery statustest/e2e/sign-in-methods.e2e-spec.ts › adds a phone to an email account and confirms it, listing every way in
Password-recovery and step-up responsesUnaffected — both already answered { challengeExpiresAt } only, before OX-S1 touched anythingtest/e2e/password-recovery.e2e-spec.ts › walks the whole recovery flow: lose it, recover it, sign in with the new one, fail with the old one; test/e2e/step-up.e2e-spec.ts › proves the code queued for a verified identifier and hands back an elevation token bound to this session

Worker (OB1/OB6) ​

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

CaseExpected answerProving test
Happy path: due messages exist in both the identity outbox and one or more organizations' tenant outboxClaimed with a fresh lease in both, same shape as events.claim_outbox_batch—
Two worker instances (or two ticks) call claim concurrentlyDisjoint claimed sets; neither blocks the otherdatabase/tests/concurrency/security-outbox-concurrency.ts › concurrentClaimersNeverLeaseTheSameRow (NINAKU_SECURITY_OUTBOX_CONCURRENCY_OK)
Empty backlogZero rows claimed, not an error—

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

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

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

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

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

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

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

CaseExpected answerProving test
Happy path: retryable failure, no Retry-After from the provideravailable_at set per OB4's schedule for that attempt number (5 s / 30 s / 2 min / 10 min / 30 min)—
Retryable failure, provider returns a Retry-AfterThat value overrides OB4's own schedule for that attempt—
Provider's Retry-After is in the past or malformedFalls back to OB4's own schedule; a non-future retry_after_at is never accepted—
Attempt 5 fails retryablyDead-lettered (OX-W6), never a 6th scheduled retry—

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

CaseExpected answerProving test
THROTTLEDRetryable, honoring Retry-After if given—
UNAVAILABLERetryable—
INDETERMINATERetryable like THROTTLED/UNAVAILABLE; at-least-once delivery accepts the duplicate-send risk over the strictly worse alternative of locking the person out (OB9), bounded by OB4's 5-attempt cap and OX-W7's challenge-expiry check—
REJECTEDNot retryable — dead-lettered immediately, even on attempt 1—
REJECTED reaching attempt 1, never entering the retry loopDead-lettered without ever computing a retry_after_at—

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

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

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

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

OX-W8 — OTP ciphertext decryption failure ​

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

OX-W9 — Worker role least privilege ​

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

OX-W10 — The API role never dispatches ​

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

Operability ​

OX-O1 — Observe backlog and dead letters ​

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

OX-O2 — Reissue a dead-lettered message ​

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

OX-O3 — Bounded retention of sent outbox rows ​

CaseExpected answerProving test
published rows (and their attempt history) older than 30 daysDeleted by the scheduled sweep (OB11)database/tests/contracts/security-outbox-contracts.ts › OB11/OX-O3 retention deletes only published rows past 30 days and dead letters past 90 days
dead_lettered rows (and their attempt/dead-letter history) older than 90 daysDeleted by the scheduled sweep (OB11)database/tests/contracts/security-outbox-contracts.ts › OB11/OX-O3 retention deletes only published rows past 30 days and dead letters past 90 days

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

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

CaseExpected answerProving test
Happy path: a future module calls events.append_domain_event inside its own transactionDomain event and exactly one outbox row committed together—
Idempotency-key replay with changed semanticsRefused — already proven by the function's own comparison logic, unexercised for lack of a producer—
Insert against a retired event typeRefused — already enforced by validate_domain_event_insert, unexercised for lack of a producer—

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

CaseExpected answerProving test
Happy path: claim → process → record, per consumerDuplicate transport never becomes a duplicate canonical effect, per the existing per-(organization_id, consumer_id, domain_event_id) uniqueness—
Contract version outside the consumer's subscribed rangeRefused — already enforced, unexercised for lack of a consumer—
Inactive consumer attempts to claimRefused — already enforced, unexercised for lack of a consumer—

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

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

Cross-cutting invariants ​

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

Gaps ​

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

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

Application Foundation in progress. Tracked in issue #13.