# notification-delivery

## Overview

Notification Delivery is the core fan-in workflow that turns a domain event from any host-app emitter module (e.g. purchase, sales, invoice, or compliance modules) into per-user delivery records dispatched across the configured channels. **Source modules do not call the dispatcher synchronously.** Instead they append an immutable `NotificationEvent` row (via [logNotificationEvent](../command/LogNotificationEvent.md)) in the same transaction as the business change; the insert is observed by a CDC trigger that drives `dispatchNotification` asynchronously. **Audience resolution lives inside the dispatcher**: the emitter supplies only its reason-tagged naturally-interested hints (`payload.recipients` with reasons `ASSIGNED` / `AUTHOR` / `MENTION`) plus the `actorUserId`; the dispatcher resolves `SUBSCRIBED` watchers from `NotificationSubscription`, **unions** them with the emitter hints, dedups by `userId` keeping the highest-precedence reason, and **suppresses the actor** unless `notifySelf` is set (see [README](../../README.md)). The dispatcher still does not depend on any emitter schema — it stores only the polymorphic `(sourceType, sourceId)` reference.

Delivery follows an **outbox split**. The dispatcher's **plan phase** runs inside the event transaction and produces only outbox rows — it performs **no external delivery**: for each event it resolves the NotificationCategory bound to the eventType (see Event Catalog below), then iterates each `(recipientUserId, channel)` pair, applies per-user NotificationPreference filtering (with the critical bypass for `ASSIGNED` / `MENTION` recipients; transactional events — binding flag or `optOutAllowed = false` — bypass the filter and are always queued), renders the channel-specific NotificationTemplate keyed by `(eventType, channelId, locale)` against `payloadVars`, and persists a Notification row at `deliveryStatus = QUEUED` with the polymorphic `sourceType + sourceId` reference. After the plan transaction commits, a **delivery worker** (`internal/deliverNotification`, driven by `internal/drainDeliveries`) drains each QUEUED row **outside any transaction**, hands the rendered message to the channel adapter port, and advances the row to its terminal state. Splitting plan from delivery keeps a slow or failing provider off the event transaction's hot path: it can neither hold the row locks nor roll back the plan, and a crash before delivery simply leaves QUEUED rows for the insurance redrain to re-drive.

Pairs suppressed before a row could be anchored are reported in the plan result's `skipped[]` ledger with a reason code (`RECIPIENT_UNRESOLVED`, `CHANNEL_DISABLED`, `PREFERENCE_MUTED`) so "muted" is distinguishable from "lost" — **within the plan result only**: the ledger is not persisted, so the skip reason is observable to the invoking executor and its logs but cannot be reconstructed from this module's data after the call returns (see the suppression scenario below).

The Notification lifecycle is tracked along **two orthogonal axes**:

- **`deliveryStatus`** (single enum, linear progression): `QUEUED → SENT → DELIVERED`, with branches `QUEUED → FAILED` (plan-time terminal failure or a delivery-time adapter error) and `SENT|DELIVERED → BOUNCED` (async bounce ingestion). The plan phase only ever writes `QUEUED` (or terminal `FAILED`); the delivery worker advances `QUEUED` onward.
- **`engagementStatuses`** (set of `{SEEN, READ, ARCHIVED}`, not mutually exclusive): zero, one, or more of these may co-exist on a single Notification. `SEEN` is added when the row is first surfaced in the recipient's feed; `READ` is added on explicit mark-read (and implies `SEEN`); `ARCHIVED` is added on explicit archive (treated as implicit read for unread counting). This axis is owned by the notification-inbox feature and updates the same Notification row.

The two axes are queried independently — `deliveryStatus` answers "did the message reach the recipient's inbox / EMAIL provider", while `engagementStatuses` answers "what has the recipient done with it". A row can be `deliveryStatus = DELIVERED` with `engagementStatuses = {}` (delivered but unread) or `deliveryStatus = DELIVERED` with `engagementStatuses = {SEEN, READ}` (delivered, surfaced, and acted on). Every transition on either axis is logged to NotificationDeliveryAudit (notification-delivery-audit feature) so the message lifecycle is fully reconstructable.

