# notification-destination-delivery

## Overview

Destination Delivery is the second of the two delivery topologies the notification module supports. Where the existing per-user (PERSONAL) topology resolves an individual recipient's address and persists one `Notification` row per recipient, the DESTINATION topology posts a **single message to a shared surface** — a polymorphic destination such as a purchase order's Slack channel — once per event, independent of any recipient set.

Like the PERSONAL topology, DESTINATION delivery follows an **outbox split**: the dispatcher's **plan phase** (in-transaction) resolves each active binding and writes one `PENDING` `DestinationDeliveryLog` row carrying the rendered subject/body; a separate **delivery worker** (`internal/deliverDestination`) later posts to the shared surface from the row alone, **outside any transaction**, and advances the row to `SENT` / `FAILED`.

**Delivery guarantee: effectively-once via idempotency, with redrain for stranded rows.** A binding whose adapter returns a terminal error (`{ ok: false }` or a throw) is recorded `FAILED` and is **not** retried — operators reconcile through the log. But a `PENDING` row whose delivery never completed (a crashed executor between plan-commit and post) is re-driven by the insurance redrain cron. To keep that retry from double-posting, the delivery worker passes the log id to the adapter as an `idempotencyKey` so a re-send of the same row collapses to a single provider-side post, and the terminal write is guarded on `status = PENDING` so an already-posted row is never re-marked. This supersedes the earlier at-most-once / no-retry stance: a transient infra crash no longer silently drops a broadcast, while the idempotency key keeps a duplicate post from reaching the channel.

The two topologies are distinguished by the `kind` field on `NotificationChannel` (`PERSONAL` | `DESTINATION`). `IN_APP`, `EMAIL`, `SMS`, and `PUSH` are `PERSONAL`; `SLACK` and `TEAMS` are `DESTINATION`. A PERSONAL channel resolves `addresses[channelId]` on a recipient profile and fans out one delivery per recipient. A DESTINATION channel resolves a `ChannelRoutingBinding` for the event's `(targetType, targetId)` and emits one post per active binding, with the recipient set playing no part in addressing.

Destination routing is generic: the binding key is `(targetType, targetId, channelId)`, not entity-specific. `ChannelRoutingBinding(targetType, targetId, channelId, externalChannelRef)` lets any source entity be bound to any destination channel without the module knowing the entity's domain. The concrete posting medium (e.g. a Slack Web API call) is supplied at composition time through a `DestinationAdapter` port; the module owns only the resolution and the one-post-per-binding contract.

**Adapter contract.** The concrete posting medium is supplied at composition time through the `DestinationAdapter` port; the engine never depends on a provider SDK and never stores provider credentials. Whatever the provider, the adapter must return a normalized outcome — `ok` with a provider message id on success, or a normalized provider-specific error class in `failureReason` on failure — plus an optional **redacted** `providerResponse` that the dispatcher persists opaquely on `DestinationDeliveryLog`. The engine treats `providerResponse` as an opaque string: its concrete shape is **adapter-defined**, and redaction is the adapter's obligation — the posted message text and provider-internal metadata must never appear in it. The bundled Slack adapter (`createSlackDestinationAdapter`) is the opt-in destination created and auto-wired when the `slack` option is enabled; see [slack-workspace-integration](./slack-workspace-integration.md).

**Render locale.** A DESTINATION post has no recipient, so no per-recipient locale resolution takes place: the dispatcher renders the template with the event-supplied `locale` from the `dispatchNotification` input, falling back to the module default locale (`en-US`) when the event carries none. Template lookup then applies the same single-hop fallback as the PERSONAL topology — if no row exists for the exact locale, the default-locale row is used; if that is also missing, the binding records `TEMPLATE_NOT_FOUND`.

**DestinationDeliveryLog write.** The plan phase writes one `PENDING` `DestinationDeliveryLog` row per active binding carrying the event's `(sourceType, sourceId)` and resolved `eventType`, the binding's `(targetType, targetId, externalChannelRef)` and `channelId`, the rendered `subject` / `body`, `status = PENDING`, and `retryCount = 0` (a binding with no template is the one row written terminally `FAILED` at plan time with blank content). The delivery worker then posts from the row and writes the terminal `status` (`SENT` / `FAILED`), `providerMessageId`, the redacted `providerResponse`, the normalized `failureReason`, and stamps `completedAt`. Capturing the rendered subject/body on the row is what lets the worker — and the redrain that re-drives stranded `PENDING` rows — post without re-resolving the template. The write is independent of Stage 1: a DESTINATION log row is never anchored to a per-user `Notification`.

