# DestinationDeliveryLog

## Description

DestinationDeliveryLog is the DESTINATION-side delivery audit: one row per post attempt against a `ChannelRoutingBinding` for an event. Where `NotificationDeliveryAudit` records the per-recipient lifecycle of PERSONAL notifications (anchored by a `notificationId` FK), the DESTINATION topology produces no per-user `Notification` row — it posts once to a shared surface — so its delivery trace cannot hang off a Notification. DestinationDeliveryLog is the separate, parallel audit for that broadcast path.

The audit keys are deliberately generic: the polymorphic destination triple `(targetType, targetId, externalChannelRef)` and the event's `(sourceType, sourceId)`, so any source entity posting to any DESTINATION channel is auditable without the module knowing the entity's domain or the provider. Each row carries the attempt `status` (`PENDING` | `SENT` | `FAILED`), the resolved `eventType`, an `idempotencyKey` (the event's logical identity `eventType:sourceType:sourceId:payloadHash`), the provider message id (`providerMessageId`, e.g. the Slack `ts`), a **redacted** `providerResponse` (an adapter-defined redacted shape — e.g. the Slack adapter's `{ ok, ts, channel, error }` — never the posted message text or provider-internal metadata), a normalized `failureReason`, a `retryCount`, and `attemptedAt` / `completedAt` timestamps.

The row doubles as the **DESTINATION outbox row**: the dispatcher's plan phase writes it at `PENDING` with the `subject` / `body` rendered at plan time captured on the row, and a separate **delivery worker** (`internal/deliverDestination`) later posts to the shared surface from the row alone (outside any transaction) and advances `status` to `SENT` / `FAILED`. Capturing the rendered content on the row is what lets the worker — and the insurance redrain that re-drives stranded `PENDING` rows — post without re-resolving the template.

Two protections are built in. **(1) auto-CRUD closure**: the model disables the GraphQL gateway's auto create/read/update/delete operations entirely, because the dispatcher writes these rows through the in-transaction db handle (not the gateway) and leaving auto-CRUD open would expose provider diagnostics through an unauthenticated-by-scope read surface — closing it removes the cross-tenant read-exposure risk wholesale. **(2) retention sweep**: the retention sweep clears `providerResponse` and `failureReason` on rows past TTL, mirroring the `NotificationDeliveryAudit` errorDetail sweep, so provider diagnostics are not retained indefinitely.

## Domain Model Definitions

### Model type

Stateful

#### State Transitions

```mermaid
stateDiagram-v2
    [*] --> PENDING: plan phase (outbox row written with rendered subject/body)
    [*] --> FAILED: plan phase (template not found)
    PENDING --> SENT: delivery worker (adapter ok)
    PENDING --> FAILED: delivery worker (adapter error / throw / no workspace)
```

| Operation      | From    | To     | Command                                                                                                     |
| -------------- | ------- | ------ | ----------------------------------------------------------------------------------------------------------- |
| AdapterSuccess | PENDING | SENT   | [dispatchNotification](../feature/notification-delivery.md) delivery worker (`internal/deliverDestination`) |
| AdapterError   | PENDING | FAILED | [dispatchNotification](../feature/notification-delivery.md) delivery worker (`internal/deliverDestination`) |

The `[*] --> PENDING` / `[*] --> FAILED` entry arrows above are **not** lifecycle transitions: the plan phase sets the initial `status` directly when it inserts the row, not via a `from`-state transition. They are omitted from this table so the generated lifecycle's state set stays exactly `{PENDING, SENT, FAILED}`.

> The plan phase writes the row at `PENDING` (the normal initial state) with the rendered `subject` / `body` captured, except a `template_not_found` binding which is written terminally `FAILED` at plan time (it can never be posted). The delivery worker then advances each `PENDING` row to its terminal `SENT` / `FAILED` outside any transaction, stamping `completedAt`. Each terminal transition is conditional on `status = PENDING`, so a redrain re-drive of an already-advanced row is a no-op.

### Command Definitions