The plan phase dedupes repeat events at the `(recipient, channel, idempotencyKey)` level by **exact key match** — an existing Notification row with the same key dedups the re-emission, with the matching rows reported in the result's `deduped[]` array. Callers may supply `idempotencyKey` explicitly (deduped for as long as the matching row exists, no TTL); otherwise the dispatcher derives the `:`-joined key `eventType:sourceId:recipientUserId:dayBucket` whose dedup scope is the UTC day. On the CDC path the dispatch executor supplies an explicit key derived from the event's logical identity — the `:`-joined `eventType:sourceType:sourceId:payloadHash` (see `internal/planEvent.ts`) — so even duplicate `NotificationEvent` rows that slip past the ingress dedup converge to the same Notification rows at dispatch time.

EMAIL delivery is delegated to the email provider the host app wires into the EMAIL adapter (e.g. SendGrid). **The EMAIL adapter is an optional DI port** (see the module README): when the host app enables the EMAIL channel but leaves the adapter unwired, the plan phase still queues the EMAIL Notification row, and the delivery worker marks it `FAILED` (`ChannelAdapterNotConfigured`) with an audit row rather than leaving it `QUEUED` forever or silently dropping it (see [notification-channels](./notification-channels.md)). When wired, the delivery worker maps the provider's send-API success to `deliveryStatus = SENT` and treats it as **terminal for EMAIL** — provider-side webhooks for delivery acknowledgements and bounces are not consumed, suppression / soft-bounce retry / unsubscribe management are fully delegated to the provider, and the worker does not perform its own retry of a returned failure. This is an explicit best-effort stance: the EMAIL channel does not advance to `DELIVERED` or `BOUNCED`. IN_APP delivery (the Notification row itself is the inbox row) advances `QUEUED → SENT → DELIVERED` in a single delivery-worker pass.

## Business Purpose

- Provide a single fan-in target so transactional modules emit a domain event once and the notification module handles audience iteration, preference filtering, template rendering, persistence, and channel dispatch
- Keep the notification schema decoupled from every emitting module by storing polymorphic `sourceType + sourceId` references rather than typed foreign keys
- Enforce a two-axis lifecycle model — `deliveryStatus` (linear, dispatcher-owned) for "did the message reach the channel" and `engagementStatuses` (set, recipient-owned) for "what has the recipient done with it" — so inbox UI, audit reporting, and SLA dashboards can query each axis independently without ambiguity
- Honor per-user channel × category preferences for optional categories while guaranteeing that transactional categories (PO confirmation, invoice approved, RFQ awarded, etc.) always reach the recipient immediately
- Provide dispatcher-level idempotency keyed on `(recipient, channel, idempotencyKey)` so emitter-side retries do not produce duplicate inbox rows or duplicate emails
- Render channel-specific templates with the recipient's locale so in-app subjects and email HTML bodies match the user's language preference
- Record every lifecycle transition to NotificationDeliveryAudit so failed deliveries can be diagnosed and SOX-class audit obligations on financial events are satisfied
- Maintain a single Event Catalog contract (see below) — seeded by the host application — that binds every emitted `eventType` to its category, so emitters have one place to look up the contract surface

## Process Flow

**Ingress and trigger topology (CDC primary / cron secondary).** Delivery is triggered by **data, not by a synchronous call**. The flow is split into a durable ingress write and an asynchronous dispatch, with two triggers covering the primary path and a safety net:

- **Ingress (`logNotificationEvent`).** A source module appends a `PENDING` `NotificationEvent` in the same transaction as its own business change. This is the only synchronous work on the emitter's hot path — no recipient resolution, no template rendering, no adapter call. Idempotency on `(eventType, sourceType, sourceId, payloadHash)` means an at-least-once emitter retry collapses to a single event row.
- **Primary trigger — CDC `recordCreatedTrigger` on `NotificationEvent`.** The `dispatch-notification-events` executor fires on each newly inserted event row and runs the **plan phase** inside the event's transaction (insert → plan, no polling delay). It maps the event envelope to the plan input: `sourceType`/`sourceId` pass through, the reason-tagged `recipients` hints, `notifySelf`, and `locale` come from the event payload, `actorUserId` from the event row, `payloadVars` is the payload itself, and the `idempotencyKey` is derived from the event's logical identity (`eventType:sourceType:sourceId:payloadHash`). On completion it stamps the event `DISPATCHED` (≥1 outbox row produced) or `NO_DELIVERY` (everything filtered out — including a plan error such as a catalog-missing eventType, surfaced as a reason code rather than re-drained forever). **After the plan transaction commits**, the executor delivers the outbox rows it produced (`deliverPlanned`) **outside the transaction**; a per-row delivery failure leaves the QUEUED / PENDING row for the redrain cron rather than reverting the plan. Because CDC is at-least-once, the executor first guards on `status = PENDING`; a re-fired trigger on an already-planned row is a no-op.
- **Insurance trigger — low-frequency re-drain cron.** A safety-net `scheduleTrigger` executor (`redrainNotificationEvents`) runs two duties each cycle: (1) **re-plan** events stuck in `PENDING` (a missed/dropped CDC fire — the plan never committed) by re-invoking the plan phase for them, and (2) **drain stranded outbox rows** — QUEUED Notifications and PENDING DestinationDeliveryLogs whose plan committed but whose delivery never completed (a crashed CDC executor). Combined with the `status = PENDING` guard, the per-row provider idempotency key the delivery worker passes, and the conditional terminal writes, this lets the system tolerate at-least-once _and_ at-most-once edge cases of the CDC stream without producing duplicate provider-side sends. Per the module's delivery-guarantee stance, the CDC stream's at-least-once property is **not** separately verified; correctness rests on the idempotency key + status guard + this insurance cron. This re-drain (plus the host-wired audit retention sweep) is the only clock-driven work in the module — everything else is event-driven.

