# NotificationDeliveryAudit

## Description

NotificationDeliveryAudit is the durable, append-only record of every lifecycle event for every Notification produced by the module. Each transition on either axis of the parent Notification — `deliveryStatus` (`QUEUED`, `SENT`, `DELIVERED`, `FAILED`, `BOUNCED`) and `engagementStatuses` additions (`SEEN`, `READ`, `ARCHIVED`), plus the reserved provider-callback `OPENED` event — writes one row referencing the parent.

> **Scope (PERSONAL only).** This model audits the **PERSONAL** delivery topology exclusively — every row is anchored by a `notificationId` FK to a per-recipient `Notification`. The **DESTINATION** topology (broadcast posts to a shared surface such as a Slack channel) produces no per-user Notification, so its delivery trace lives on the separate [DestinationDeliveryLog](./DestinationDeliveryLog.md). The DESTINATION audit sits alongside this model, not merged into it (a broadcast post has no Notification FK to hang off, and the row granularity is inverted: append-per-transition here vs one mutable attempt row there). The audit captures the granular event stream regardless of how the parent Notification's two-axis state ends up; it is the system of record for SOX-class internal audit on financial events (PO submitted / approved, invoice approved / rejected, supplier suspended) and is the surface consulted whenever an investigator asks "did user X receive notification of invoice Y approval at time T, and did they read it?".

A row carries `id`, `notificationId` (FK to Notification), `eventType` (one of `QUEUED`, `SENT`, `DELIVERED`, `SEEN`, `READ`, `ARCHIVED`, `FAILED`, `BOUNCED`, `OPENED`), `occurredAt`, `occurredBy` (the userId for user-driven events such as `READ` / `ARCHIVED`, or the `system` sentinel for dispatcher- and adapter-driven events), and `errorClass` and `errorDetail` (only populated for `FAILED` and `BOUNCED`). The row never duplicates rendered subject, body, or `payloadVars` — content lives on the parent Notification, and the audit stores only references and event metadata. This separation is the PII-minimization stance: the audit shell is preserved for compliance integrity even when the parent Notification is anonymized.

The audit is technically AppendOnly, with two documented exceptions that anonymize (never delete) row fields. The first is GDPR right-to-erasure: when a user is deleted, audit rows where `occurredBy = U` have `occurredBy` replaced with a `TOMBSTONED` sentinel and the parent Notification has `recipientUserId` similarly tombstoned and `payloadVars` dropped — deleting the audit row would defeat the compliance integrity that audit exists to provide. The second is the retention sweep (`runNotificationAuditRetentionSweep`, host-scheduled): rows older than the configured TTL (default 90 days, overridable via the command's `ttlDays` input) have `errorDetail` cleared and any user-driven `occurredBy` replaced with `TOMBSTONED`, while `id`, `notificationId`, `eventType`, and `occurredAt` are preserved as metadata-only. **The EMAIL channel does not produce `DELIVERED`, `BOUNCED`, or `OPENED` audit rows** because the dispatcher does not consume email-provider webhooks — EMAIL audit ends at `SENT` (or `FAILED` if the send API failed). The `recordDeliveryEvent` port and the corresponding `eventType` enum values are reserved for webhook ingestion.

## Domain Model Definitions

### Model type

AppendOnly

NotificationDeliveryAudit has no state machine: rows are written once and never transition between states. Two field-level mutations are permitted as documented exceptions and apply only to anonymization paths — `occurredBy` may be replaced with `TOMBSTONED` (by `anonymizeNotificationsForUser` and `runNotificationAuditRetentionSweep`) and `errorDetail` may be cleared (by `runNotificationAuditRetentionSweep`). All other fields (`id`, `notificationId`, `eventType`, `occurredAt`) are immutable once written. See the Invariants section for the full mutation contract.

### Command Definitions

- [recordDeliveryEvent](../command/RecordDeliveryEvent.md) - Append a `DELIVERED`, `BOUNCED`, or `OPENED` audit row from a channel-adapter callback (reserved port; not exercised by the dispatcher)
- [anonymizeNotificationsForUser](../command/AnonymizeNotificationsForUser.md) - GDPR right-to-erasure: replace `occurredBy` with `TOMBSTONED` on every audit row where `occurredBy = U` while preserving the audit shell
- [runNotificationAuditRetentionSweep](../command/RunNotificationAuditRetentionSweep.md) - Host-scheduled retention sweep that anonymizes rows older than the configured TTL (set-based) by clearing `errorDetail` and tombstoning user-driven `occurredBy`

### Query Definitions

- [listAuditByNotification](../query/ListAuditByNotification.md) - Return the ordered event stream for a single Notification
- [listAuditBySource](../query/ListAuditBySource.md) - Return audit rows for every Notification fanned out from a given `(sourceType, sourceId)`
- [searchNotificationDeliveryAudit](../query/SearchNotificationDeliveryAudit.md) - Parametric search across audit rows by eventType, recipient, source, and date range

### Models

- NotificationDeliveryAudit

### Invariants

- Every row carries a non-null `notificationId` referencing a persisted Notification
- `eventType` is one of `QUEUED`, `SENT`, `DELIVERED`, `SEEN`, `READ`, `ARCHIVED`, `FAILED`, `BOUNCED`, `OPENED`
- `occurredAt` is a wall-clock timestamp at row-write time and is immutable
- `occurredBy` is the recipient's `userId` for user-driven events (`SEEN`, `READ`, `ARCHIVED`) and the `system` sentinel for dispatcher- and adapter-driven events (`QUEUED`, `SENT`, `DELIVERED`, `FAILED`, `BOUNCED`, `OPENED`)
- `errorClass` and `errorDetail` are populated only for `FAILED` and `BOUNCED` rows; other event types persist them as null
- `errorDetail` must not contain raw PII, credentials, or full email addresses; sanitization is the writer's responsibility, enforced by convention at the channel-adapter boundary
- The row never carries rendered subject, body, or `payloadVars`; content fields are not part of the audit schema
- Rows are append-only in the normal path; the only fields that may be mutated are `occurredBy` (set to `TOMBSTONED` by anonymization) and `errorDetail` (cleared by the retention sweep). `id`, `notificationId`, `eventType`, and `occurredAt` are never modified once written
- The retention sweep must not delete rows; rows older than TTL retain a metadata-only shell after anonymization
- An anonymized row is terminal — subsequent sweep passes treat it as already-anonymized and do not re-anonymize
- A bounce or webhook callback arriving after a row has been anonymized still appends a new audit row and joins the anonymized history; the sweep does not delete the parent Notification, so late writes remain valid
- An emitter retry within the dispatcher's idempotency TTL does not produce a duplicate `QUEUED` audit row; the original audit trail is unchanged

### Relationships

- **References Notification as notificationId**: the parent Notification this audit row describes
- **References User (user-management) as occurredBy**: present for user-driven events; absent (or set to the `system` sentinel) for dispatcher- and adapter-driven events
- **Written by notification-delivery**: the dispatcher's plan phase writes the `QUEUED` row (and a plan-time `FAILED` row for a template/validation/address failure) in-transaction; the delivery worker writes `SENT`, `DELIVERED`, and delivery-time `FAILED` rows out of transaction when it drains the outbox row
- **Written by notification-inbox**: inbox actions write `SEEN`, `READ`, and `ARCHIVED` rows inline when `engagementStatuses` is updated
- **Written by notification-channels (reserved)**: provider webhooks call `recordDeliveryEvent` to append `DELIVERED`, `BOUNCED`, and `OPENED` rows asynchronously
