# wire-notification-channels **Package**: `@suaveplan/api-plugin-notifications` **Status**: PROPOSED **Type**: feature **Spec delta**: ./specs/api-plugin-notifications/spec.md ## Why The initial migration (`add-api-plugin-notifications`, archived 2026-05-19) explicitly scoped out four classes of delivery wiring because their dependencies had not yet landed on genesis. Quote from the archived proposal (`openspec/archive/2026-05-19-add-api-plugin-notifications/proposal.md` L97-L138, `### Excluded from this migration`): - FCM / Web Push / Email / SMS / Slack channels — depend on `@suaveplan/notification-channel`, `@suaveplan/emailer`, etc. - Audit-log emission, idempotency, rate-limit middleware — depend on `@suaveplan/api-plugin-audit`, `@suaveplan/idempotency`, `@suaveplan/api-plugin-rate-limit`. - Delivery tracking — depends on `@suaveplan/delivery-tracking`. Three of those deps have since shipped on `genesis`: `@suaveplan/notification-channel` (267 tests, full router + transports), `@suaveplan/emailer` (136 tests, three transports), and `@suaveplan/delivery-tracking` (71 tests, channel-agnostic event tracker). `@suaveplan/idempotency` was already shipped at the time of the initial migration. Now that the deps exist, this change recovers the **dispatcher** slice of the excluded scope so the plugin can route a notification across multiple channels instead of being APN-only at the route layer. Audit and rate-limit middleware composition remain host-app concerns (the original proposal documented them as such) and are not in scope here. This is a `MODIFIED-Requirements` follow-up — the package (`@suaveplan/api-plugin-notifications`) already shipped at commit `eca398680` and is archived. No new package is created; the existing package gains a thin dispatcher layer on top of its APN service. ## What changes - ADDED: `NotificationDispatcher` class — wraps `notification-channel`'s `NotificationRouter` and exposes a single `dispatch(input)` surface that fans out to the registered channels with delivery-tracking emission. - ADDED: `createNotificationDispatcher(options)` factory — builds a dispatcher pre-wired with the APN and email channels (when the caller supplies an `ApnService` / `EmailService`), an optional delivery-event store, an optional idempotency guard, and an injectable clock. - ADDED: `ApnDeliveryChannel` class — implements `notification-channel`'s `DeliveryChannel` interface against the in-package `ApnService`. Registered under channel name `apn`. Maps the APN payload shape from a generic `Notification.body` + `metadata.deviceToken` (or `metadata.deviceTokens` for fan-out). - ADDED: `EmailDeliveryChannel` class — implements `DeliveryChannel` against `@suaveplan/emailer`'s `EmailService`. Registered under channel name `email`. Maps `Notification.recipient` → `EmailMessage.to`, `Notification.subject` → `subject`, `Notification.body` → `text` (HTML body when `metadata.html === true`). - ADDED: `DispatchInput` / `DispatchResult` types — Zod-validated at the public boundary; channel selection mirrors `NotificationInput.channel` and falls back to the dispatcher's default channel. - ADDED: `DispatcherIdempotencyAdapter` interface — narrow contract (`execute(key, fn)`) that lets callers plug `@suaveplan/idempotency`'s `IdempotencyGuard` (or any equivalent) without the package taking a hard runtime dep on the guard. Default is the in-package `NoopIdempotencyAdapter` that just calls the closure. - ADDED: `NoopIdempotencyAdapter` — the default implementation that executes the closure without any dedup. Exported so consumers can reuse it in tests. - ADDED: `NOTIFICATIONS_PLUGIN_CODES` entries `DISPATCH_FAILED`, `DISPATCH_CHANNEL_UNAVAILABLE`, `DISPATCH_INPUT_INVALID`, `EMAIL_SERVICE_UNAVAILABLE` — surfaced from the new dispatcher / email channel. - ADDED: `notificationsDispatchedTotal` and `notificationsDispatchFailedTotal` OTel counters in `getNotificationsInstruments()`. - ADDED: `services/registry-augmentation.ts` slots `notificationDispatcher` (`NotificationDispatcher`) and `emailService` (`EmailService`) — both optional, registered only when the caller supplies them through new plugin options. - ADDED: `NotificationsPluginOptionsSchema` extends with `enableDispatcher: boolean (default false)`, `defaultChannel` (`NotificationChannel` from notification-channel, default `apn`), and a nested `delivery` block (`store`, `tenantId`, `idempotency.adapter`, `idempotency.keyFrom(input)`). - MODIFIED: `createNotificationsPlugin` `onInit` — when the host injects an `EmailService` via the new `services.emailService` option, register it under `emailService`. When `enableDispatcher` is on, instantiate a `NotificationDispatcher` from the configured channels (`apn` if `apnService` is present, `email` if `emailService` is present) and register it under `notificationDispatcher`. `onClose` clears the dispatcher too. - ADDED: Subpath exports under `./dispatcher/notification-dispatcher`, `./channels/apn-channel`, `./channels/email-channel`, `./schemas/dispatch-schemas`. ## Out of scope - Rewriting the existing route layer to call the dispatcher. The routes still call `apnService` directly — adding a dispatcher surface is additive and consumers opt in. (Cross-cutting route refactor is a separate change.) - FCM / Web Push / SMS / Slack delivery channels — no upstream transport exists on genesis yet (`@suaveplan/sms-providers`, `@suaveplan/slack-client` remain pending). Each lands in a follow-up change once its transport package ships. - Audit-log middleware composition — host-app concern per the archived proposal (L135-L138). Not modified here. - Rate-limit middleware composition — same. Hosts compose `@suaveplan/api-plugin-rate-limit` at the app layer. - Schema migrations or queue/history tables — the slimmed schema from the initial migration is unchanged. ## Source mapping This change derives from `../suaveplan/enterprise/api-plugin-notifications.md` "Required enterprise-quality improvements" (the now-unblocked rows) and from the archived genesis proposal at `openspec/archive/2026-05-19-add-api-plugin-notifications/proposal.md`. | Requirement | Source lines | Notes | |---|---|---| | Multi-channel dispatcher routing notifications to email/push/sms/webhook | archived proposal L113-L117 (`### Excluded — FCM / Web Push / Email / SMS / Slack channels`) | recovered now that `@suaveplan/notification-channel` ships | | Email channel via `@suaveplan/emailer` | archived proposal L113-L117 (same row) | concrete email channel binding | | Delivery-tracking integration | archived proposal L124-L130 (`Preference center, … delivery tracking, …`) | wired via `@suaveplan/delivery-tracking`'s `DeliveryEventStore` | | Optional idempotency on dispatch | archived proposal L135-L138 (`Audit-log emission, idempotency, rate-limit`) | adapter contract so the package never hard-deps on a guard | | APN delivery as one channel among several | enterprise/api-plugin-notifications.md "Cross-channel routing" section; archived proposal L34-L33 | wraps existing `ApnService` as a `DeliveryChannel` | ### Requirements with no source line | Requirement | Justification | |---|---| | `DispatcherIdempotencyAdapter` interface (instead of a hard `IdempotencyGuard` dep) | Genesis convention — keep `api-plugin-notifications` from gaining a runtime dep on `@suaveplan/idempotency`. Callers that want dedup wire their own guard through the adapter. | | `notificationsDispatchedTotal` + `notificationsDispatchFailedTotal` OTel counters | Genesis-wide observability policy (CLAUDE.md "Quality", AGENTS.md §13) — every new code path emits a counter. | | `enableDispatcher` opt-in flag | Backwards compatibility — the existing routes/services keep working unchanged when the flag is off. | ## Verification approach - `src/dispatcher/notification-dispatcher.test.ts` — dispatcher surface end-to-end: success, channel-disabled, transport failure, delivery-tracking emission, idempotency dedup, default-channel fallback, multiple-channel fan-out, OTel emission with real `MeterProvider`. - `src/channels/apn-channel.test.ts` — APN `DeliveryChannel` against the in-package `MemoryApnTransport`-backed `ApnService`: success path, per-device failure, missing-token rejection, bulk recipient fan-out via `metadata.deviceTokens`. - `src/channels/email-channel.test.ts` — Email `DeliveryChannel` against a `DummyTransport`-backed `EmailService`: success, missing subject, transport failure → `DeliveryResult.error`. - `src/schemas/dispatch-schemas.test.ts` — Zod boundary tests for `DispatchInput` validation. - `src/schemas/options.test.ts` — MODIFIED to cover the new `enableDispatcher` / `defaultChannel` / `delivery` fields. - `src/plugin/plugin.test.ts` — MODIFIED to cover the dispatcher registration path (`onInit` registers `notificationDispatcher` when `enableDispatcher` is on; `onClose` clears it). - `src/api-plugin-notifications.spec-conformance.test.ts` — MODIFIED to pin every new public symbol exported by the package barrel. - `src/error-codes/error-codes.test.ts` — MODIFIED to pin reachability of the four new codes. - `src/otel/instruments.test.ts` — MODIFIED to pin the two new counters. ## Migration impact None to existing consumers. The dispatcher and the new channels are opt-in: - `createNotificationsPlugin()` with no options behaves identically (no dispatcher registered, routes still call `apnService` directly). - Existing imports (`NotificationRepository`, `ApnService`, `createPushRoutes`, etc.) are unchanged. ### Affected dependents None on `genesis` today — `@suaveplan/api-plugin-notifications` had no downstream consumers at the time of writing. Future consumers that opt in to the dispatcher gain multi-channel routing without changing call sites for the existing routes. ## Impact Additive — no removed surface, no signature changes to existing public symbols.