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_notificationswith read state.messaging.webhook_endpointswith 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
NotificationChannelPortadapter delivered with FS-LOY-0014. - Deciding, rendering or queueing.
Behaviour (normative)
- An adapter delivers and reports, nothing else. It never queries consent, suppression or configuration. If it needs to, the cascade was wrong.
- Every adapter returns a normalized result: accepted with a provider id, or failed with a
classification of
retryableorterminal. A provider error the adapter cannot classify isretryable— retrying a terminal failure wastes a job, but not retrying a transient one loses a message. - No provider SDK is imported outside its adapter file. Enforced by lint (R21).
- 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.
- 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.
- Push is delivered through FCM for both platforms. An invalid token returns terminal and suppresses that token, exactly like a hard bounce.
- Adapters are stateless and independently deployable. Adding one is a new file plus a catalog row, never a change to the dispatcher.
- Every adapter records provider latency, so a slow provider is visible before it becomes a queue.
Data (normative)
API (normative)
Events (normative)
None emitted by the adapters. They produce provider results, which FS-MSG-0003 turns into status history and events.Acceptance criteria (normative)
- No provider SDK is imported outside an adapter file — enforced by a lint rule with a failing fixture.
- Every adapter returns a normalized result, and an unclassifiable error is
retryable. - A webhook to a private IP range is rejected before any request leaves.
- A webhook signature verifies with the documented algorithm and a replayed timestamp is rejected.
- An invalid push token returns terminal and suppresses that token.
- In-app notifications respect the cascade — a member who disabled the channel receives none.
- An endpoint failing consecutively past the threshold is deactivated and its tenant notified.
- Negative: no adapter reads consent, suppression or channel configuration.
Execution
Single slice, synchronous command from the worker’s perspective. Adapters live withbackend/workers; the port is in packages/core. This is the second half of TS-002.