```mermaid
flowchart LR
    SRC[Source module commit] -->|logNotificationEvent in-tx| EV[(NotificationEvent PENDING)]
    EV -->|CDC recordCreatedTrigger| EXE[dispatch-notification-events executor]
    EXE -->|status=PENDING guard, in-tx| PLAN[plan phase: write outbox rows]
    PLAN --> STAMP[stamp DISPATCHED / NO_DELIVERY, commit]
    STAMP -->|after commit, out-of-tx| DELIV[deliver outbox rows]
    INS[insurance re-drain cron] -->|stuck PENDING events| EXE
    INS -->|stranded QUEUED/PENDING rows| DELIV
```

Once the plan phase is invoked (by either trigger), the per-recipient / per-channel fan-out proceeds as follows. The plan phase ends at a persisted outbox row (`QUEUED`, or a plan-time terminal `FAILED`); the dashed delivery steps run **after the plan transaction commits**, in the delivery worker, outside any transaction. (The entry node below reads "executor invokes plan phase"; in this module that invocation is made by the executor, not the source module directly.)

```mermaid
flowchart TD
    A[Executor invokes plan phase] --> B{eventType maps to a NotificationCategory?}
    B -->|No| C[Error: CATEGORY_NOT_FOUND]
    B -->|Yes| RES[Resolve audience: emitter hints + SUBSCRIBED watchers, dedup by highest-precedence reason, suppress actor unless notifySelf]
    RES --> D{Resolved audience non-empty?}
    D -->|No| E[Stage 1 no-op: zero notifications; DESTINATION stage still runs]
    D -->|Yes| F[For each resolved recipient x enabled channel]
    F --> RU{resolveRecipient returns a profile?}
    RU -->|No| SK1[Record skipped RECIPIENT_UNRESOLVED, continue]
    RU -->|Yes| ID{Idempotency check: existing Notification for recipient+channel+idempotencyKey?}
    ID -->|Yes| IDH[Report existing row in deduped, no new persist]
    ID -->|No| G{Transactional? binding flag OR optOutAllowed=false}
    G -->|Yes| L{Template exists for eventType x channel x locale?}
    G -->|No| J{User preference allows channel x category, or critical bypass?}
    J -->|No| I[Record skipped PREFERENCE_MUTED, continue]
    J -->|Yes| L
    L -->|No, locale != default| M[Fall back to default-locale template]
    M --> L
    L -->|No, default also missing| N[Plan-time FAILED: persist blank-content row at FAILED with TEMPLATE_NOT_FOUND, excluded from deliverable set, continue sibling pairs]
    L -->|Yes| O[Render template against payloadVars, validate variableSchema]
    O -->|validation fails or address missing| N2[Plan-time FAILED: persist row at FAILED, excluded from deliverable set]
    O -->|ok| P[Persist Notification outbox row: deliveryStatus=QUEUED, rendered content, engagementStatuses=empty]
    P -.->|after commit, delivery worker| Q[Call channel adapter port out of transaction]
    Q -.->|IN_APP| R1[deliveryStatus -> SENT -> DELIVERED, audit logged]
    Q -.->|EMAIL provider success| R2[deliveryStatus -> SENT, audit logged. Terminal for EMAIL; the provider handles delivery / bounce / suppression internally]
    Q -.->|adapter error/throw, or channel unavailable/unconfigured| T[deliveryStatus -> FAILED, audit logged. Contained per row; no worker-side retry of a returned failure]

    V[Recipient opens inbox feed] --> W{Row's deliveryStatus IN SENT, DELIVERED?}
    W -->|Yes, SEEN not yet in engagementStatuses| X[Add SEEN to engagementStatuses, stamp seenAt, audit logged]
    W -->|Yes, mark-read action| Y[Add READ to engagementStatuses, also add SEEN if absent, stamp readAt, audit logged]
    W -->|Yes, archive action| AA[Add ARCHIVED to engagementStatuses, stamp archivedAt, audit logged]
```

