# Notification

## Description

Notification is the core delivery + inbox row produced by the dispatcher when a transactional module emits a domain event. One row exists per `(recipientUserId, channelId, idempotencyKey)` per event. Delivery follows an **outbox split**: the dispatcher's **plan phase** (in-transaction) resolves the eventType to a category, resolves the recipient audience (emitter-supplied reason-tagged role-holders unioned with internally-resolved `SUBSCRIBED` watchers), applies preference filtering (with the reason-driven critical bypass), renders the appropriate template, and persists the row at `deliveryStatus = QUEUED` (the outbox row) — it performs **no external delivery**. A separate **delivery worker** (`internal/deliverNotification`) later drains the QUEUED row **outside any transaction**, invokes the channel adapter, and advances `deliveryStatus`. Each row carries the polymorphic source reference (`sourceType + sourceId`), the resolved `eventType` and `channelId`, the recipient's `recipientUserId` and `locale`, the resolved `reason` tag (`SUBSCRIBED` / `ASSIGNED` / `AUTHOR` / `MENTION`), the input `payloadVars` (preserved for inbox preview and traceability), the rendered `subject`, `body`, and optional `htmlBody`, and the idempotency key used for dedup.

The Notification lifecycle is tracked along **two orthogonal axes** on the same row:

- **`deliveryStatus`** is a single linear enum (`QUEUED`, `SENT`, `DELIVERED`, `FAILED`, `BOUNCED`) owned by the plan phase (which only ever writes `QUEUED`, or `FAILED` for a plan-time terminal failure) and the delivery worker (which advances `QUEUED →` onward). Reachable values per channel: `IN_APP` advances `QUEUED → SENT → DELIVERED` in the delivery worker (out of transaction); `EMAIL` advances `QUEUED → SENT (terminal)` on adapter success or `QUEUED → FAILED` on an adapter error. `BOUNCED` and post-`SENT` `DELIVERED` are reserved on the enum for provider-webhook ingestion via `recordDeliveryEvent` but are not produced.
- **`engagementStatuses`** is a set of `{SEEN, READ, ARCHIVED}` (not mutually exclusive) owned by the inbox feature. Members are added independently and never removed. `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. A row may carry any subset of the three, but once `ARCHIVED` is present the row is terminal for further engagement writes: the read/seen commands skip archived rows or return them unchanged. The set lives next to `deliveryStatus` but is queried independently — `deliveryStatus` answers "did the message reach the recipient", `engagementStatuses` answers "what has the recipient done with it".

The IN_APP "delivery" is the Notification record itself: persisting the row is the in-app inbox surface, and the IN_APP adapter is a no-op that the delivery worker uses to advance `deliveryStatus` straight to `DELIVERED`. EMAIL delegates to the email provider the host app wires into the EMAIL adapter (e.g. SendGrid); the delivery worker invokes `send(...)` once, and on 2xx the row's `deliveryStatus` becomes `SENT` and the optional `adapterMessageId` is recorded — `SENT` is terminal for EMAIL, with suppression / soft-bounce retry / unsubscribe management fully delegated to the provider. The worker forwards the Notification id to the adapter as an `idempotencyKey` so a redrain re-send of the same QUEUED row collapses to a single provider-side delivery. Idempotency at the dispatcher is enforced separately, by exact key match: two emissions with the same `(recipientUserId, channelId, idempotencyKey)` collapse to a single Notification row for as long as the matching row exists, and the dispatcher derives a default `:`-joined key `eventType:sourceId:recipientUserId:dayBucket` (UTC-day scope) when no explicit key is supplied.

## Domain Model Definitions

### Model type

Stateful

#### State Transitions

```mermaid
stateDiagram-v2
    [*] --> QUEUED: plan phase (outbox row written)
    [*] --> FAILED: plan phase (plan-time failure)
    QUEUED --> SENT: delivery worker (IN_APP / EMAIL adapter success)
    SENT --> DELIVERED: delivery worker (IN_APP only)
    SENT --> DELIVERED: recordDeliveryEvent (reserved)
    QUEUED --> FAILED: delivery worker (adapter error / channel unavailable)
    SENT --> BOUNCED: recordDeliveryEvent (reserved)
    DELIVERED --> BOUNCED: recordDeliveryEvent (reserved)
