Appearance
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 cases | 25 |
| Per-case scenario rows | 128 |
| Per-case scenario rows proven | 89 |
| Cross-cutting invariants | 5 (1 proven) |
| Gaps | 43 |
Security outbox (Identity-owned, OB2/OB3)
OX-S1 — Enqueue the sign-up verification code atomically with its challenge
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: sign-up submits a reachable identifier | Challenge row and outbox message row both committed in one transaction | src/modules/identity/application/registration/register-identity.use-case.integration-spec.ts › leaves exactly one pending outbox message bound to the challenge it queued the code for; src/modules/identity/application/registration/register-identity.use-case.spec.ts › enqueues inside the same transaction, right after the challenge is claimed |
| Enqueue insert fails (constraint violation, disk full) | Whole transaction rolls back; no challenge left with nothing to notify it | src/modules/identity/application/registration/register-identity.use-case.integration-spec.ts › rolls the challenge and the login identifier back when the outbox enqueue fails; src/modules/identity/application/registration/register-identity.use-case.spec.ts › lets an enqueue failure escape so the transaction rolls the challenge back too |
| Retry of the same registration before the first challenge expires | Governed unchanged by the existing pending-challenge-reuse repository logic (PR #401's fix included); this case only changes how the code is delivered | src/modules/identity/application/registration/register-identity.use-case.integration-spec.ts › never supersedes the first registrant pending code when a different registrant claims the same unconfirmed address (OX-S8) |
OX-S2 — Enqueue the add-sign-in-method verification code atomically with its challenge
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: signed-in caller adds a reachable identifier | Challenge and outbox message committed together; the old abandonAddition compensating transaction is retired | src/modules/identity/application/sign-in-methods/add-sign-in-method.use-case.spec.ts › enqueues the sealed code inside the same transaction as the claim, and never notifies synchronously; test/e2e/sign-in-methods.e2e-spec.ts › adds a phone to an email account and confirms it, listing every way in |
| Concurrent second add-sign-in-method request for the same caller before the first confirms | Governed unchanged by the existing claim-uniqueness invariant | src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › lets exactly one of two identities simultaneously claiming the same identifier win, serialized by the per-identifier advisory lock |
| Enqueue insert fails | Whole transaction rolls back; no orphaned challenge, no compensating transaction needed | src/modules/identity/application/sign-in-methods/add-sign-in-method.use-case.spec.ts › lets an enqueue failure escape so the transaction rolls the claim back too |
OX-S3 — Enqueue the password-recovery code atomically with its challenge, preserving anti-enumeration
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: known identifier | Challenge opened, outbox message enqueued, response { challengeExpiresAt } | src/modules/identity/application/password-recoveries/request-password-recovery.use-case.integration-spec.ts › leaves exactly one pending outbox message bound to the challenge it opened; src/modules/identity/application/password-recoveries/request-password-recovery.use-case.spec.ts › enqueues inside the same transaction, right after the challenge opens |
| Unknown identifier | Nothing opened, nothing enqueued, response identical in shape and timing to the known-identifier case | src/modules/identity/application/password-recoveries/request-password-recovery.use-case.integration-spec.ts › enqueues nothing for an identifier nobody registered; › answers a known and an unknown identifier with the exact same shape; test/e2e/password-recovery.e2e-spec.ts › enqueues nothing for an address nobody registered |
| Enqueue insert fails for a real identifier | Hidden behind the same 201 outcome as the unknown-identifier case (surfacing it would reveal that the account exists) and logged at error level with only the error name and code, never the message, the identifier or the code (the challenge itself rolls back with the failed enqueue, inside the same transaction) | src/modules/identity/application/password-recoveries/request-password-recovery.use-case.failures.spec.ts › hides an enqueue failure for an existing account behind the exact outcome of an unknown one; › raises the hidden enqueue failure to an error carrying only its name and code; › never carries a message, an identifier or a code into the error log; src/modules/identity/application/password-recoveries/request-password-recovery.use-case.spec.ts › logs a swallowed enqueue failure with its name and code but never its message, whatever the message carries; src/modules/identity/application/password-recoveries/request-password-recovery.use-case.integration-spec.ts › rolls the recovery challenge back when the outbox enqueue fails, but still answers accepted (anti-enumeration) |
| Challenge insert failure, or commit failure of a transaction that wrote a challenge, for a real identifier | Hidden behind the same 201 outcome and logged at error level with only the error name and code, for the same reason; the use case marks the account as known as soon as the lookup answers, before the insert runs | src/modules/identity/application/password-recoveries/request-password-recovery.use-case.failures.spec.ts › hides a failure of the challenge insert for an existing account behind the exact outcome of an unknown one; › raises a failed challenge insert to an error carrying only its name and code; › hides a commit failure of a transaction that wrote a challenge, and raises it to an error |
| Failure that does not depend on whether the account exists: the transaction cannot begin, the account lookup throws, or the commit of a transaction that found no account fails | Reported, never answered 201: the same 503 with Retry-After for a transient database failure and the same 500 for any other, for a registered and an unregistered address alike | src/modules/identity/application/password-recoveries/request-password-recovery.use-case.failures.spec.ts › reports the failure of the account lookup instead of answering accepted; › reports a transaction that cannot begin instead of answering accepted; › reports a commit failure of a transaction that found no account, because it says nothing about existence; test/e2e/password-recovery-infrastructure-failure.e2e-spec.ts › answers a transient database failure with the same 503 and Retry-After for a registered and an unregistered address; › answers any other database failure with the same 500 for a registered and an unregistered address |
OX-S4 — Enqueue the step-up verification code atomically with its challenge (fixes notify-before-commit)
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: a reachable candidate exists | Challenge and outbox message committed together; nothing sent synchronously | src/modules/identity/application/step-ups/open-step-up.use-case.integration-spec.ts › leaves exactly one pending outbox message bound to the challenge it opened; src/modules/identity/application/step-ups/open-step-up.use-case.spec.ts › enqueues a code of the length the policy sets, inside the same transaction as the challenge (fixes notify-before-commit) |
| No notifiable candidate at all | Refused before any transaction opens (StepUpUnreachableError/ChallengeChannelUnavailableError) | src/modules/identity/application/step-ups/open-step-up.use-case.integration-spec.ts › refuses when the identity has no verified identifier at all, without opening a transaction; src/modules/identity/application/step-ups/open-step-up.use-case.spec.ts › refuses an identity with no verified identifier to reach, without hashing anything |
| Database failure after the code is generated but before commit | Whole transaction rolls back; nothing was sent, so no live code exists outside the database — the bug OX-S4 exists to close | src/modules/identity/application/step-ups/open-step-up.use-case.integration-spec.ts › rolls the step-up challenge back when the outbox enqueue fails, committing neither; src/modules/identity/application/step-ups/open-step-up.use-case.spec.ts › lets an enqueue failure escape so the transaction rolls the challenge back too |
OX-S5 — Encrypted OTP custody in the security outbox
G2 binds the AAD context to the outbox message's own id and purpose (not the challenge id the catalog above originally proposed): id is the ciphertext row's own primary key, so it is unique by construction, and purpose adds a second independent binding on top, the same belt-and-suspenders reasoning OX-S5's own narrative already applies to the challenge id. security-outbox-otp-cipher.service.ts also carries the otp_key_version column's key-version check, rejecting any version other than the one currently configured before attempting decryption at all — key rotation itself stays out of scope, unchanged from the catalog's own call. foundation/crypto/secret-material-cipher.ts gained sealParts/ openParts underneath (nonce and ciphertext returned/accepted as separate buffers, matching otp_ciphertext/otp_nonce) without changing seal/ open's behavior for TOTP, which now delegate to the new pair.
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: seal under the security-outbox key with the message id and purpose as AAD context, then open with the same context | Round-trips to the original code | src/modules/identity/infrastructure/security-outbox/security-outbox-otp-cipher.service.spec.ts › round-trips a sealed OTP back to the original bytes |
| Two seals of the same OTP | Different nonce each time | src/modules/identity/infrastructure/security-outbox/security-outbox-otp-cipher.service.spec.ts › produces a different nonce for two seals of the same OTP |
open() called with a ciphertext copied onto a different message id | Throws (authentication-tag mismatch); never returns a wrong-but-plausible code | src/modules/identity/infrastructure/security-outbox/security-outbox-otp-cipher.service.spec.ts › fails to open when the ciphertext is copied onto a different message id |
open() called with a ciphertext copied onto a different purpose | Throws, same as the message id case | src/modules/identity/infrastructure/security-outbox/security-outbox-otp-cipher.service.spec.ts › fails to open when the ciphertext is copied onto a different purpose |
open() called against a tampered ciphertext byte | Throws | src/modules/identity/infrastructure/security-outbox/security-outbox-otp-cipher.service.spec.ts › fails to open with a tampered ciphertext byte |
open() called with a key different from the one used to seal | Throws | src/modules/identity/infrastructure/security-outbox/security-outbox-otp-cipher.service.spec.ts › fails to open with the wrong encryption key |
open() called with a key version other than the one currently configured | Throws a distinct, clearly-named error before any decryption attempt | src/modules/identity/infrastructure/security-outbox/security-outbox-otp-cipher.service.spec.ts › fails to open with an unsupported key version, rejected before any decryption attempt |
| Any decryption failure | Never includes the plaintext OTP or the configured key in its message | src/modules/identity/infrastructure/security-outbox/security-outbox-otp-cipher.service.spec.ts › never includes the plaintext OTP in a decryption failure message; › never includes the configured encryption key in a decryption failure message |
OX-S6 — Delete OTP ciphertext immediately after the worker sends it
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: provider accepts the send | publication_state='published' and the ciphertext row deleted, in one transaction | database/tests/contracts/security-outbox-contracts.ts › OX-S6/OX-W6 published outcome empties the ciphertext and stamps published_at |
| Provider call succeeds but the process crashes before recording the attempt | Ciphertext not deleted; lease eventually expires and another worker resends the same code — an accepted at-least-once duplicate | — |
| Crash between the delete and the caller observing success | Cannot happen partially; delete and state flip are the same transaction | — |
OX-S7 — Delete unsent OTP ciphertext when its challenge expires
| Case | Expected answer | Proving test |
|---|---|---|
Happy path: sweep finds an expired-challenge message still pending/leased | Dead-lettered (reason='challenge_expired'), ciphertext deleted | database/tests/contracts/security-outbox-contracts.ts › OX-S7 the expiry sweep dead-letters an expired, never-claimed message |
| Sweep runs while a worker actively holds the message's lease | Worker's in-flight send wins; sweep only claims rows no worker is touching | database/tests/contracts/security-outbox-contracts.ts › OX-S7 the expiry sweep leaves a live-leased message for the worker holding it to finish |
| Challenge expires, sweep has not run yet | Message sits pending with a stale, decryptable ciphertext until the next tick — the maximum window is not bounded by any owner decision | — |
OX-S8 — Resend a security verification code
The supersede key is purpose + destination_kind + destination_snapshot (normalized the same way identity.login_identifiers.normalized_value is), never challenge_id: a resend always opens a new challenge (proven by identity-registration.repository.integration-spec.ts › gives a second claim its own code without touching the first, which leaves both the old and the new challenge live with different ids), so anchoring on challenge_id can never find the older message to cancel. Supersede only ever touches a pending row; a leased row is being sent right now and is never rewritten by a newer enqueue.
The sign_up exception (revision 40). sign_up messages are never superseded, by either supersede path (the enqueue-time UPDATE and the retryable-error-yields-to-a-newer-resend check in record_security_outbox_attempt), and the partial unique index (security_outbox_messages_one_pending_per_purpose_destination_uq) excludes purpose = 'sign_up' so more than one pending row can coexist for the same destination. Confirming a sign_up challenge requires the registrant's own candidate password (VerifyIdentifierUseCase.claimsThatAreTheirs), so an attacker's code is never usable against the victim's challenge — but before this revision, an attacker registering a victim's address would supersede and dead-letter the victim's own still-undelivered code, denying the victim their registration outright (a resend-triggered denial of service). The other three purposes keep superseding unchanged: add_sign_in_method and step_up are only ever requested by the identity's own authenticated caller, and any password_recovery code works for the account regardless of who requested it, so superseding there never locks anyone out.
The invitation exception (revision 63). An invitation is a fifth purpose (D27, J1-1). Like sign_up, it is never superseded, by either supersede path, and the partial unique index excludes it: the key is purpose + destination, and two different organizations may legitimately invite the same address, so a newer invitation message from organization B must never dead-letter the pending one from organization A. A message of this purpose names an invitation instead of a challenge (security_outbox_messages_source_chk), the claim and the purge read its expiry from the invitation, and staff cannot reissue it. Since revision 64 (J1-1b) the claim and the attempt also read the invitation: a message of a revoked, accepted or expired invitation is never leased and never requeued, and the staff dead-letter list includes invitation messages. The four existing purposes keep the supersede behaviour they already had.
| Case | Expected answer | Proving test |
|---|---|---|
Resend requested while the previous message for the same purpose+destination is still pending (a different challenge — resends never reuse one; add_sign_in_method/password_recovery/step_up only, never sign_up) | Superseded: the older message is marked dead_lettered (reason superseded_by_resend) before the new one is inserted; only the newest is ever sent (OB7) | database/tests/contracts/security-outbox-contracts.ts › OB7 a resend for the same purpose and destination supersedes the older pending message even though it belongs to a different challenge |
A second sign_up claim for the same destination arrives while the first registrant's message is still pending (a different registrant, a different challenge) | Never superseded: both messages stay pending, so each registrant's own code keeps its own row and neither ever reaches the other's candidate password (the sign_up exception) | database/tests/contracts/security-outbox-contracts.ts › OB7 a sign-up for an address with a still-pending challenge never supersedes it, so the first registrant still gets their own code |
Same purpose, a different destination | Both messages stay pending; supersede never crosses destinations | database/tests/contracts/security-outbox-contracts.ts › OB7 same purpose but a different destination leaves both messages pending |
Resend requested after the previous message already reached published | Fresh enqueue, no ambiguity | — |
An invitation message from organization B arrives while organization A's invitation message to the same address is still pending | Never superseded: A's message stays pending with its ciphertext, and two pending invitation messages to one address coexist | database/tests/contracts/invitation-delivery-contracts.ts › J1-1 organization B's invitation message does not supersede A's pending one; database/tests/contracts/invitation-delivery-contracts.ts › J1-1 two pending invitation messages to one address coexist at the table level |
| An in-flight invitation message fails with a retryable error while another organization's invitation message to the same address is pending | Returns to pending with its failure class, never dead-lettered as superseded_by_resend | database/tests/contracts/invitation-delivery-contracts.ts › J1-1 a retryable failure of A's message is not a supersede by B's pending message |
| The claim meets an invitation message whose invitation was revoked, accepted or has expired (pending, or leased with its lease expired) | Dead-lettered as invitation_revoked, invitation_accepted or invitation_expired with the ciphertext wiped and never leased, so no new delivery attempt exists; an open invitation's message is leased as before | database/tests/contracts/invitation-delivery-review-closed-contracts.ts › J1-1b the claim dead-letters and wipes the message of an invitation that is ${message.kind} instead of leasing it; database/tests/contracts/invitation-delivery-review-closed-contracts.ts › J1-1b a revoked invitation whose lease expired is dead-lettered by the next claim, never delivered again; database/tests/contracts/invitation-delivery-review-closed-contracts.ts › J1-1b the claim still leases the message of an open invitation |
| An in-flight invitation message fails with a retryable error after its invitation was revoked or expired | Fails closed: dead_lettered with the invitation's failure class and the ciphertext erased, never pending. A message the worker already handed to the provider cannot be recalled (the revoked invitation's token is refused at acceptance); what is guaranteed is that no new attempt is offered and no ciphertext survives | database/tests/contracts/invitation-delivery-review-closed-contracts.ts › J1-1b a retryable error on an invitation that is ${how} dead-letters and wipes instead of returning to pending; database/tests/contracts/invitation-delivery-review-closed-contracts.ts › J1-1b no new delivery attempt is offered after an invitation that is ${how} failed |
An in-flight invitation message is reported published or failed after its invitation was revoked | Recorded as reported: published stays published and wiped, an explicit failure keeps the worker's class | database/tests/contracts/invitation-delivery-review-closed-contracts.ts › J1-1b a message already handed to the provider stays published and wiped after the revocation; database/tests/contracts/invitation-delivery-review-closed-contracts.ts › J1-1b an explicit failure keeps the class the worker reported |
| The purge meets the pending message of an accepted invitation | Dead-lettered as invitation_accepted, ciphertext erased | database/tests/contracts/invitation-delivery-review-closed-contracts.ts › J1-1b the purge retires and wipes the message of an accepted invitation |
| The claim returns an invitation message | Two columns are appended after the existing ones, organization_name and invitation_id, NULL for a challenge message; the worker's explicit column list still reads it | database/tests/contracts/invitation-delivery-review-closed-contracts.ts › J1-1b the claim returns the organization name and the invitation id of an invitation message; database/tests/contracts/invitation-delivery-review-closed-contracts.ts › J1-1b the claim returns no organization name and no invitation id for a challenge message; database/tests/contracts/invitation-delivery-review-closed-contracts.ts › J1-1b the existing claim columns keep their order and the new ones come last; database/tests/contracts/invitation-delivery-review-closed-contracts.ts › J1-1b the worker claim query, which names its columns, still reads the extended claim |
| Staff lists an invitation dead letter | Listed with its failure class, masked destination and the invitation expiry in the unchanged shape, without the address, token or ciphertext | database/tests/contracts/invitation-delivery-review-closed-contracts.ts › J1-1b the staff dead-letter listing shows an invitation dead letter with its class and the invitation expiry; database/tests/contracts/invitation-delivery-review-closed-contracts.ts › J1-1b the staff listing keeps its shape and never carries the address, the token or the ciphertext; src/modules/identity/application/security-outbox-operations/security-outbox-operations.invitation.integration-spec.ts › lists an invitation dead letter in the same shape as any other, masked, without a token, a ciphertext or the address; test/e2e/platform-admin-security-outbox-invitation.e2e-spec.ts › lists an invitation dead letter with a 200, the invitation purpose and no secret |
| The worker sends an invitation message (J1-W) | The sealed token is opened with the message id and the invitation purpose as context, the message is built from the organization name in the claim, the expiry and a link <INVITATION_LINK_BASE_URL>/join#token=<token> (the token in the fragment, never in the path or the query), sent by email with a subject or by WhatsApp without one, then recorded published with the ciphertext erased by identity.record_security_outbox_attempt; the opened plaintext is zeroed once the send settles. The sender is faked in every test | src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.integration-spec.ts › sends the exact link with the token in the fragment, publishes the message and erases the ciphertext; src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.use-case.spec.ts › opens the sealed token bound to the message id and the invitation purpose; src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.use-case.spec.ts › sends the organization name, the link with the token in the fragment and the expiry, then publishes; src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.use-case.spec.ts › sends the same invitation to a phone number as a subject-less text; src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.use-case.spec.ts › zeroes the opened token once the send settles; src/modules/identity/domain/security-outbox/security-outbox-invitation-copy.spec.ts › carries the token in the URL fragment and never in the path or the query, so no server or proxy log sees it |
| The token reaches no log, record call or failure class (J1-W) | Not on success, not when the provider error echoes the whole message or the link, not in a malformed-row log | src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.use-case.spec.ts › never puts the token or the link in a log line, a record call or a failure class when the provider error echoes them; src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.use-case.spec.ts › never logs the token on the happy path either; src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.integration-spec.ts › never logs the token even when the provider error echoes the whole message, and keeps the message for a retry; src/modules/identity/infrastructure/security-outbox/security-outbox-dispatch.repository.invitation.spec.ts › logs a malformed invitation row without its organization name, destination, ciphertext or nonce |
| The worker meets an invitation that was revoked before the claim, or revoked while its message is leased (J1-W) | Nothing is sent for the first; the message is dead-lettered as invitation_revoked with the ciphertext erased. For the second, a failed send is dead-lettered by the SQL attempt record instead of returning to pending. An invitation that expired while the message waited is dead-lettered by the worker as invitation_expired without a send; a claim that returns no rows sends nothing | src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.integration-spec.ts › never sends the message of an invitation revoked before the claim, and dead-letters it with the ciphertext erased; src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.integration-spec.ts › fails closed when the invitation is revoked while its message is leased: a failed send is dead-lettered, never requeued; src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.use-case.spec.ts › dead-letters an invitation that expired while it waited, as invitation_expired, without sending anything; src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.use-case.spec.ts › returns an empty tally and sends nothing when the claim returns no rows |
| The worker has no invitation link base URL (J1-W) | Optional in every environment: the worker boots in production and staging without INVITATION_LINK_BASE_URL and logs one startup warning naming the variable (no value). Without it, an invitation message is not opened and not sent: it is reported as a retryable failure with the class invitation_link_unconfigured (SQL accepts any non-empty class), retries with the normal bounded backoff and is dead-lettered after the last attempt; the four OTP purposes are unaffected. Development and test fall back to http://localhost:3000 | src/foundation/config/schema/invitation-link-base-url.schema.spec.ts › lets the worker boot in %s without it, so OTP delivery never depends on an invitation setting; src/foundation/config/app-config.service.spec.ts › reports no invitation link base URL in %s when none is set, never a localhost one; src/foundation/config/app-config.service.spec.ts › falls back to the local machine for the invitation link base URL in %s when none is set; src/modules/identity/presentation/jobs/security-outbox-dispatch.job.invitation-link.spec.ts › logs exactly one warning that names the variable and carries no value when it is not configured; src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.use-case.spec.ts › neither opens the token nor sends, and reports a retryable failure with its own class and the normal backoff; src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.use-case.spec.ts › dead-letters the message once the bounded attempts are exhausted; src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.use-case.spec.ts › leaves every OTP purpose completely unaffected; src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.use-case.spec.ts › still sends an invitation once the variable is configured; src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.invitation.integration-spec.ts › without the base URL sends nothing, opens nothing, keeps the sealed token and retries with its own failure class |
| The claim returns a row the worker cannot map as an invitation or a challenge message (J1-W) | A row with neither a challenge nor an invitation, an invitation without its organization name, or a row mixing both is dead-lettered as malformed_claim_row and never reaches a sender; an unknown purpose still is | src/modules/identity/infrastructure/security-outbox/security-outbox-dispatch.repository.invitation.spec.ts › dead-letters as malformed a row that $what, and never lets it reach a sender; src/modules/identity/infrastructure/security-outbox/security-outbox-dispatch.repository.invitation.spec.ts › still dead-letters an unknown purpose as malformed; src/modules/identity/infrastructure/security-outbox/security-outbox-dispatch.repository.invitation.spec.ts › maps a challenge row with a null organization name and a null invitation id unchanged |
Happy path (application layer): add_sign_in_method/password_recovery/step_up resend by simply calling the same use case again for a still-pending challenge/claim | The older pending message is superseded; only the newest is ever decryptable | src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › still resends its own challenge when the same identity retries its own pending claim, superseding the older message (OX-S8); src/modules/identity/application/password-recoveries/request-password-recovery.use-case.integration-spec.ts › supersedes the older pending message when the same address requests recovery again (OX-S8); src/modules/identity/application/step-ups/open-step-up.use-case.integration-spec.ts › supersedes the older pending message when the same identity opens a second step-up (OX-S8) |
Happy path (application layer): a different registrant claims sign_up again for the same still-unconfirmed address | Never superseded — both codes stay pending and decryptable, and the first registrant still confirms with their own code and their own password | src/modules/identity/application/registration/register-identity.use-case.integration-spec.ts › never supersedes the first registrant pending code when a different registrant claims the same unconfirmed address (OX-S8) |
A resend arrives while the previous message is leased (a worker is sending it right now) | Never superseded — the leased row is left untouched, the new message is enqueued pending alongside it, and the worker can still record the leased one as published afterward. Both codes can reach the recipient in this window; accepted as the same at-least-once trade-off OX-S6/OB9 already name | database/tests/contracts/security-outbox-contracts.ts › OB7 a leased message in flight is not superseded by a newer enqueue; it stays leased alongside the new pending one; › the worker can still record the in-flight message as sent after the resend |
The in-flight message fails with a retryable error after a newer resend is already pending (add_sign_in_method/password_recovery/step_up only, never sign_up) | Retired as dead_lettered with superseded_by_resend and its ciphertext erased; it never returns to pending, so the partial unique index is never violated and only the newest code can go out | database/tests/contracts/security-outbox-contracts.ts › OB7 a retryable failure of an in-flight message that a newer resend already replaced retires it instead of queueing a second code |
An in-flight sign_up message fails with a retryable error while a different registrant's message is pending for the same destination | Never treated as superseded: it requeues to pending with the next retry time like any ordinary retryable failure, because the other pending row belongs to a different registrant, not a resend of this one | database/tests/contracts/security-outbox-contracts.ts › a sign-up retryable failure requeues even while a different registrant has a pending message for the same address, never mistaking it for a supersede |
OX-S9 — API response contract once delivery is asynchronous
| Case | Expected answer | Proving test |
|---|---|---|
| Sign-up response, once enqueueing replaces the synchronous provider call | challengeDelivery is removed outright; the response reports { challengeExpiresAt } only, no compatibility field (OB8) | test/e2e/sign-up.e2e-spec.ts › registers an email identity and answers 201 inside the envelope, accepted with only an expiry; src/modules/identity/presentation/controllers/sign-ups.controller.spec.ts › answers accepted with the identity and an ISO expiry, and nothing about delivery |
| Add-sign-in-method response | Unaffected — it never reported synchronous delivery status | test/e2e/sign-in-methods.e2e-spec.ts › adds a phone to an email account and confirms it, listing every way in |
| Password-recovery and step-up responses | Unaffected — both already answered { challengeExpiresAt } only, before OX-S1 touched anything | test/e2e/password-recovery.e2e-spec.ts › walks the whole recovery flow: lose it, recover it, sign in with the new one, fail with the old one; test/e2e/step-up.e2e-spec.ts › proves the code queued for a verified identifier and hands back an elevation token bound to this session |
Worker (OB1/OB6)
OX-W1 — Claim a batch of due outbox rows with SKIP LOCKED and a lease
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: due messages exist in both the identity outbox and one or more organizations' tenant outbox | Claimed with a fresh lease in both, same shape as events.claim_outbox_batch | — |
| Two worker instances (or two ticks) call claim concurrently | Disjoint claimed sets; neither blocks the other | database/tests/concurrency/security-outbox-concurrency.ts › concurrentClaimersNeverLeaseTheSameRow (NINAKU_SECURITY_OUTBOX_CONCURRENCY_OK) |
| Empty backlog | Zero rows claimed, not an error | — |
The worker never throttles a destination (removed after review, revision 42 — not named in OB1–OB11 or OUTBOX_USE_CASES.md, so not counted in Totals). Revision 41 had given identity.claim_security_outbox_messages a per-destination send cap (p_send_cap_per_destination/p_send_cap_window_seconds, defaults 5 per hour), meant to bound a sign_up flood against one address now that sign_up is never superseded (OB7, revision 40). Review found it made the flood worse, not better: it counted only already-published rows (never leased ones or rows the same claim was about to pick), was evaluated once before the whole batch was chosen — so one claim could still lease every pending sign_up row for a capped destination in a single call — and its 3600s window vastly outlives a challenge's own lifetime of minutes, so a message the cap deferred could never be sent before its challenge expired and the expiry sweep (OX-S7) dead-lettered it. An attacker filling the cap with five throwaway sign-ups could permanently block the victim's own code from ever being sent — the exact denial of service OB7 exists to prevent, reopened from another angle. The cap was removed in migration 0033 rather than patched: any cap the worker enforces on itself can be exhausted by whoever controls how many messages exist for a destination, which is exactly the attacker. The worker now claims every due row for a destination without exception; flood protection belongs at the HTTP edge (rate limiting per IP and per destination), tracked as a separate follow-up, not built here.
| Case | Expected answer | Proving test |
|---|---|---|
Several pending sign_up messages exist for one destination, even after messages already published to it would have reached the old cap | All claimable in the same call; the worker never defers or drops one for its destination alone | database/tests/contracts/security-outbox-contracts.ts › the worker never throttles a destination: even after 5 messages already published to it (the old cap), several more pending messages for the same destination are all still claimable |
OX-W2 — Worker crashes mid-send; the lease expires and the message is reclaimed
| Case | Expected answer | Proving test |
|---|---|---|
| Worker A claims, crashes before recording; lease expires | Worker B's next claim reclaims the same row, attempt_count unchanged from A's claim | database/tests/contracts/security-outbox-contracts.ts › OX-W2 an expired lease is reclaimed |
| Worker A recovers after B already reclaimed and is mid-send | A's stale lease token no longer matches; a late recording attempt from A is refused | — |
| Lease duration shorter than a genuinely slow (not crashed) provider call | Message reclaimed and sent twice even though A was still alive — bounded by choosing lease_seconds well above SEND_TIMEOUT_MS | — |
OX-W3 — Two workers never send the same message twice under a live lease
| Case | Expected answer | Proving test |
|---|---|---|
| Worker holds a live, unexpired lease and records a result | Succeeds | database/tests/contracts/security-outbox-contracts.ts › OX-S6/OX-W6 published outcome empties the ciphertext and stamps published_at |
| Worker's lease expired moments before it records a result, no one else has claimed it yet | Refused ("lease expired before result recording"), even though nobody raced it | — |
A second attempt to record a terminal outcome for an already-published/dead_lettered message | Refused — the row is no longer leased | database/tests/contracts/security-outbox-contracts.ts › OX-W3 a second attempt result for an already-published message is refused; › OX-W3 a dead-lettered message cannot be recorded again |
OX-W4 — Retry with backoff and honor the provider's Retry-After
| Case | Expected answer | Proving test |
|---|---|---|
Happy path: retryable failure, no Retry-After from the provider | available_at set per OB4's schedule for that attempt number (5 s / 30 s / 2 min / 10 min / 30 min) | — |
Retryable failure, provider returns a Retry-After | That value overrides OB4's own schedule for that attempt | — |
Provider's Retry-After is in the past or malformed | Falls back to OB4's own schedule; a non-future retry_after_at is never accepted | — |
| Attempt 5 fails retryably | Dead-lettered (OX-W6), never a 6th scheduled retry | — |
OX-W5 — Classify provider failures into a retry decision
| Case | Expected answer | Proving test |
|---|---|---|
THROTTLED | Retryable, honoring Retry-After if given | — |
UNAVAILABLE | Retryable | — |
INDETERMINATE | Retryable like THROTTLED/UNAVAILABLE; at-least-once delivery accepts the duplicate-send risk over the strictly worse alternative of locking the person out (OB9), bounded by OB4's 5-attempt cap and OX-W7's challenge-expiry check | — |
REJECTED | Not retryable — dead-lettered immediately, even on attempt 1 | — |
REJECTED reaching attempt 1, never entering the retry loop | Dead-lettered without ever computing a retry_after_at | — |
OX-W6 — Exhausted retries dead-letter the message
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: attempt 5 fails (retryable or not) | Terminal dead_lettered, one dead-letter row inserted | — |
REJECTED on attempt 1 | Reaches the same terminal state immediately, skipping the retry loop | database/tests/contracts/security-outbox-contracts.ts › OX-W6 a permanent failure dead-letters the message and empties the ciphertext |
| A worker bug retries past attempt 5 | Not caught by any table constraint — attempt_count past 5 is not itself refused by the schema; only worker-code correctness prevents it | — |
| The worker's own code throws unexpectedly while handling one claimed message (a sender or OTP-opener bug, anything that is not a provider failure) | Recorded through the same retry path as a provider failure: attempt_count +1, available_at per OB4's schedule, last_failure_class='unexpected_worker_error'; the rest of the batch is still processed, and the message no longer cycles through lease expiry without counting | src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.unexpected-failure.use-case.spec.ts › records an unexpected send exception as a retryable attempt on the first backoff step and keeps going with the batch; src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.unexpected-failure.use-case.spec.ts › follows the documented backoff for every attempt number instead of a fixed delay; src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.unexpected-failure.use-case.spec.ts › treats an OTP opener that throws as an unexpected worker failure and never sends; src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.unexpected-failure.integration-spec.ts › counts every unexpected failure as an attempt with the documented backoff and dead-letters on the fifth, erasing the ciphertext, while a healthy message of the same batch is published |
| That unexpected failure happens on attempt 5 | Dead-lettered with the same class and the ciphertext erased, exactly as a provider failure on attempt 5; a poison message cannot retry forever | src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.unexpected-failure.use-case.spec.ts › dead-letters an unexpected exception on the fifth attempt with the same safe class and no retry time; src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.unexpected-failure.integration-spec.ts › counts every unexpected failure as an attempt with the documented backoff and dead-letters on the fifth, erasing the ciphertext, while a healthy message of the same batch is published |
| The failure text and the log of an unexpected failure | The stored class is the fixed code, never the exception text; the log carries the message id, the purpose and the error name and code, never the exception text, OTP, ciphertext, nonce or destination; the recovered OTP is wiped from memory | src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.unexpected-failure.use-case.spec.ts › stores only the fixed class, never the exception text, the OTP or the destination; src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.unexpected-failure.use-case.spec.ts › logs the unexpected failure with the message id but never the exception text, the ciphertext, the nonce, the OTP or the destination; src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.unexpected-failure.use-case.spec.ts › wipes the recovered OTP from memory even when the send throws |
| Recording that unexpected failure itself fails (database unavailable), or recording a message that was already sent fails | Logged without secrets and the batch continues; lease expiry stays the fallback; a message already sent is never turned into a second attempt that would resend the code | src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.unexpected-failure.use-case.spec.ts › logs and carries on when recording the unexpected failure itself fails, leaving the lease expiry as the fallback; src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.unexpected-failure.use-case.spec.ts › does not turn a failed recording of a sent message into a second attempt that would resend the code |
| A provider failure after this change | Unchanged: its own class and its own backoff | src/modules/identity/application/security-outbox-dispatch/dispatch-security-outbox-batch.unexpected-failure.integration-spec.ts › keeps a provider failure on its own class and its first backoff step |
OX-W7 — An OTP message's own expiry stops its retries
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: claimed message's challenge already expired before the send attempt | Send skipped; dead-lettered with error_code='challenge_expired', distinct from a provider failure | — |
| Challenge expires in the window between the expiry check and the provider call | Code sent for an already-useless challenge; accepted as a narrow, harmless race | — |
| Challenge already consumed (person entered the code correctly) before the worker sends | Worker still attempts to send — "cancel on early success" is a named, unresolved gap, not designed by OB1–OB6 | — |
Database-level backstop: record_security_outbox_attempt is asked to record a retryable_error for a message whose challenge already expired (the race between the worker's own pre-send check and the actual provider call, or a worker that skips the pre-send check entirely) | Converted to failed/challenge_expired in the same call, never requeued with a scheduled available_at for a challenge nobody can complete any more | database/tests/contracts/security-outbox-contracts.ts › OB4/OX-W7 a retryable outcome for an already-expired challenge is dead-lettered, not requeued |
OX-W8 — OTP ciphertext decryption failure
| Case | Expected answer | Proving test |
|---|---|---|
Happy path: open() throws (corrupted envelope or context mismatch) | Immediately dead-lettered (error_code='decryption_failed'), retry loop skipped entirely | — |
| Logged severity | Reaches security monitoring, not only ordinary operational logs | — |
| Every message under a since-rotated-away key | Spike of decryption_failed dead letters — the intended signal of an out-of-step key rotation (rotation itself is out of scope) | — |
| A single corrupted ciphertext row (storage bit-rot, manual data-fix mistake) | Dead-lettered alone; does not affect any other message's decryption | — |
OX-W9 — Worker role least privilege
| Case | Expected answer | Proving test |
|---|---|---|
Happy path: ninaku_worker calls a claim/record function within its granted set | Succeeds | database/tests/contracts/security-outbox-contracts.ts › OX-W1 ninaku_worker claims a due batch |
ninaku_worker attempts SELECT on identity.password_credentials/totp_methods/recovery_codes | Refused — no grant exists | database/tests/contracts/security-outbox-contracts.ts › OX-W9 ninaku_worker cannot select password credentials; › OX-W9 ninaku_worker cannot select TOTP methods; › OX-W9 ninaku_worker cannot select recovery codes |
ninaku_worker's Railway credential for the separate worker service | Requires explicit owner approval at deploy time, per AGENTS.md §10; this catalog does not itself authorize provisioning it | — |
G4: ninaku_worker calls purge_expired_security_outbox_ciphertexts/purge_security_outbox_retention (the OX-S7/OX-O3 sweeps), and, even with the retention sweep's own app.security_outbox_retention_sweep escape hatch set, still cannot raw-DELETE the table directly | Both sweep calls succeed within the granted set; a raw DELETE is refused, 42501, before it ever reaches the guard trigger, because ninaku_worker has no direct table privilege at all | database/tests/contracts/security-outbox-contracts.ts › OX-S7 the expiry sweep dead-letters an expired, never-claimed message; › OB11/OX-O3 retention deletes only published rows past 30 days and dead letters past 90 days; › ninaku_worker has no direct table privilege, so it cannot raw-DELETE either |
OX-W10 — The API role never dispatches
| Case | Expected answer | Proving test |
|---|---|---|
ninaku_runtime attempts claim_security_outbox_messages/events.claim_outbox_batch directly | Refused, 42501, before touching any row | database/tests/contracts/security-outbox-contracts.ts › OX-W10 ninaku_runtime cannot claim a batch; › OX-W10 ninaku_runtime cannot record an attempt outcome (events.claim_outbox_batch itself unproven — no tenant-outbox producer exists yet) |
ninaku_runtime attempts a raw SELECT/DELETE on security_outbox_ciphertexts | Refused — it only ever receives INSERT | database/tests/contracts/security-outbox-contracts.ts › OX-W10 ninaku_runtime cannot select the security outbox table (this catalog's storage shape folds the ciphertext into security_outbox_messages itself, so the same refusal covers the whole row, not a separate ciphertext table) |
G4: ninaku_runtime attempts purge_expired_security_outbox_ciphertexts/purge_security_outbox_retention directly | Refused, 42501 — neither sweep function is ever granted to ninaku_runtime | database/tests/contracts/security-outbox-contracts.ts › OX-W9 ninaku_runtime cannot run the expiry sweep; › OX-W9 ninaku_runtime cannot run the retention sweep |
Operability
OX-O1 — Observe backlog and dead letters
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: counts per publication state for the security outbox | One row per state (pending, leased, published, dead_lettered), each equal to the table's own count | database/tests/contracts/security-outbox-backlog-contracts.ts › OX-O1 the backlog reports one row per publication state; › OX-O1 the reported pending count equals the table's; src/modules/identity/application/security-outbox-operations/security-outbox-operations.integration-spec.ts › reports every publication state, counting a seeded dead letter; test/e2e/platform-admin-security-outbox.e2e-spec.ts › shows staff one row per state and the oldest pending age |
| Oldest pending message age | Seconds the oldest already-due pending message has waited; a retry scheduled in the future never counts | database/tests/contracts/security-outbox-backlog-contracts.ts › OX-O1 the oldest pending age counts only messages already due |
| No messages exist | Zero for every state and a null age, not an error | database/tests/contracts/security-outbox-backlog-contracts.ts › OX-O1 an empty outbox reports zero for every state and no oldest age |
| Dead-letter list shows what staff need and nothing secret | Purpose, destination kind, masked destination, attempt count, failure class, dead-lettered-at; never the address, ciphertext, nonce or code | database/tests/contracts/security-outbox-backlog-contracts.ts › OX-O1 a listed dead letter shows purpose, kind, masked destination, attempts and class; › OX-O1 the full address never appears in the listing; › OX-O1 the listing has no ciphertext, nonce, key version or code column; src/modules/identity/application/security-outbox-operations/security-outbox-operations.integration-spec.ts › lists a dead letter with a masked destination and never its address; test/e2e/platform-admin-security-outbox.e2e-spec.ts › lists a dead letter masked, pages by keyset and never leaks the address or a secret |
| Destination masking | Defined once in identity.mask_security_outbox_destination: j***@e***.com for email, ***67 for phone, *** for anything malformed | database/tests/contracts/security-outbox-backlog-contracts.ts › OX-O1 masking ${kind} ${expected}; › OX-O1 masking email x***@l***; › OX-O1 masking email ***; › OX-O1 masking phone ***67 |
| Dead letters page newest first by keyset in both directions | after/before cursors with pageInfo, no OFFSET; no dead letter repeated or skipped | database/tests/contracts/security-outbox-backlog-contracts.ts › OX-O1 the first page returns the newest dead letters first; › OX-O1 the page after a position returns only older dead letters; › OX-O1 the page before a position returns the newer ones; src/modules/identity/application/security-outbox-operations/security-outbox-operations.integration-spec.ts › pages dead letters by keyset without repeating or skipping one; test/e2e/platform-admin-security-outbox.e2e-spec.ts › rejects both cursors at once and a tampered cursor |
| A caller with no staff grant (signed in or not), a revoked staff grant, or no actor at all | Refused with 42501 at the database and platform.staff_required (403) at the route; an anonymous caller gets 401 | database/tests/contracts/security-outbox-backlog-contracts.ts › OX-O1 a runtime session with no staff grant cannot read the backlog; › OX-O1 a runtime session with no staff grant cannot list dead letters; › OX-O1 a runtime session with no actor at all cannot read the backlog; › OX-O1 a revoked staff grant cannot read the backlog; src/modules/identity/application/security-outbox-operations/security-outbox-operations.integration-spec.ts › refuses the backlog and the dead-letter list to an identity with no staff grant; test/e2e/platform-admin-security-outbox.e2e-spec.ts › refuses the backlog and the dead-letter list to a signed-in non-staff caller; › refuses an anonymous caller |
ninaku_worker attempts either read | Refused, 42501 — neither function is granted to it | database/tests/contracts/security-outbox-backlog-contracts.ts › OX-O1 ninaku_worker cannot execute the backlog read; › OX-O1 ninaku_worker cannot execute the dead-letter list |
Backlog of the tenant outbox (events.outbox_records) | Same shape, tenant-scoped; no producer or runtime grant exists yet, so it is built with the first business event | — |
OX-O2 — Reissue a dead-lettered message
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: staff reissue a dead letter that failed delivery, challenge still open | A new one-time code is minted for the same challenge, the challenge's code hash is replaced so the old code stops working, and one new pending message carries the new code sealed; the terminal dead letter is never resurrected | database/tests/contracts/security-outbox-reissue-contracts.ts › OX-O2 rule 4: staff reissues a dead-lettered delivery failure; › OX-O2 rule 4: the challenge secret hash is replaced so the old code stops working; › OX-O2 rule 4: a new pending message carries the sealed code for the same challenge, purpose and destination; › OX-O2 rule 4: the terminal dead letter is never resurrected; src/modules/identity/application/security-outbox-operations/reissue-security-outbox-message.use-case.spec.ts › mints one new code, hashes it for the challenge and seals the same code bound to the new message and purpose; src/modules/identity/application/security-outbox-operations/security-outbox-operations.integration-spec.ts › queues exactly one new sealed message for the same challenge when staff reissue a rejected dead letter; test/e2e/platform-admin-security-outbox-reissue.e2e-spec.ts › gives the person a new working code, kills the old one, keeps their password and audits the staff member |
| The plaintext code is never returned, logged, audited or persisted outside its sealed envelope | The response carries only the new message id; the repository receives only the hash and the sealed envelope | src/modules/identity/application/security-outbox-operations/reissue-security-outbox-message.use-case.spec.ts › never hands the plaintext code to the repository; test/e2e/platform-admin-security-outbox-reissue.e2e-spec.ts › gives the person a new working code, kills the old one, keeps their password and audits the staff member |
Rule 1: only a dead_lettered message that failed delivery | superseded_by_resend, challenge_expired, a pending or published message and an unknown id are each refused, nothing hashed or enqueued | database/tests/contracts/security-outbox-reissue-contracts.ts › OX-O2 rule 1: a ${failureClass} dead letter is refused; › OX-O2 rule 1: a challenge_expired dead letter is refused; › OX-O2 rule 1: a pending message is refused; › OX-O2 rule 1: a published message is refused; › OX-O2 rule 1: an unknown message id is refused; src/modules/identity/application/security-outbox-operations/reissue-security-outbox-message.use-case.spec.ts › mints and hashes nothing when the reissue is refused; src/modules/identity/application/security-outbox-operations/security-outbox-operations.integration-spec.ts › refuses a superseded dead letter with its own reason, and a second reissue once a newer message is live; › reports an unknown message and refuses an identity with no staff grant; test/e2e/platform-admin-security-outbox-reissue.e2e-spec.ts › refuses a message that was superseded rather than failed, and one that is not a dead letter; › answers 404 for a message that does not exist |
| A refused reissue is audited | The refused attempt leaves one Audit Record with the same action code platform.security_outbox_message.reissued, outcome = denied, reason_code equal to the refusal (not_a_delivery_failure, challenge_not_open, ...), the staff member as actor and the message as target; the refusal rolls the command back, so the record is written in a second transaction by PlatformRefusalAuditInterceptor | test/e2e/platform-admin-security-outbox-reissue.e2e-spec.ts › refuses a message that was superseded rather than failed, and one that is not a dead letter; › refuses once the person has already confirmed, because the challenge is gone; src/modules/platform/presentation/audit/platform-refusal-audit.interceptor.spec.ts |
| Rule 2: the challenge is expired, consumed, revoked or out of attempts | Refused; a code for a challenge nobody can complete is pointless | database/tests/contracts/security-outbox-reissue-contracts.ts › OX-O2 rule 2: an expired challenge is refused; › OX-O2 rule 2: a consumed challenge is refused; › OX-O2 rule 2: a revoked challenge is refused; › OX-O2 rule 2: an exhausted challenge is refused; › OX-O2 rule 2: completing against an expired challenge is refused too; test/e2e/platform-admin-security-outbox-reissue.e2e-spec.ts › refuses once the person has already confirmed, because the challenge is gone |
| Rule 3: a newer pending, leased or published message already exists for the same purpose and destination | Refused, because the person already has or is about to get a code; a newer dead letter does not block | database/tests/contracts/security-outbox-reissue-contracts.ts › OX-O2 rule 3: a newer ${state} message for the same purpose and destination blocks the reissue; › OX-O2 rule 3: a newer leased message for the same purpose and destination blocks the reissue; › OX-O2 rule 3: a newer published message for the same purpose and destination blocks the reissue; › OX-O2 rule 3: a newer dead letter does not block the reissue; › OX-O2 rule 3: a second reissue of the same message is refused because a newer live message now exists |
| Rule 5: a sign-up challenge keeps its candidate password hash; a purpose with no open challenge cannot be reissued | The candidate hash is byte-for-byte unchanged and the person still confirms with their own password; a closed challenge is refused (rule 2) | database/tests/contracts/security-outbox-reissue-contracts.ts › OX-O2 rule 5: a sign-up candidate password hash is untouched; test/e2e/platform-admin-security-outbox-reissue.e2e-spec.ts › gives the person a new working code, kills the old one, keeps their password and audits the staff member |
| Rule 6: idempotency and concurrency | The same Idempotency-Key returns the stored result and enqueues one message; the same key for another dead letter is refused; two concurrent reissues of one dead letter enqueue at most one; the header is required | test/e2e/platform-admin-security-outbox-reissue.e2e-spec.ts › replays the same Idempotency-Key with the same result and exactly one new message; › enqueues at most one message when the same dead letter is reissued twice at once; › requires an Idempotency-Key; src/modules/platform/application/security-outbox/reissue-security-outbox-message.use-case.spec.ts › replays the same Idempotency-Key with the stored result, one message and one audit record; › refuses the same key reused for a different dead letter without reissuing; database/tests/contracts/security-outbox-reissue-contracts.ts › OX-O2 rule 6: exactly one new message exists after the repeated attempts; › OX-O2 rule 6: two concurrent reissues of the same message enqueue at most one |
| Rule 7: who may reissue | Only an active platform.staff_members row with a fresh step-up (OB10); a signed-in non-staff caller, the affected person themselves, a staff member with no elevation and ninaku_worker are refused with the shape the other staff commands use | database/tests/contracts/security-outbox-reissue-contracts.ts › OX-O2 a runtime session with no staff grant cannot ${label} a reissue; › OX-O2 the affected identity itself cannot open a reissue; › OX-O2 ninaku_worker cannot open a reissue; › OX-O2 a runtime session with no staff grant cannot complete a reissue; › OX-O2 the affected identity itself cannot complete a reissue; › OX-O2 ninaku_worker cannot complete a reissue; › OX-O2 a refused reissue changed nothing; test/e2e/platform-admin-security-outbox-reissue.e2e-spec.ts › refuses a staff member with no fresh elevation; › refuses a signed-in non-staff caller, even fully elevated, and the affected person themselves |
| Staff reissue an invitation dead letter (J1-W) | Refused with the staff refusal invitation_message (422 · platform.security_outbox_reissue_refused): the SQL 23514 · security_outbox_reissue_invitation_message is mapped by constraint name; nothing is queued and the denial is audited | src/modules/identity/application/security-outbox-operations/security-outbox-operations.invitation.integration-spec.ts › refuses staff the reissue of an invitation dead letter with the invitation reason and queues nothing; test/e2e/platform-admin-security-outbox-invitation.e2e-spec.ts › refuses the reissue of an invitation dead letter with a clean 422, audits the denial and queues nothing |
| Audit | Exactly one platform audit record per executed reissue naming the staff member and the dead letter; a refusal or a replay writes none | src/modules/platform/application/security-outbox/reissue-security-outbox-message.use-case.spec.ts › reissues once and writes one audit record naming the staff member and the dead letter; › lets a refused reissue reach the caller and audits nothing; test/e2e/platform-admin-security-outbox-reissue.e2e-spec.ts › gives the person a new working code, kills the old one, keeps their password and audits the staff member |
| Nobody but the reissue path can rewrite a challenge's code hash | The challenge trigger still refuses secret_hash changes for every role, including a role that may update challenges and a session that sets the reissue flag itself | database/tests/contracts/security-outbox-reissue-contracts.ts › OX-O2 ninaku_runtime cannot rewrite a challenge secret directly; › OX-O2 ninaku_runtime cannot rewrite it even after setting the reissue flag itself; › OX-O2 a role that can update challenges still cannot rewrite the secret; › OX-O2 the reissue flag alone never lets any other role rewrite the secret; › OX-O2 the challenge secret is unchanged after the refused rewrites |
OX-O3 — Bounded retention of sent outbox rows
| Case | Expected answer | Proving test |
|---|---|---|
published rows (and their attempt history) older than 30 days | Deleted by the scheduled sweep (OB11) | database/tests/contracts/security-outbox-contracts.ts › OB11/OX-O3 retention deletes only published rows past 30 days and dead letters past 90 days |
dead_lettered rows (and their attempt/dead-letter history) older than 90 days | Deleted by the scheduled sweep (OB11) | database/tests/contracts/security-outbox-contracts.ts › OB11/OX-O3 retention deletes only published rows past 30 days and dead letters past 90 days |
Business events / notifications (OB5 — catalogued, implemented later)
OX-E1 — Append a tenant business domain event atomically with its business transaction
| Case | Expected answer | Proving test |
|---|---|---|
Happy path: a future module calls events.append_domain_event inside its own transaction | Domain event and exactly one outbox row committed together | — |
| Idempotency-key replay with changed semantics | Refused — already proven by the function's own comparison logic, unexercised for lack of a producer | — |
| Insert against a retired event type | Refused — already enforced by validate_domain_event_insert, unexercised for lack of a producer | — |
OX-E2 — A consumer processes a business domain event exactly-once
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: claim → process → record, per consumer | Duplicate transport never becomes a duplicate canonical effect, per the existing per-(organization_id, consumer_id, domain_event_id) uniqueness | — |
| Contract version outside the consumer's subscribed range | Refused — already enforced, unexercised for lack of a consumer | — |
| Inactive consumer attempts to claim | Refused — already enforced, unexercised for lack of a consumer | — |
OX-E3 — Notify a customer of a business event through the notifications catalog
| Case | Expected answer | Proving test |
|---|---|---|
Two simultaneous delivery attempts for the same delivery_id | Out of scope for this iteration — notifications.delivery_attempts has no lease/lock columns at all; deferred to whichever module first produces notifications.intents, which must add the same SKIP LOCKED+lease treatment events.outbox_records already has | — |
| A module inserts a domain event under another module's event type | Out of scope for this iteration — validate_domain_event_insert never compares producer_module_code against event_types.owner_module_code; deferred to the first real producer (OX-E1) | — |
| A delivery receipt is recorded | Out of scope for this iteration — no trigger updates notifications.deliveries.status/delivered_at from a delivery_receipts insert; deferred to whichever module first consumes receipts | — |
An out-of-order or gapped attempt_number is inserted | Out of scope for this iteration — notifications.delivery_attempts only has a bare uniqueness constraint, not a sequence-enforcing trigger like events.publication_attempts; deferred to the same future producer | — |
Cross-cutting invariants
| Invariant | Expected answer | Proving test |
|---|---|---|
| Publication order is best-effort, never guaranteed | A retried message can be sent after a later message that never needed a retry; accepted, since none of the four Identity security flows have a cross-message ordering requirement | — |
| "Published"/"sent" means provider-accepted, not confirmed end-user delivery | outcome='published' records provider custody, not recipient receipt; a stronger fact needs a delivery receipt, unconsumed by this catalog's scope (OX-E3) | — |
| Retries are capped by worker-side policy, not a database constraint | OB4's 5-attempt cap is enforced by the worker counting attempt_count; no CHECK on events.outbox_records/the identity outbox stops a worker bug from retrying past it | — |
The security outbox never carries organization_id and never joins into events.* | Structural — identity.security_outbox_messages has no organization_id column and no foreign key into any tenant table | — |
ninaku_runtime can never claim or record an outbox outcome | Refused, 42501, for every claim/record function named in OX-W9/OX-W10, even if request-handling code accidentally called one | database/tests/contracts/security-outbox-contracts.ts › OX-W10 ninaku_runtime cannot claim a batch; › OX-W10 ninaku_runtime cannot record an attempt outcome (proven for the identity outbox's own functions; events.claim_outbox_batch/record_publication_attempt unproven, no tenant-outbox producer exists yet) |
Gaps
Grouped by family, each line names the lowest layer that could prove it honestly once the corresponding code exists; none of these rows are blocked on a product decision any more, since OB7–OB11 settled the five that were. G1 (this revision) shipped the identity security outbox's storage shape, claim/lease, attempt recording, and least-privilege split; the lines below reflect what G1 proved at the database layer and what still needs the application code (G2 encryption, G3 the four Identity flows, G4 the worker process) that G1 does not touch.
- OX-S1–OX-S4 (enqueue atomicity) — G3 wired the enqueue call into all four Identity flows, inside the same transaction as the challenge write; the happy path, the atomic-rollback-on-enqueue-failure guarantee, and (for OX-S1/OX-S8) the resend-supersede behavior are now proven per flow, both at the unit level (fake ports) and against real PostgreSQL (
*.use-case.integration-spec.ts, extending each flow's own file). Password recovery's anti-enumeration property is proven separately (OX-S3's own row): an enqueue failure for a real identifier is hidden behind the same answer and logged at error level, while a failure that does not depend on the account's existence is reported, so the response never differs between a real and an unknown identifier. - OX-S5 (encrypted custody) — storage shape (ciphertext/nonce/key-version columns, emptied only on a terminal state) and the cipher wrapper's own round-trip/binding/key-version behavior are both now proven; G3 wired
SecurityOutboxOtpCipherPort.seal()into all four enqueue use cases (proven end to end: the outbox row's ciphertext decrypts back to the exact code that verifies the challenge). The worker's ownopen()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 patternpaymentVersusGraceExpiryRacealready uses for Platform. - OX-S8 (resend) — the supersede key is now
purpose+destination_kind+destination_snapshot(neverchallenge_id, since a resend always opens a new challenge) and scoped topendingonly (aleasedrow is left alone); all three are proven single-session at the database layer, and G3 additionally proves the application-layer happy path foradd_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_upis 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 forsign_uprequires 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(twosign_upmessages for the same destination from different challenges both staypending; asign_upretryable failure is never mistaken for a supersede) andregister-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-publishedrow, and a dedicated two-connection concurrency test for the enqueue-versus-claim race on the samependingrow (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:
challengeDeliveryis 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 bysecurity-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_errorwrite path (state back topending,available_atadvanced, ciphertext untouched) is now proven; the worker-side classification mapping and OB4's actual 5-attempt schedule (unit, including OB9'sINDETERMINATE-retries-like-THROTTLED/UNAVAILABLErule) 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 theunexpected_worker_errorclass, 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 intofailed/challenge_expiredinstead 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.tsfor every function/table this group ships, including G4's two new sweep functions (worker-only EXECUTE, barred forninaku_runtime, and a rawDELETEstill refused even with the retention flag set), the same patterndatabase/tests/prepare/prepare-database-contracts.tsalready uses forplatform.provision_organization's own bar (also mirrored there asWORKER_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/backlogand/dead-letters) overSECURITY DEFINERfunctions, 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_retentionand proved both thresholds (30-daypublished, 90-daydead_lettered) at the database layer; the scheduled job itself (mirroringSubscriptionLifecycleSweepJob'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_runtimecan never claim or record for the identity outbox's own functions is now proven; itsevents.claim_outbox_batch/record_publication_attempthalf 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.