Files
suaveplan/openspec/archive/2026-05-19-wire-notification-channels/proposal.md
2026-05-27 23:29:53 +01:00

9.9 KiB

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<T>(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.