The diagram tracks two axes per Notification:

- **`deliveryStatus`**: single linear value, written `QUEUED` / plan-time `FAILED` by the plan phase and advanced by the delivery worker. Reachable values per channel — `IN_APP: QUEUED → SENT → DELIVERED`; `EMAIL: QUEUED → SENT (terminal)` or `QUEUED → FAILED`. `BOUNCED` and post-`SENT` `DELIVERED` are reserved on the enum for webhook ingestion but not produced.
- **`engagementStatuses`**: set of `{SEEN, READ, ARCHIVED}`, owned by notification-inbox. Members are added independently and never removed (idempotent additions); `READ` always implies `SEEN`.

## Scenario Patterns

- **Single recipient, in-app only**: Recipient opted into a transactional category for the in-app channel only; the plan phase persists one QUEUED Notification on the IN_APP channel, and the delivery worker advances it to `deliveryStatus = DELIVERED`, no email rendered
- **Single recipient, in-app + email**: Recipient has default preferences, the category is enabled on both channels, and the host app has wired an EMAIL adapter; the plan phase persists two QUEUED Notification rows (one per channel), then the delivery worker advances IN_APP `QUEUED → SENT → DELIVERED` while EMAIL advances `QUEUED → SENT` and stays at `SENT` (terminal for EMAIL)
- **EMAIL channel enabled but adapter unwired**: The host app enabled the EMAIL channel without wiring an EMAIL adapter; the plan phase still queues the EMAIL Notification row, and the delivery worker marks it `FAILED` (audit `errorClass = ChannelAdapterNotConfigured`, outcome reason `CHANNEL_ADAPTER_FAILED`), so the configuration gap is operator-visible instead of rows lingering at `QUEUED`
- **Multi-recipient fan-out**: RFQ_PUBLISHED event with five invited supplier users in `recipients`; the plan phase iterates and queues up to ten Notification rows (5 recipients × 2 default channels), each rendered independently and delivered independently by the worker
- **Optional category opted out for one channel**: Recipient opted out of an optional category on EMAIL only; the in-app Notification is persisted and dispatched, the email Notification is suppressed (not persisted)
- **Optional category opted out for all channels**: Recipient opted out of an optional category on every channel; **no Notification is persisted** (preference suppression is treated as "the user did not want this message at all", and persisting a SUPPRESSED row would create inbox noise without value). **Known limitation:** the suppression reason exists only in the dispatch result's `skipped[]` ledger and is not persisted — after the call returns, this module's data records that no Notification row exists for the pair, but cannot distinguish `PREFERENCE_MUTED` from `RECIPIENT_UNRESOLVED` or any other pre-anchor skip. "Why was user X not notified" is answerable from the executor's dispatch-result logs, not from module queries; the SOX-grade reconstruction guarantees of notification-delivery-audit apply to persisted Notifications and their audit trail only, not to preference-suppressed pairs. Persisting the `skipped[]` ledger (e.g. on the `NotificationEvent` row) is a known candidate enhancement if investigator-grade suppression forensics become a requirement
- **Transactional category bypass**: Event maps to a transactional category (PO confirmation, INVOICE_APPROVED, RFQ awarded); the preference filter is skipped and Notifications are persisted on every enabled channel regardless of opt-out state
- **Channel adapter failure (EMAIL provider 5xx)**: Email provider returns `{ ok: false }` during delivery; the delivery worker transitions the QUEUED row to `deliveryStatus = FAILED` (guarded on `status = QUEUED`) and records the failure in NotificationDeliveryAudit. The worker does **not** auto-retry a returned failure — EMAIL retry is delegated to the wired email provider, which typically performs internal soft-bounce / transient-failure retries during its `send` API window before returning success or terminal failure. A PERSONAL adapter that **throws** (rather than returning `{ ok: false }`) is treated as a transient infra fault, not a terminal failure: the throw is caught and logged by the drain, the row stays `QUEUED`, and the insurance redrain re-drives it (the provider idempotency key collapses any duplicate send). Per-row isolation means one stuck row never aborts the wider drain. Operators reconcile permanent failures through the audit log.
- **EMAIL terminal at SENT**: Once the email provider's send API returns success, the delivery worker advances the Notification to `deliveryStatus = SENT` and it stays there. The module does **not** ingest provider webhooks for delivery acknowledgements, hard bounces, or unsubscribes; suppression-list management, bounce processing, and complaint handling are fully delegated to the provider's internal mechanisms.
- **Idempotent re-dispatch (caller-supplied key)**: Emitter calls `dispatch({ ..., idempotencyKey: 'po-update-PO-123-cycle-7' })` and a transient retry causes the same call to land twice. The second call finds an existing Notification row keyed by `(recipientUserId, channelId, idempotencyKey)` and returns it without persisting a duplicate; explicit keys dedup for as long as the matching row exists. The dispatch result reports the existing rows in `deduped[]` for observability.
- **Idempotent re-dispatch (default-derived key)**: Emitter does not supply `idempotencyKey`; dispatcher derives the `:`-joined default `eventType:sourceId:recipientUserId:dayBucket`. Two re-emissions of the same event for the same recipient on the same UTC day collapse to a single Notification per channel.
- **Idempotent re-dispatch (CDC path)**: Duplicate `NotificationEvent` rows for the same logical event (same `eventType`, `sourceType`, `sourceId`, `payloadHash`) slip past the ingress dedup; both dispatches derive the same event-identity key, so the second converges onto the first's Notification rows via `deduped[]` — duplicate event rows never produce duplicate notifications.
- **Intentional re-emission across day boundary**: Same event emitted on day N and day N+1 for the same recipient produces two distinct Notifications because the default-derived dayBucket differs. To force collapse across a longer window, emitters supply an explicit `idempotencyKey` with the desired stable scope.
- **Locale-specific rendering**: Recipient's `locale` is `ja-JP`; the Japanese template for the `(eventType, channel)` pair is selected and rendered, with the dispatcher's `locale` override taking precedence over the recipient's stored locale when both are present
- **Locale fallback**: Template missing for `(eventType, channel, ja-JP)` but present for the configured default locale; the default-locale template is rendered and the Notification is dispatched normally
- **Template missing entirely**: Template missing for both the requested locale and the default locale; a blank-content Notification is still anchored for that `(recipient, channel)` pair and marked `FAILED` with reason `TEMPLATE_NOT_FOUND` so the failure is auditable per pair, and other channels / recipients continue independently — the DESTINATION stage still runs
- **Recipient resolution miss**: A resolved audience member the `resolveRecipient` lookup cannot find is recorded in the dispatch result's `skipped[]` with reason `RECIPIENT_UNRESOLVED` (no row can be anchored without a profile); the rest of the fan-out continues
- **Empty resolved audience**: The emitter supplies no hints and no watchers exist (e.g., an announcement targeted at a group with zero members); Stage 1 is a no-op — zero Notifications persisted, zero audit rows, no error — and the DESTINATION stage still runs
- **Engagement axis transitions**: After delivery, the recipient's interactions add members to `engagementStatuses` independently — first feed open adds `SEEN`; explicit click adds `READ` (and `SEEN` if absent); explicit archive adds `ARCHIVED`. The set is monotonic (members are never removed) and additions are idempotent. Detailed inbox semantics are owned by notification-inbox and operate on the same Notification row.
- **Subscription-driven recipient inclusion**: NotificationSubscription watchers for the event's `(sourceType, sourceId)` are resolved **inside the dispatcher** and unioned with the emitter's reason-tagged hints (reason `SUBSCRIBED`, deduped by highest-precedence reason); the emitter never pre-resolves watchers

