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

192 lines
9.9 KiB
Markdown

# 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.