## Business Purpose

- Support broadcast-to-shared-surface delivery (e.g. a purchase order's Slack channel) without modelling each recipient — DESTINATION posts go to a shared channel, never per-user DMs
- Keep the notification schema domain-agnostic by binding destinations through a polymorphic `(targetType, targetId)` reference instead of a hard-coded entity foreign key
- Let one event fan out across **both** topologies at once (per-user IN_APP plus a single shared-channel post) from a single dispatch call
- Reserve `TEAMS` as a second DESTINATION channel so adding it later is purely a new adapter implementation plus binding rows
- Decouple the posting medium from the module: the provider's Web API client (e.g. Slack/Teams) is a composition-layer `DestinationAdapter`, swappable without touching dispatch logic

## Process Flow

```mermaid
flowchart TD
    A[plan phase receives event: eventType, sourceType, sourceId, recipients, payload] --> B[Stage 1: PERSONAL fan-out, queue Notification rows]
    A --> C[Stage 2: DESTINATION resolution loop]
    C --> C1[Look up ChannelRoutingBinding rows where targetType = sourceType AND targetId = sourceId AND isActive = true]
    C1 --> C2{Any active bindings?}
    C2 -->|No| C3[No destination row; stage completes]
    C2 -->|Yes| C4[For each binding: load its NotificationChannel]
    C4 --> C5{Channel kind = DESTINATION and enabled?}
    C5 -->|No| C6[Skip this binding]
    C5 -->|Yes| C7{Template exists for eventType x channel x locale, with single-hop default fallback?}
    C7 -->|No| C7b[Write terminal FAILED log: template_not_found, blank subject/body]
    C7 -->|Yes| C8[Render subject/body once per binding using the event-supplied locale, defaulting to en-US]
    C8 --> C9[Write PENDING DestinationDeliveryLog row with rendered subject/body — plan transaction commits]
    C9 -.->|delivery worker, out of transaction| D[DestinationAdapter.send: one post to externalChannelRef, idempotencyKey = log id]
    D -.-> D1{Adapter ok?}
    D1 -.->|Yes| D2[Advance PENDING -> SENT with providerMessageId, guarded on status=PENDING]
    D1 -.->|No / throw| D3[Advance PENDING -> FAILED with failureReason, guarded on status=PENDING]
```

## Scenario Patterns

- **Single destination post**: an event for `(PurchaseOrder, po-1)` with one active DESTINATION binding plans exactly one `PENDING` log row and the delivery worker makes exactly one DestinationAdapter `send` call, regardless of how many PERSONAL recipients the same event has
- **Both topologies in one dispatch**: the same event plans N per-user QUEUED Notifications (Stage 1) and one PENDING destination log (Stage 2); the two stages are independent and a delivery failure in one does not abort the other
- **No binding present**: an event whose `(sourceType, sourceId)` has no active `ChannelRoutingBinding` plans zero destination log rows and is not an error
- **Multiple bindings for one target**: a target bound to two DESTINATION channels (e.g. `SLACK` and `TEAMS`), or to one channel via two distinct `externalChannelRef` rows, plans one PENDING log per active binding
- **Inactive binding**: `isActive = false` bindings are skipped exactly like an absent binding
- **Disabled destination channel**: when the bound `NotificationChannel.enabled = false`, the binding is skipped (kill-switch parity with PERSONAL channels)
- **PERSONAL channel referenced by a binding**: a binding whose `channelId` resolves to a `kind = PERSONAL` channel is rejected/skipped — destination bindings are only valid for DESTINATION channels
- **Adapter failure on post**: DestinationAdapter returns `{ ok: false, errorClass, errorDetail }`; the delivery worker advances the log `PENDING → FAILED` with the normalized `failureReason` and the drain continues with remaining rows; no per-user Notification is created or mutated by the destination path, and the FAILED row is terminal (not retried)
- **Adapter throws**: an uncaught throw from `send` is caught by the delivery worker and converted to a FAILED log row with a generic error class (`DestinationAdapterThrew`)
- **Stranded PENDING row re-driven**: a plan committed a `PENDING` log but the executor crashed before delivery; once the row ages past the redrain grace window the insurance cron picks it up and posts it, and the `status = PENDING` guard makes a row whose terminal write already landed a no-op. Delivery is **at-least-once**: the worker passes the log-id idempotency key, but a provider that does not honor it (Slack `chat.postMessage` has none) can receive a duplicate post if the crash fell between the provider call and the `PENDING → SENT/FAILED` write. The grace window scopes the redrain to genuine strandings so it never races the inline delivery path
- **Provider-specific failure classification**: provider-level failures (revoked credentials, rate limits, timeouts, transport errors) are normalized by the concrete adapter into error classes recorded in `failureReason`, and provider hooks (e.g. fast-failing once a provider connection is revoked) are adapter concerns — the Slack taxonomy is specified in [slack-workspace-integration](./slack-workspace-integration.md)
- **Recipient-independence**: changing the recipient set of an event does not change the number or content of destination posts — addressing comes solely from the binding's `externalChannelRef`

## Test Cases

- An event with one active DESTINATION binding for its `(sourceType, sourceId)` should plan exactly one `PENDING` log row, and delivering it should invoke `DestinationAdapter.send` exactly once
- An event with no matching `ChannelRoutingBinding` should plan zero log rows and invoke `DestinationAdapter.send` zero times, completing without error
- An event with two active bindings for the same target should plan two `PENDING` log rows and invoke `DestinationAdapter.send` twice, once per binding
- A binding with `isActive = false` should be skipped
- A binding whose `NotificationChannel.enabled = false` should be skipped
- A binding whose `channelId` resolves to a `kind = PERSONAL` channel should not be planned as a destination
- A binding with no template (neither exact-locale nor default) should plan a terminal `FAILED` log with `failureReason = template_not_found` and never invoke the adapter
- The delivery worker should pass the binding's `externalChannelRef` to the adapter as the post target and the log id as the `idempotencyKey`, independent of the event's recipient list
- A successful `DestinationAdapter.send` should advance the log `PENDING → SENT` carrying the returned `externalMessageId` as `providerMessageId` and a redacted `providerResponse`, stamping `completedAt`
- A failed `DestinationAdapter.send` should advance the log `PENDING → FAILED` with a normalized `failureReason` and not abort delivery of remaining rows
- An adapter that throws should be caught and surface as a `FAILED` log row with a generic error class
- Delivering a log row whose `status` is no longer `PENDING` (already advanced) should be a no-op, making the drain idempotent
- The PERSONAL and DESTINATION delivery paths should be independent: a PERSONAL adapter failure should not suppress the destination post, and vice versa
- The number of planned destination log rows should equal the number of active, enabled, DESTINATION-kind bindings regardless of recipient count (one-event → one-row-per-binding invariant)
- A successful destination post should leave one `DestinationDeliveryLog` row with `status = SENT`, the adapter-returned provider message id as `providerMessageId`, and a redacted `providerResponse`
- A failed destination post should leave one `DestinationDeliveryLog` row with `status = FAILED` and a normalized `failureReason`, without creating or mutating any per-user `Notification` or `NotificationDeliveryAudit`
- Destination rendering should use the event-supplied `locale`, defaulting to `en-US` when the event carries none, with single-hop fallback to the default-locale template row when no exact-locale row exists
- Any `DestinationAdapter` should return its `providerResponse` as an opaque redacted string — never the posted message text or provider-internal metadata (the bundled Slack adapter's concrete redaction shape is specified and tested under the `slack` option)

## Reference Links

- [Notification module README](../../README.md)
- [notification-delivery feature](./notification-delivery.md)
- [notification-channels feature](./notification-channels.md)
- [ChannelRoutingBinding model](../model/ChannelRoutingBinding.md)
- [NotificationChannel model](../model/NotificationChannel.md)