```

| Operation        | From            | To        | Command                                                     |
| ---------------- | --------------- | --------- | ----------------------------------------------------------- |
| AdapterSuccess   | QUEUED          | SENT      | [dispatchNotification](../feature/notification-delivery.md) delivery worker |
| SyncDeliver      | SENT            | DELIVERED | [dispatchNotification](../feature/notification-delivery.md) delivery worker (IN_APP) |
| AdapterError     | QUEUED          | FAILED    | [dispatchNotification](../feature/notification-delivery.md) delivery worker |
| ProviderDeliver  | SENT            | DELIVERED | [recordDeliveryEvent](../command/RecordDeliveryEvent.md)    |
| ProviderBounce   | SENT, DELIVERED | BOUNCED   | [recordDeliveryEvent](../command/RecordDeliveryEvent.md)    |

The `[*] --> QUEUED` / `[*] --> FAILED` entry arrows in the diagram above are **not** lifecycle transitions: the plan phase sets the initial `deliveryStatus` (`QUEUED`, or `FAILED` for a plan-time terminal failure) directly when it inserts the row, not via a `from`-state transition. They are intentionally omitted from this table so the generated lifecycle's state set stays exactly `{QUEUED, SENT, DELIVERED, FAILED, BOUNCED}`.

`engagementStatuses` is a parallel axis on the same row and is **not** a state machine: it is a monotonic set of `{SEEN, READ, ARCHIVED}` whose members can only be added (never removed). Engagement-axis transitions are documented in the invariants below and in each engagement command (`markNotificationsAsSeen`, `markNotificationAsRead`, `markAllNotificationsAsRead`, `archiveNotification`); they do not appear in the state diagram above because the axes are queried independently and a row may carry any subset of the three regardless of `deliveryStatus`.

### Command Definitions

- [dispatchNotification](../feature/notification-delivery.md) - Plan phase: persist a Notification outbox row at `QUEUED` (or `FAILED` for a plan-time failure) from an emitter event, rendering the template at plan time. The channel adapter is invoked later by the delivery worker, which advances `deliveryStatus` per channel rules
- [markNotificationAsRead](../command/MarkNotificationAsRead.md) - Add `READ` (and `SEEN` if absent) to `engagementStatuses` on a single recipient-owned row, stamping `readAt`
- [markAllNotificationsAsRead](../command/MarkAllNotificationsAsRead.md) - Bulk-add `READ` (and `SEEN`) to every caller-owned row whose unread predicate matches
- [markNotificationsAsSeen](../command/MarkNotificationsAsSeen.md) - Bulk-add `SEEN` to caller-owned rows surfaced by the inbox feed, stamping `seenAt`
- [archiveNotification](../command/ArchiveNotification.md) - Add `ARCHIVED` to `engagementStatuses` so the row is removed from the default feed, stamping `archivedAt`
- [recordDeliveryEvent](../command/RecordDeliveryEvent.md) - Reserved port for provider-webhook ingestion (`DELIVERED`, `BOUNCED`, `OPENED`); not exercised by the dispatcher
- [anonymizeNotificationsForUser](../command/AnonymizeNotificationsForUser.md) - GDPR right-to-erasure: replace `recipientUserId` with a `TOMBSTONED` sentinel and scrub `payloadVars` plus the rendered `subject` / `body` / `htmlBody` for every Notification owned by the deleted user

### Query Definitions

- [getNotification](../query/GetNotification.md) - Retrieve a single Notification by id with all axis state
- [listInboxNotifications](../query/ListInboxNotifications.md) - The recipient-scoped inbox feed: caller-owned `IN_APP`-channel rows where `deliveryStatus IN {SENT, DELIVERED}` and `ARCHIVED ∉ engagementStatuses`, ordered by `createdAt desc`, with paging and an unread count (non-IN_APP rows such as EMAIL are excluded)

### Models

- Notification

### Invariants

- `recipientUserId` is non-null on every persisted row (replaced with the `TOMBSTONED` sentinel only by anonymization)
- The triple `(recipientUserId, channelId, idempotencyKey)` is unique so emitter retries collapse to a single row (exact key match; derived keys carry a UTC dayBucket, explicit keys dedup as long as the row exists)
- `eventType` must resolve against EventCategoryBinding; events whose `eventType` is absent from the catalog are rejected before any row is persisted
- `channelId` must reference an enabled NotificationChannel; rows for disabled or unregistered channels are not persisted
- `subject`, `body`, and optional `htmlBody` are the rendered output captured at dispatch time and are immutable on the row except for anonymization (the single sanctioned overwrite); updating a NotificationTemplate does not mutate already-persisted Notifications
- `payloadVars` carries the input variables supplied by the emitter for traceability and inbox preview; rendered output (subject / body / htmlBody) is also persisted for display, but `payloadVars` is the source-of-truth input
- `reason` is the resolved inclusion reason (`SUBSCRIBED`, `ASSIGNED`, `AUTHOR`, `MENTION`) captured at dispatch; `ASSIGNED` / `MENTION` are the reasons that trigger the critical bypass over a user preference mute
- A row persisted on the happy path has `deliveryStatus = QUEUED` and `engagementStatuses = ∅` after the plan phase and before any adapter call; plan-time terminal failures (`TEMPLATE_NOT_FOUND` blank-content anchors, template-variable validation failures, and `MISSING_RECIPIENT_ADDRESS`) persist the row directly at `FAILED` in the plan phase so the failure is auditable per `(recipient, channel)` pair and the row is excluded from the deliverable set
- IN_APP rows advance `QUEUED → SENT → DELIVERED` in a single delivery-worker pass (out of transaction); the row never lingers at `SENT` for IN_APP
- EMAIL rows advance `QUEUED → SENT` on provider success and stay at `SENT`; `DELIVERED` and `BOUNCED` are not produced for EMAIL by the delivery worker
- A delivery-time adapter error (or an unavailable / unconfigured channel) transitions `QUEUED → FAILED`; the failed row is never reattempted (EMAIL retry is delegated to the provider). A row whose delivery never ran (a crashed worker) stays `QUEUED` and is re-driven by the insurance cron, with the provider idempotency key collapsing any duplicate send
- `engagementStatuses` is a monotonic set: members are only ever added, never removed; adding `READ` always also implies `SEEN`
- `ARCHIVED` is terminal for engagement writes: once present, the read/seen commands make no further additions to the row (bulk commands exclude archived rows from their selection; the single-row mark-read returns the archived row unchanged)
- `seenAt`, `readAt`, and `archivedAt` are stamped only on the first addition of the corresponding member and are never overwritten on idempotent re-adds
- Engagement-axis transitions (`SEEN`, `READ`, `ARCHIVED`) are only valid on rows where `deliveryStatus IN {SENT, DELIVERED}`; rows with `deliveryStatus IN {QUEUED, FAILED, BOUNCED}` are not user-actionable in the inbox surface
- Cross-user inbox actions are rejected with `NOTIFICATION_NOT_FOUND` (indistinguishable from a missing id, so foreign ids cannot be enumerated); only the row's `recipientUserId` may add to `engagementStatuses`
- Anonymization replaces `recipientUserId`, `subject`, and `body` with the `TOMBSTONED` sentinel and clears `payloadVars` and `htmlBody` (rendered content can embed the same personal data as the input variables); the row itself and the audit shell are preserved

### Relationships

- **References User (user-management) as recipientUserId**: the recipient whose inbox the row belongs to
- **References NotificationChannel as channelId**: the channel the row was dispatched on
- **References EventCategoryBinding as eventType**: the seeded binding consulted by the dispatcher to resolve category, transactional flag, and default channels
- **References NotificationTemplate as `(eventType, channelId, locale)`**: the template rendered against `payloadVars` to produce the row's subject, body, and optional htmlBody
- **References polymorphic source as `(sourceType, sourceId)`**: the originating record in the emitting module; declared by string discriminator with no foreign-key constraint
- **Has many NotificationDeliveryAudit**: every transition on either lifecycle axis writes a NotificationDeliveryAudit row referencing this Notification — `QUEUED` / plan-time `FAILED` by the plan phase, `SENT` / `DELIVERED` / delivery-time `FAILED` by the delivery worker, engagement events by the inbox feature