- [dispatchNotification](../feature/notification-delivery.md) - Plan phase: writes one `PENDING` DestinationDeliveryLog row per active binding in the DESTINATION stage, with the rendered subject/body captured (or a terminal `FAILED` row when the template is missing). The post outcome and redacted provider response are written later by the delivery worker (`internal/deliverDestination`), which advances the row to `SENT` / `FAILED`
- [runNotificationAuditRetentionSweep](../command/RunNotificationAuditRetentionSweep.md) - Clears `providerResponse` / `failureReason` on DestinationDeliveryLog rows past the TTL alongside the PERSONAL-audit sweep

### Query Definitions

- No dedicated gateway query — auto-CRUD is closed so provider diagnostics are never exposed through the gateway. Operators read DestinationDeliveryLog through internal/admin tooling.

### Models

- DestinationDeliveryLog

### Invariants

- One row per `(binding, event)` post attempt; the row count equals the number of DESTINATION posts attempted, independent of the recipient set. Dedup is keyed on `(idempotencyKey, channelId, externalChannelRef)`: re-planning the same logical event (e.g. a duplicate `NotificationEvent` row) reuses the existing log instead of inserting a second one, so a shared-channel post is never duplicated; because `idempotencyKey` carries the `payloadHash`, two *distinct* events for the same `(eventType, sourceType, sourceId)` still get separate posts (symmetric with the PERSONAL path's `loadExistingNotification`)
- `providerResponse` is stored opaquely as an adapter-defined redacted shape (e.g. the Slack adapter's `{ ok, ts, channel, error }`) — the posted message text, rich blocks, and provider-internal metadata are never stored
- `status = SENT` carries a non-null `providerMessageId` (the adapter-returned message id, e.g. the Slack `ts`); `status = FAILED` carries a normalized `failureReason`. The `failureReason` values are **adapter-defined and enumerated by each concrete adapter's taxonomy** (e.g. the bundled Slack adapter's `workspace_not_connected`, `SlackTokenRevoked`, `SlackChannelUnreachable`, `SlackApiError` — see [slack-workspace-integration](../feature/slack-workspace-integration.md)); the dispatcher persists them opaquely and the notification core does not define provider reason codes itself. An event with no active `ChannelRoutingBinding` produces **zero rows** (not a `FAILED` row), so there is no `no_channel_binding` reason
- A DESTINATION post failure never creates or mutates a per-user `Notification` or `NotificationDeliveryAudit` row — the two audit tracks are fully independent
- The plan phase writes each row at `PENDING` with the rendered `subject` / `body` and the resolved `eventType` captured, so the delivery worker can post from the row alone; a `template_not_found` binding is the one row the plan phase writes terminally `FAILED` (blank `subject` / `body`)
- The delivery worker's terminal write (`PENDING → SENT` / `FAILED`) is guarded on `status = PENDING`, so a concurrent worker or redrain that already advanced the row is never overwritten
- Auto-CRUD GraphQL operations are disabled; rows are written only by the dispatcher (plan phase, in-transaction) and the delivery worker (out of transaction), never through the gateway (guards against cross-tenant read exposure of provider diagnostics)
- The retention sweep clears `providerResponse` and `failureReason` on rows past TTL while preserving the structural shell (`id`, `eventType`, `sourceType`, `sourceId`, `targetType`, `targetId`, `channelId`, `status`, `attemptedAt`). **The rendered `subject` / `body` captured for the delivery worker are NOT cleared by the sweep** — they persist for the row's lifetime, so any PII embedded in the rendered destination message is retained past the diagnostics TTL (a known gap; the PERSONAL anonymization path scrubs `Notification.subject` / `body`, but no equivalent exists for DestinationDeliveryLog content)

### Relationships

- **Audits ChannelRoutingBinding posts**: each row records one post attempt against a binding's `(targetType, targetId, externalChannelRef)`
- **References polymorphic source as `(sourceType, sourceId)`**: the originating event, for "every destination post fanned out from this source" queries
- **Parallel to NotificationDeliveryAudit**: PERSONAL lifecycle (per-recipient, Notification-anchored) vs DESTINATION attempts (broadcast, binding-anchored); the split mirrors the PERSONAL / DESTINATION channel `kind`
