# notification-delivery-audit

## Overview

Notification Delivery Audit is the durable, append-only record of "who was notified, when, on what channel, with what content reference" for every Notification produced by the notification module. Each event on either lifecycle axis — `deliveryStatus` transitions (QUEUED, SENT, DELIVERED, FAILED) and `engagementStatuses` additions (SEEN, READ, ARCHIVED) — writes a `NotificationDeliveryAudit` row referencing the parent Notification. The audit captures the granular event stream regardless of how the parent Notification's two-axis state ends up; this is the system of record for SOX-class internal audit on financial events (PO submitted, PO approved, invoice approved, invoice rejected, supplier suspended) and is consulted whenever an investigator needs to answer "did user X receive notification of invoice Y approval at time T, and did they read it?".

**Scope of the trail.** The audit reconstructs the lifecycle of every notification that was **anchored** — i.e. a `Notification` row was persisted. Pairs suppressed *before* anchoring (preference mutes, unresolved recipients, disabled channels — the dispatcher's `skipped[]` reasons) leave no persisted audit row; they are visible only in the dispatch result and executor logs, so the negative question "why was user X **not** notified" is answerable from this feature only for anchored rows. Persisting the suppression ledger (e.g. on `NotificationEvent`) is a named candidate enhancement on the notification-delivery feature.

**Two audit tracks.** Delivery audit is split to match the two delivery topologies, because they have structurally incompatible anchors:

| Track           | Model                                                           | Anchors on                                                               | Granularity                             | Topology                                      |
| --------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------ | --------------------------------------- | --------------------------------------------- |
| **PERSONAL**    | `NotificationDeliveryAudit`                                     | `notificationId` FK to a per-recipient `Notification`                    | append one row per lifecycle transition | per-recipient fan-out (IN_APP / EMAIL)        |
| **DESTINATION** | [`DestinationDeliveryLog`](../model/DestinationDeliveryLog.md) | the `ChannelRoutingBinding` `(targetType, targetId, externalChannelRef)` | one attempt row per binding per event   | broadcast to a shared surface (SLACK / TEAMS) |

A DESTINATION post produces no per-user `Notification`, so it cannot hang an audit row off a `notificationId` — hence the separate `DestinationDeliveryLog`. The two tracks never merge: a DESTINATION post failure never creates or mutates a `NotificationDeliveryAudit` row, and vice versa. Both tracks are swept by the same retention-sweep command (`runNotificationAuditRetentionSweep`, wired to a cron by the host app) — `NotificationDeliveryAudit` clears `errorDetail`, `DestinationDeliveryLog` clears `providerResponse` / `failureReason`. The remainder of this feature describes the **PERSONAL** track; the DESTINATION track is specified on the `DestinationDeliveryLog` model and the `notification-destination-delivery` feature.

A `NotificationDeliveryAudit` 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 `system` for QUEUED / SENT / DELIVERED produced by the dispatcher and channel adapters), and `errorClass` and `errorDetail` (only populated for FAILED and BOUNCED). The audit 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.

Writers are split between sync and async paths. Sync events (`QUEUED`, `SENT`, `DELIVERED` for IN_APP, `SENT` for EMAIL, `FAILED` on send, and inbox-additions `SEEN` / `READ` / `ARCHIVED`) are written inline by the dispatcher and the inbox feature. Async events (`DELIVERED` provider acknowledgement, `BOUNCED` provider callback, `OPENED` email-pixel callback) are written via the channel adapter calling back into a `recordDeliveryEvent` command on this feature, which inserts the audit row and updates the parent Notification's `deliveryStatus` if applicable. **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 its eventType enum values for these events are reserved for webhook ingestion. The retention policy is bounded — default is 90 days of full-detail retention, overridable through the sweep command's `ttlDays` input (a positive integer, validated with `INVALID_RETENTION_TTL`) — with a host-scheduled sweep that anonymizes rows past TTL using set-based updates; a sweep failure aborts the transaction and propagates to the scheduler, and the next run retries the same set. The 90-day default is a privacy-lean baseline, **not** a compliance recommendation: the sweep replaces user-driven `occurredBy` with `TOMBSTONED`, which removes the who-read-it dimension an investigator needs, so hosts with SOX-class retention obligations on financial events must configure `ttlDays` to their audit horizon (typically multiple years). The sanitization contract for `errorDetail` (no PII, no credentials, no full email addresses) is the writer's responsibility and is documented at the boundary; enforced by convention.