## Test Cases

- Dispatching an event for a known eventType with a single recipient, default preferences, and a wired EMAIL adapter should persist one IN_APP Notification (advancing to `deliveryStatus = DELIVERED`) and one EMAIL Notification (advancing to `deliveryStatus = SENT` and stopping there)
- Dispatching an EMAIL pair when the host app has not wired an EMAIL adapter should persist the Notification row, mark it `FAILED`, write an audit row with `errorClass = ChannelAdapterNotConfigured`, and report the outcome reason `CHANNEL_ADAPTER_FAILED`
- A newly persisted Notification should have `deliveryStatus = QUEUED` and `engagementStatuses = empty set` before any adapter call
- Dispatching an event whose eventType has no NotificationCategory mapping should fail with CATEGORY_NOT_FOUND and persist zero Notifications
- Dispatching with an empty resolved audience should return success and persist zero Notifications and zero audit rows, while still running the DESTINATION stage
- Dispatching with five recipients and two enabled channels should persist exactly ten Notifications when all recipients opt in on both channels
- Dispatching an optional-category event to a recipient who opted out on EMAIL only should persist exactly one IN_APP Notification and zero EMAIL Notifications
- Dispatching an optional-category event to a recipient who opted out on every channel should persist zero Notifications
- Dispatching a transactional-category event to a recipient who opted out on every channel should still persist one Notification per enabled channel
- Logging a NotificationEvent should persist a `PENDING` row and leave dispatch to the CDC executor; the source transaction should not perform any adapter call
- The CDC `dispatch-notification-events` executor firing on a `PENDING` event should run the plan phase (in-transaction) with the reason-tagged `recipients` hints from the payload and an idempotencyKey derived from the event's logical identity, stamp the event `DISPATCHED` when at least one outbox row is produced, and — after the transaction commits — deliver the produced outbox rows outside the transaction
- The CDC executor firing on an event with no resolvable recipients and no matching destination binding should stamp the event `NO_DELIVERY` (plan ran, zero outbox rows) without erroring
- The CDC executor firing twice on the same event (at-least-once redelivery) should be a no-op on the second fire because the row is no longer `PENDING`; no duplicate Notification rows are produced
- A delivery-worker failure on an outbox row should not revert the committed plan or re-stamp the event; the row is left `QUEUED` / `PENDING` for the insurance redrain
- The insurance re-drain cron should (1) re-plan only events still in `PENDING` (a dropped CDC fire), not touching `DISPATCHED` / `NO_DELIVERY` events, and (2) drain stranded QUEUED Notifications and PENDING DestinationDeliveryLogs whose plan committed but whose delivery never completed
- Dispatching with a recipient locale of `ja-JP` and a Japanese template available should render the Japanese template
- Dispatching with a recipient locale of `ja-JP` when only the default-locale template exists should render the default-locale template and persist the Notification normally
- Dispatching with a recipient locale of `ja-JP` when neither the locale-specific nor the default-locale template exists should anchor a blank-content Notification for that `(recipient, channel)` pair marked FAILED with TEMPLATE_NOT_FOUND without affecting other channels for the same recipient
- Dispatching with a `locale` override on the dispatcher call should use the override over the recipient's stored locale
- The delivery worker delivering a QUEUED IN_APP row should transition `deliveryStatus` QUEUED → SENT → DELIVERED in a single pass and record audit rows for each transition, each transition guarded on the expected prior status
- The delivery worker delivering a QUEUED EMAIL row on a provider success response should transition `deliveryStatus` QUEUED → SENT and stop there (terminal for EMAIL); no DELIVERED or BOUNCED transition is produced
- The delivery worker delivering a QUEUED EMAIL row on a provider `{ ok: false }` response should transition `deliveryStatus` to FAILED, record an audit row, and not auto-retry the returned failure
- The delivery worker should skip a row whose `deliveryStatus` is no longer `QUEUED` (already advanced by a concurrent worker / redrain), making the drain idempotent
- A PERSONAL adapter that throws should leave the row `QUEUED` (caught and logged by the drain, re-driven by the redrain) rather than marking it FAILED, and must not abort delivery of sibling rows
- A failure delivering the EMAIL row should not affect the IN_APP Notification queued from the same event
- A persisted Notification should carry the polymorphic `sourceType` and `sourceId` exactly as supplied by the emitter
- A persisted Notification should carry the rendered subject, body, and channelId, plus the original `payloadVars` for traceability
- Inbox first-surface should add `SEEN` to the Notification's `engagementStatuses` set and record an audit row (handled by notification-inbox; verified against the same Notification row)
- An explicit mark-read action should add `READ` to `engagementStatuses` (and `SEEN` if absent) with a `readAt` timestamp (handled by notification-inbox)
- An explicit archive action should add `ARCHIVED` to `engagementStatuses` with an `archivedAt` timestamp (handled by notification-inbox); `engagementStatuses` may simultaneously contain READ and ARCHIVED
- Re-emitting the same event with the same `idempotencyKey` for the same recipient should return the existing Notification row(s) in the result's `deduped[]` array and persist zero new rows
- Re-emitting the same event without supplying `idempotencyKey` twice within the same UTC dayBucket for the same recipient should likewise collapse to a single Notification per channel (default-derived key)
- Re-emitting the same event without supplying `idempotencyKey` across two distinct UTC dayBuckets should produce two distinct Notifications per channel
- Supplying different explicit `idempotencyKey` values for two re-emissions should produce two distinct Notifications per channel even within the same dayBucket
- The dispatcher should accept events for every eventType the host app seeded into the Event Catalog (e.g. PO_ISSUED, INVOICE_APPROVED) and reject events whose eventType is absent from the catalog with CATEGORY_NOT_FOUND
- A NotificationDeliveryAudit row should be written for every deliveryStatus transition (QUEUED, SENT, DELIVERED, FAILED) and for every engagementStatuses addition (SEEN, READ, ARCHIVED)
- Dispatch invoked with malformed `payloadVars` that fail template variable validation should mark that `(recipient, channel)` pair as FAILED and continue dispatching the remaining pairs
- A `resolveRecipient` lookup miss for a resolved audience member should record the recipient in `skipped[]` as RECIPIENT_UNRESOLVED without aborting the rest of the fan-out
- SUBSCRIBED watchers resolved by the dispatcher should be processed identically to emitter-hinted recipients, with reason-precedence dedup when a user appears in both sets
- An event whose category is transactional should never be filtered out by a per-user preference, regardless of any rows present
- The dispatcher should not call channel adapters for any recipient whose preference filter suppressed every channel for that category

