> ## Documentation Index
> Fetch the complete documentation index at: https://internal.softcrum.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Channel Adapters — Email, In-App, Webhook, Push

> After this ships, a resolved message actually reaches a person — by email, in the app, as a push, or into the tenant's own system.

## Context

Four adapters, one contract. `NotificationChannelPort` (amendment A1) says an adapter does exactly
one thing: `send(recipient, renderedContent, opts) -> ProviderResult`. It does not decide whether to
send, does not choose a channel, does not check consent. Those decisions were made upstream, and an
adapter that second-guesses them is an adapter that will disagree with the cascade.

They are one delivery rather than four because they implement the same contract with the same tests;
splitting them would repeat one spec four times and invite four different interpretations of it.

The outgoing webhook adapter is the odd one: its recipient is a system rather than a person, but it
goes through the same cascade, the same rails and the same status tracking. That uniformity is worth
more than the small awkwardness.

## Scope *(normative)*

* `ResendEmailAdapter`, `InAppAdapter`, `WebhookAdapter`, `FcmPushAdapter`.
* `messaging.in_app_notifications` with read state.
* `messaging.webhook_endpoints` with HMAC signing and SSRF protection.
* Provider result normalization into our status vocabulary.
* Per-adapter failure classification: retryable or terminal.

## Non-scope *(normative)*

* SMS — FS-MSG-0009, behind its provider ADR.
* WhatsApp and Live Activities — F2.
* Wallet pass updates, which are a `NotificationChannelPort` adapter delivered with FS-LOY-0014.
* Deciding, rendering or queueing.

## Behaviour *(normative)*

1. An adapter **delivers and reports, nothing else**. It never queries consent, suppression or
   configuration. If it needs to, the cascade was wrong.
2. Every adapter returns a **normalized result**: accepted with a provider id, or failed with a
   classification of `retryable` or `terminal`. A provider error the adapter cannot classify is
   `retryable` — retrying a terminal failure wastes a job, but not retrying a transient one loses a
   message.
3. **No provider SDK is imported outside its adapter file.** Enforced by lint (R21).
4. In-app notifications are stored with read state and are the only channel where the recipient
   fetches rather than being pushed. They obey the cascade like any other.
5. Outgoing webhooks are **HMAC-signed with a timestamp**, protected against SSRF (no private ranges,
   no redirects to them), and their PII inclusion is opt-in per endpoint, default off.
6. Push is delivered through FCM for both platforms. An invalid token returns terminal and
   **suppresses that token**, exactly like a hard bounce.
7. Adapters are **stateless and independently deployable**. Adding one is a new file plus a catalog
   row, never a change to the dispatcher.
8. Every adapter records provider latency, so a slow provider is visible before it becomes a queue.

## Data *(normative)*

| Table                            | Key invariants                                                                                                                                                  |
| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `messaging.in_app_notifications` | `tenant_id`, `contact_id`; FK send; `read_at` nullable; `expires_at`; partition candidate at volume                                                             |
| `messaging.webhook_endpoints`    | `tenant_id`; `url` https only, never a private range; `secret_hash`; `events` subscribed; `include_pii` default false; `is_active`; consecutive failure counter |

## API *(normative)*

| Endpoint                                         | Class      | Permission                                  | Budget       |
| ------------------------------------------------ | ---------- | ------------------------------------------- | ------------ |
| `GET /v1/messaging/in-app`                       | Runtime    | member token                                | p95 \<150 ms |
| `POST /v1/messaging/in-app/{id}/read`            | Runtime    | member token                                | p95 \<150 ms |
| `GET/POST/PATCH /v1/messaging/webhook-endpoints` | Management | `messaging.webhooks.{read\|create\|update}` | p95 \<1 s    |
| `POST /v1/messaging/webhook-endpoints/{id}/test` | Management | `messaging.webhooks.test`                   | p95 \<5 s    |

## Events *(normative)*

None emitted by the adapters. They produce provider results, which FS-MSG-0003 turns into status
history and events.

## Acceptance criteria *(normative)*

1. No provider SDK is imported outside an adapter file — enforced by a lint rule with a failing
   fixture.
2. Every adapter returns a normalized result, and an unclassifiable error is `retryable`.
3. A webhook to a private IP range is rejected before any request leaves.
4. A webhook signature verifies with the documented algorithm and a replayed timestamp is rejected.
5. An invalid push token returns terminal and suppresses that token.
6. In-app notifications respect the cascade — a member who disabled the channel receives none.
7. An endpoint failing consecutively past the threshold is deactivated and its tenant notified.
8. **Negative:** no adapter reads consent, suppression or channel configuration.

## Execution

Single slice, synchronous command from the worker's perspective. Adapters live with
`backend/workers`; the port is in `packages/core`. This is the second half of TS-002.

## Open questions

| # | Question                                                                        | Decides | By              |
| - | ------------------------------------------------------------------------------- | ------- | --------------- |
| 1 | Consecutive webhook failures before deactivation — 10, or a rate over a window? | Daniel  | before approval |
| 2 | Do in-app notifications expire, and after how long?                             | Daniel  | before approval |

## Changelog

| Version | Date       | Change        | Why | Author                 |
| ------- | ---------- | ------------- | --- | ---------------------- |
| 0.1.0   | 2026-08-17 | Initial draft | —   | daniel + claude-opus-5 |

## Delivery record

*Not implemented yet.*