## Business Purpose

- Provide a SOX-grade audit trail on financial events (PO submitted / approved, invoice approved / rejected, supplier suspended) so that an investigator can reconstruct the full delivery and engagement timeline of any notification tied to a financial transaction
- Support GDPR data-subject access requests by giving the data-protection team a single query surface (`searchAudit({ recipientUserId })`) that returns every notification a given user has been touched by
- Support GDPR right-to-erasure with anonymize-not-delete: when a user is erased, audit rows for that user are anonymized (`recipientUserId`, rendered `subject`, and `body` on the parent Notification are replaced with the `TOMBSTONED` sentinel, and `payloadVars` / `htmlBody` are dropped) while the audit shell is preserved — deleting audit rows would defeat the compliance integrity that audit exists to provide
- Diagnose delivery failures by surfacing the FAILED / BOUNCED rows together with sanitized `errorClass` / `errorDetail` so operators can identify systemic provider issues without exposing PII
- Provide capacity and failure-rate observability across channels by letting dashboards aggregate audit rows by `eventType` and time bucket
- Enable recipients to inspect their own delivery history (their notifications, their lifecycle) for trust and self-service troubleshooting

## Process Flow

```mermaid
flowchart TD
    A[Dispatcher persists Notification with deliveryStatus=QUEUED] --> B[Inline write: audit row eventType=QUEUED, occurredBy=system]
    B --> C[Dispatcher calls channel adapter]
    C -->|IN_APP no-op| D1[Inline write: audit row eventType=SENT, then eventType=DELIVERED, both occurredBy=system; Notification deliveryStatus -> SENT -> DELIVERED]
    C -->|EMAIL provider success| D2[Inline write: audit row eventType=SENT, occurredBy=system; Notification deliveryStatus -> SENT and stops there]
    C -->|Adapter error| E[Inline write: audit row eventType=FAILED with sanitized errorClass and errorDetail; Notification deliveryStatus -> FAILED]

    F[Reserved: provider webhook ingestion] -.-> G[Channel adapter calls recordDeliveryEvent eventType=DELIVERED]
    G -.-> H[Async write: audit row eventType=DELIVERED, Notification deliveryStatus -> DELIVERED]
    F -.-> I[Channel adapter calls recordDeliveryEvent eventType=BOUNCED]
    I -.-> J[Async write: audit row eventType=BOUNCED with sanitized errorClass and errorDetail, Notification deliveryStatus -> BOUNCED]
    F -.-> K[Channel adapter calls recordDeliveryEvent eventType=OPENED]
    K -.-> L[Async write: audit row eventType=OPENED only, Notification axes unchanged]

    M[Recipient opens inbox feed] --> N[Inbox feature: add SEEN to engagementStatuses]
    N --> O[Inline write: audit row eventType=SEEN, occurredBy=recipientUserId]
    O --> P{Recipient action}
    P -->|Mark as read| Q[Inbox feature: add READ to engagementStatuses, also SEEN if absent]
    Q --> R[Inline write: audit row eventType=READ, occurredBy=recipientUserId]
    P -->|Archive| S[Inbox feature: add ARCHIVED to engagementStatuses]
    S --> T[Inline write: audit row eventType=ARCHIVED, occurredBy=recipientUserId]

    U[Host-scheduled retention sweep] --> V{Audit row older than ttlDays cutoff, default 90 days?}
    V -->|No| W[Excluded by the set-based WHERE bounds]
    V -->|Yes| X[Anonymize: clear errorDetail, replace occurredBy with TOMBSTONED if user-driven; parent Notification untouched]
```