## Reference Links

- See module README
- [notification-inbox](./notification-inbox.md) — handles SEEN, READ, ARCHIVED transitions and inbox feed scoping
- [notification-preferences](./notification-preferences.md) — supplies the per-user channel × category opt-in matrix applied here
- [notification-templates](./notification-templates.md) — supplies the channel-specific templates rendered against `payloadVars`
- [notification-channels](./notification-channels.md) — defines the channel adapter port called from the dispatcher (EMAIL adapter delegates suppression / retry / bounce handling to the configured email provider)
- [notification-subscriptions](./notification-subscriptions.md) — follower-style audience contributions; `SUBSCRIBED` watchers are resolved **inside the dispatcher** and unioned with the emitter's reason-tagged hints
- [notification-delivery-audit](./notification-delivery-audit.md) — per-message lifecycle audit trail written alongside every deliveryStatus transition and engagementStatuses addition

**Event Catalog.** The dispatcher's `eventType` parameter is the contract surface between notification and every emitter. Each `eventType` maps to exactly one `NotificationCategory` (resolved by the dispatcher at dispatch time) through an `EventCategoryBinding` row. **The catalog is seeded by the host application, not by this module** — the module ships an empty `seed/` scaffold documenting the required shape: `NotificationChannel` rows for every channel the app enables, one `NotificationCategory` per event type, an `EventCategoryBinding` per emitted `eventType`, and a `NotificationTemplate` per `(eventType, channel, locale)`. Adding a new emitter event is a host-app seed change, not a runtime registration. Emitters MUST seed a binding before emitting; events whose `eventType` is absent from the catalog are rejected with `CATEGORY_NOT_FOUND`.

Example catalog rows a host ERP app might seed:

| eventType          | NotificationCategory | Transactional | Default channels | Emitter (host app) |
| ------------------ | -------------------- | ------------- | ---------------- | ------------------ |
| `PO_ISSUED`        | `po-updates`         | yes           | IN_APP, EMAIL    | purchase           |
| `INVOICE_APPROVED` | `invoice-updates`    | yes           | IN_APP, EMAIL    | invoice            |
| `RFQ_PUBLISHED`    | `rfq-updates`        | no            | IN_APP           | sourcing           |

Notes:

- "Transactional" rows bypass `NotificationPreference` at dispatch time and are always delivered on every default channel; a category seeded with `optOutAllowed = false` is treated the same way even when the binding's flag is unset.
- "Default channels" lists the channels the dispatcher will fan out to when the recipient has no preference rows for that category. Per-user overrides can disable optional categories on individual channels.
- Categories themselves are owned by `notification-preferences`; the catalog only declares the eventType→category binding.
