Skip to main content

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)

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)

  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

Changelog

Delivery record

Not implemented yet.