Dotted edges denote webhook-ingestion paths reserved on the audit contract but not exercised by the dispatcher.

## Scenario Patterns

- **In-app happy path**: Transactional in-app notification produces a `QUEUED` audit row written by the plan phase, then `SENT` → `DELIVERED` rows written by the delivery worker (the IN_APP adapter is a no-op); no async callback path exercised. After delivery, recipient interaction adds `SEEN` and `READ` audit rows on engagement-axis events
- **Email happy path (provider send-API success)**: the plan phase writes the `QUEUED` audit row; the delivery worker writes the `SENT` row on provider success. The module does not consume provider webhooks, so no `DELIVERED`, `BOUNCED`, or `OPENED` audit rows are produced for the EMAIL channel. The Notification's `deliveryStatus` is `SENT` (terminal for EMAIL). Investigators can correlate with the provider's dashboards using `adapterMessageId` for downstream provider-side events
- **Email failure at delivery**: the delivery worker's adapter call returns a failure (e.g., the provider's send API returns 4xx after its internal retries, or a malformed recipient address is rejected); the worker writes a `FAILED` audit row with sanitized `errorClass` (`INVALID_RECIPIENT`, `PROVIDER_REJECTED`, etc.) and `errorDetail`, sets `Notification.deliveryStatus` to `FAILED`, and never produces a `SENT` row for that pair. (A template/validation/address failure caught at plan time instead writes the `FAILED` row in the plan phase.)
- **Inbox SEEN**: Recipient opens the inbox feed for the first time after delivery; inbox feature adds `SEEN` to `engagementStatuses` and writes an audit row with `eventType=SEEN` and `occurredBy=recipientUserId`
- **Inbox READ**: Recipient explicitly clicks a notification to mark it read; inbox feature adds `READ` (and `SEEN` if absent) to `engagementStatuses`; audit row `eventType=READ`, `occurredBy=recipientUserId`
- **Inbox ARCHIVED**: Recipient archives a notification; inbox feature adds `ARCHIVED` to `engagementStatuses`; audit row `eventType=ARCHIVED`, `occurredBy=recipientUserId`. A row may simultaneously have `READ` and `ARCHIVED` audit rows in its history
- **Idempotency deduplication**: An emitter retries a dispatch with the same `idempotencyKey`; the dispatcher returns the existing Notification (in `deduped[]`) without persisting a new one and **does not** write a new `QUEUED` audit row. The original audit trail is unchanged
- **OPENED pixel observation (reserved)**: When provider webhooks are wired, email tracking pixels would produce `OPENED` audit rows via `recordDeliveryEvent({ eventType: 'OPENED' })`. `OPENED` rows are observability-only and do not promote either lifecycle axis; `READ` (recipient click within the in-app inbox) remains the canonical engagement signal
- **Audit query by notification**: Investigator asks "what is the lifecycle of notification N?"; `listAuditByNotification(N)` returns the ordered event stream from `QUEUED` through whatever terminal `deliveryStatus` was reached, plus all engagement-axis events
- **Audit query by source**: SOX investigator asks "everyone notified that invoice I was approved"; `listAuditBySource('INVOICE_APPROVED', I)` returns all notifications fanned out from that source event with their lifecycle state
- **SOX audit search**: Auditor runs `searchAudit({ eventType: 'DELIVERED', sourceType: 'INVOICE_APPROVED', dateRange: [startQ1, endQ1] })`; result is the matrix of who-received-what-when for the quarter
- **Recipient self-service**: A recipient runs `searchAudit({ recipientUserId: self })` and gets the lifecycle of every notification they own; they cannot see other recipients' rows
- **GDPR right-to-erasure (anonymize-not-delete)**: User U requests deletion; the **host application's erasure flow** (user-management does not model user deletion) invokes `anonymizeNotificationsForUser`, which anonymizes audit rows where `occurredBy=U` (replaces `occurredBy` with `TOMBSTONED`) and anonymizes parent Notifications (replaces `recipientUserId` and the rendered `subject` / `body` with `TOMBSTONED`, drops `payloadVars` and `htmlBody` — rendered content embeds the same personal data as the input variables). Audit row `id`, `eventType`, `occurredAt`, `errorClass` are preserved so the integrity trail survives. Rationale: deleting audit defeats the compliance purpose audit exists to serve; anonymization satisfies erasure obligations while preserving "the system did notify someone at this time"
- **GDPR data-subject access**: User U requests their data; the audit feature returns every audit row where `occurredBy=U` plus every audit row tied to a Notification where `recipientUserId=U`
- **Retention sweep, full-detail TTL exceeded**: The host-scheduled sweep finds audit rows older than the full-detail TTL (default 90 days, overridable via the command's `ttlDays` input). For those rows, `errorDetail` is cleared and `occurredBy` is replaced with `TOMBSTONED` for user-driven events using set-based updates; the audit shell (id, notificationId, eventType, occurredAt) is retained as metadata-only. Anonymize, do not drop — operators tuning retention can extend or shorten the TTL without losing structural completeness of the audit
- **Retention sweep failure propagation**: A storage failure during the sweep aborts the transaction and propagates to the scheduler — nothing is silently swallowed; the next scheduled run retries the same set (the WHERE bounds make passes idempotent)
- **Sanitization contract enforcement at the boundary**: Channel adapter writers are required to sanitize `errorDetail` before calling `recordDeliveryEvent` — strip `Authorization` headers, mask the local part of email addresses (keep the domain), strip API keys and tokens. Enforcement is documented contract; the feature does not parse or validate the free-text content
- **Audit row immutability in the normal path**: Once written, audit rows are never overwritten by feature logic. The retention sweep is the single exception, and it only anonymizes (clears) fields; it never changes `id`, `notificationId`, `eventType`, or `occurredAt`
- **No audit on rejected dispatch**: When the dispatcher rejects an event before persisting any Notification (`CATEGORY_NOT_FOUND`) or suppresses a pair before anchoring a row (`skipped[]` reasons), no audit rows are written by this feature — there is no Notification to anchor them to. A `TEMPLATE_NOT_FOUND` pair, by contrast, **does** anchor a blank-content Notification and produces `QUEUED` + `FAILED` audit rows
- **Bounce arrives after retention sweep**: A late provider bounce arrives for a Notification whose audit rows have been anonymized; the BOUNCED row is still written, joins the anonymized history, and updates `Notification.deliveryStatus` to BOUNCED. The sweep does not delete the parent Notification, so late writes remain valid

## Test Cases

- A successful IN_APP delivery should write three audit rows for the resulting Notification with `eventType` values `QUEUED` (plan phase), `SENT`, and `DELIVERED` (delivery worker) in that occurredAt order
- A successful EMAIL delivery (provider send-API success) should produce exactly two audit rows: `QUEUED` (plan phase) and `SENT` (delivery worker); no `DELIVERED`, `BOUNCED`, or `OPENED` rows are produced because the module does not consume provider webhooks
- An EMAIL delivery where the adapter returns a failure (post-internal-retry provider failure or address validation failure) should produce a `QUEUED` row followed by a `FAILED` row with populated `errorClass` and `errorDetail`, and no `SENT` row
- An emitter retry matching an existing idempotency key should not produce a duplicate `QUEUED` audit row; the existing audit trail is unchanged
- An audit row for a system-driven event (`QUEUED`, `SENT`, `DELIVERED`, `FAILED`) should have `occurredBy` set to the `system` sentinel
- An audit row for a user-driven event (`SEEN`, `READ`, `ARCHIVED`) should have `occurredBy` set to the recipient's userId
- An audit row should never carry rendered subject, body, or `payloadVars` — content fields should not appear on the audit schema at all
- The `recordDeliveryEvent` port should accept `DELIVERED`, `BOUNCED`, and `OPENED` eventType values for forward compatibility, but the dispatcher / EMAIL adapter should not invoke this path (no production calls)
- `listAuditByNotification(notificationId)` should return all audit rows for that notification ordered by `occurredAt` ascending
- `listAuditBySource(sourceType, sourceId)` should return audit rows for every Notification fanned out from the given source event
- `searchAudit({ eventType, dateRange, recipientUserId, sourceType })` should return only audit rows matching all supplied filters
- A recipient querying `searchAudit` should be able to see audit rows for their own notifications and should not see rows for other recipients
- A tenant admin / auditor role should be able to query `searchAudit` for any recipient
- GDPR erasure for user U should anonymize `occurredBy` to `TOMBSTONED` on every audit row where `occurredBy=U`, and should anonymize `recipientUserId` to `TOMBSTONED` and scrub `payloadVars` plus the rendered `subject` / `body` / `htmlBody` on every Notification where `recipientUserId=U`, while preserving audit row `id`, `notificationId`, `eventType`, `occurredAt`, and `errorClass`
- The retention sweep should anonymize audit rows older than the configured full-detail TTL with set-based updates that clear `errorDetail` and replace user-driven `occurredBy` with `TOMBSTONED`, while preserving `id`, `notificationId`, `eventType`, and `occurredAt`
- The retention sweep default TTL should be 90 days when no `ttlDays` override is supplied
- The retention sweep should reject a non-positive-integer `ttlDays` with `INVALID_RETENTION_TTL` before touching any row
- A storage failure during the sweep should abort the transaction and propagate to the scheduler instead of being silently swallowed per row
- An audit row that has been anonymized by the sweep should never be re-anonymized or overwritten; the WHERE bounds exclude it on subsequent passes
- The sweep should return the anonymized row counts (`rowsAnonymized`, `destinationRowsAnonymized`) so the scheduler can report progress
- A bounce callback arriving for a Notification whose earlier audit rows have already been anonymized should still write a `BOUNCED` audit row and update `Notification.status`
- The dispatcher should not produce an audit row for a `(recipient, channel)` pair that was suppressed by preference filtering (no Notification persisted means no audit rows)
- Calling `recordDeliveryEvent` for a `notificationId` that does not exist should fail without writing an audit row
- Calling `recordDeliveryEvent` from an authenticated channel adapter context should write the audit row; calling it from an unauthorized context should be rejected
- Audit rows should be append-only in the normal path: feature commands other than the retention sweep should never update or delete an existing row
- A DESTINATION post (e.g. Slack) should write a `DestinationDeliveryLog` row, not a `NotificationDeliveryAudit` row, and a DESTINATION failure should never create or mutate a `NotificationDeliveryAudit` row
- The retention sweep should clear `providerResponse` and `failureReason` on `DestinationDeliveryLog` rows past the TTL while preserving the row's structural shell, in the same sweep pass that anonymizes `NotificationDeliveryAudit`
- `DestinationDeliveryLog` should not be readable through the GraphQL gateway (auto-CRUD closed to prevent cross-tenant read exposure); it is written only by the dispatcher through the in-transaction db handle

## Reference Links

- See module README
- [notification-delivery](./notification-delivery.md) — writes most audit rows: `QUEUED` (and plan-time `FAILED`) in the plan phase, `SENT` / `DELIVERED` / delivery-time `FAILED` in the delivery worker
- [notification-channels](./notification-channels.md) — async event source for DELIVERED, BOUNCED, OPENED via channel adapter callbacks into `recordDeliveryEvent`
- [notification-inbox](./notification-inbox.md) — user-action event source for SEEN, READ, ARCHIVED audit rows
