Files
suaveplan/openspec/archive/2026-05-19-add-notification-channel/specs/notification-channel/spec.md
2026-05-27 23:29:53 +01:00

259 lines
17 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: notification-channel (add-notification-channel)
**Package**: `@suaveplan/notification-channel`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: Stable error-code registry
The package SHALL expose `NOTIFICATION_CHANNEL_CODES` built via `defineCodes("NOTIFICATION_CHANNEL", [...] as const)` with eleven stable reason codes — `NOT_FOUND`, `CHANNEL_DISABLED`, `TEMPLATE_NOT_FOUND`, `RATE_LIMIT_EXCEEDED`, `ROUTING_FAILED`, `DELIVERY_FAILED`, `PROVIDER_ERROR`, `RATE_LIMITED`, `INVALID_RECIPIENT`, `CHANNEL_UNAVAILABLE`, `TIMEOUT` — and one typed `SuaveplanError` subclass per code. `src/index.ts` MUST side-effect-import `./error-codes.js`.
#### Scenario: every reason code is reachable
- **WHEN** `Object.keys(NOTIFICATION_CHANNEL_CODES)` is enumerated
- **THEN** the eleven literal reasons are present and every value is `"NOTIFICATION_CHANNEL.<REASON>"`
#### Scenario: every subclass carries its code
- **WHEN** an instance of any concrete error class is created
- **THEN** its `.code` matches the corresponding `NOTIFICATION_CHANNEL_CODES.<REASON>` literal
**Verified by:** `src/error-codes.test.ts::"NOTIFICATION_CHANNEL_CODES exposes the eleven stable reasons"` AND `src/error-codes.test.ts::"every error subclass carries its NOTIFICATION_CHANNEL.<REASON> code"`
---
### Requirement: Five sending delivery channels conform to a common contract
The package SHALL ship five sending `DeliveryChannel` implementations — `EmailChannel`, `SmsChannel`, `PushChannel`, `InAppChannel`, `WebhookChannel` — each exposing `name`, `canSend(notification)`, and `send(notification) => Promise<DeliveryResult>`. Failures from underlying transport MUST be captured into `DeliveryResult { success: false, error }` rather than thrown.
#### Scenario: channel send returns success with messageId
- **WHEN** a sending channel is invoked with a notification it accepts and the transport returns success
- **THEN** the result has `success: true` and the channel's `messageId`
#### Scenario: channel send captures transport failure
- **WHEN** the underlying transport throws
- **THEN** the result has `success: false` and an `error` string
**Verified by:** `src/channels/email-channel.test.ts::"sends successfully and returns result"` AND `src/channels/sms-channel.test.ts::"sends successfully and returns result"` AND `src/channels/push-channel.test.ts::"delivers to a single subscription successfully"` AND `src/channels/in-app-channel.test.ts::"sends and stores notification in memory"` AND `src/channels/webhook-channel.test.ts::"sends successfully with 200 response"` AND `src/channels/email-channel.test.ts::"returns failure when sendFn throws"`
---
### Requirement: Webhook and push channels use injectable FetchLike
`WebhookChannel` and `PushChannel` SHALL accept an injectable `FetchLike` from `@suaveplan/types` so tests run without `globalThis.fetch` patching and downstream consumers can plug their own retry/observability fetch.
#### Scenario: webhook routes through injected fetch
- **WHEN** `WebhookChannel({ fetchFn })` is configured and `.send(...)` is called
- **THEN** the injected `fetchFn` is invoked with the recipient URL and JSON payload
#### Scenario: push routes through injected sendFn
- **WHEN** `PushChannel({ sendFn })` is configured and `.send(...)` is called
- **THEN** the injected `sendFn` receives the subscription, payload, and VAPID keys
**Verified by:** `src/channels/webhook-channel.test.ts::"sends correct payload in POST body"` AND `src/channels/push-channel.test.ts::"delivers to a single subscription successfully"`
---
### Requirement: DigestChannel batches deliveries through a delegate
`DigestChannel` SHALL accept any `DeliveryChannel` as `delegate`, enqueue notifications per recipient up to `maxPerDigest`, gate flushes by `intervalMs` against the injected `Clock`, and deliver a formatted summary via the delegate when `flush` is called.
#### Scenario: digest aggregates and flushes via delegate
- **WHEN** multiple notifications are enqueued for the same recipient and `flush(recipient)` is called
- **THEN** the delegate's `send` is called exactly once with a digest body containing every enqueued message
**Verified by:** `src/digest/digest-channel.test.ts::"delivers a single digest via delegate"`
---
### Requirement: TemplateEngine renders templates with channel variants
`TemplateEngine` SHALL register templates with a `defaultTemplate` plus optional `channelTemplates`, render `{{var}}` placeholders against a flat `Record<string,string>`, fall back to the default when the requested channel has no variant, and throw `NotFoundError` for unknown template IDs.
#### Scenario: channel-specific template wins
- **WHEN** `render(id, data, "email")` is called and an email variant is registered
- **THEN** the email variant body and subject are rendered
#### Scenario: missing variant falls back to default
- **WHEN** `render(id, data, "sms")` is called and only `defaultTemplate` is registered
- **THEN** the default body is rendered
**Verified by:** `src/templates/template-engine.test.ts::"uses channel-specific template when available"` AND `src/templates/template-engine.test.ts::"falls back to default when channel variant missing"` AND `src/templates/template-engine.test.ts::"throws for non-existent template"`
---
### Requirement: ChannelRateLimiter enforces sliding-window limits per channel and per user
`ChannelRateLimiter` SHALL accept per-channel and per-user `{ maxCount, windowMs }` rules, accept an injectable `Clock`, throw `RateLimitExceededError` when a check would exceed either limit, support `isAllowed` non-recording probe, and expose remaining capacity through `getChannelRemaining`/`getUserRemaining`.
#### Scenario: channel limit throws on exhaustion
- **WHEN** `maxCount` calls have already been recorded within `windowMs`
- **THEN** the next `checkAndRecord` throws `RateLimitExceededError`
#### Scenario: window expiry releases capacity
- **WHEN** the injected `Clock` advances past `windowMs`
- **THEN** the next `checkAndRecord` succeeds
**Verified by:** `src/rate-limiter/channel-rate-limiter.test.ts::"throws when channel limit exceeded"` AND `src/rate-limiter/channel-rate-limiter.test.ts::"allows sends after window expires"` AND `src/rate-limiter/channel-rate-limiter.test.ts::"throws when user limit exceeded"`
---
### Requirement: DeliveryRetry wraps a channel with exponential backoff + dead-letter
`DeliveryRetry` SHALL accept a partial `{ maxRetries, baseDelayMs, maxDelayMs }` config, retry failed `send` calls up to `maxRetries+1` total attempts with exponential backoff capped at `maxDelayMs`, push exhausted attempts onto an internal dead-letter queue, invoke an optional `DeadLetterHandler`, and throw `DeliveryFailedError` on exhaustion.
#### Scenario: returns first success without retry
- **WHEN** the wrapped channel succeeds on the first attempt
- **THEN** the result is returned and no delay is incurred
#### Scenario: exhausts retries then dead-letters
- **WHEN** every attempt fails
- **THEN** the call throws `DeliveryFailedError` AND a `DeadLetterEntry` is appended AND the handler is invoked
**Verified by:** `src/retry/delivery-retry.test.ts::"returns result on first success"` AND `src/retry/delivery-retry.test.ts::"throws after exhausting retries"` AND `src/retry/delivery-retry.test.ts::"adds to dead letter queue on exhaustion"` AND `src/retry/delivery-retry.test.ts::"calls dead letter handler"`
---
### Requirement: NotificationScheduler manages future deliveries
`NotificationScheduler` SHALL accept an injectable `Clock`, expose `schedule(input, deliverAt)`, `cancel(id)`, `get(id)`, `listPending()`, `getDue(now?)`, async `processDue()` (calls the registered `onDue` handler and removes processed entries), and `start()`/`stop()` interval driver. `start()` MUST be idempotent and `stop()` MUST tolerate calls when no interval is running.
#### Scenario: due notifications are processed and removed
- **WHEN** `processDue()` is invoked and entries have `scheduledFor <= now`
- **THEN** the handler is awaited for each non-cancelled entry AND the entry is removed AND the return is the count processed
**Verified by:** `src/scheduler/notification-scheduler.test.ts::"processes due notifications and removes them"` AND `src/scheduler/notification-scheduler.test.ts::"does not process future notifications"` AND `src/scheduler/notification-scheduler.test.ts::"start is idempotent"` AND `src/scheduler/notification-scheduler.test.ts::"stop is idempotent"`
---
### Requirement: PreferenceManager enforces per-user opt-in/out + quiet hours
`PreferenceManager` SHALL accept a `PreferenceStoreAdapter` plus optional `defaultChannels`, return default-channel preferences when no record exists, persist enable/disable toggles, persist quiet-hours config, accept an injectable `Clock` for the quiet-hours probe, and answer `canDeliver(userId, channel, priority)` by combining channel opt-in/out, default-channel allowance, and quiet-hours vs `urgentThreshold`.
#### Scenario: default channels are allowed without explicit enable
- **WHEN** a user has no stored preferences and the channel is in `defaultChannels`
- **THEN** `canDeliver(...)` returns true
#### Scenario: quiet hours block sub-threshold priorities
- **WHEN** the injected `Clock` is inside the user's quiet-hours window and the priority is below `urgentThreshold`
- **THEN** `canDeliver(...)` returns false
**Verified by:** `src/preferences/preference-manager.test.ts::"returns defaults when no preferences stored"` AND `src/preferences/preference-manager.test.ts::"blocks low priority during quiet hours"` AND `src/preferences/preference-manager.test.ts::"allows urgent during quiet hours"` AND `src/preferences/preference-manager.test.ts::"respects custom urgentThreshold"`
---
### Requirement: NotificationRouter is the primary routing surface
`NotificationRouter` SHALL accept channels, optional templates, optional routing rules, a default channel, an injectable `Clock`, and optional pre-registered `DeliveryChannel` implementations. `send(input)` MUST resolve the channel (input override → routing rules → default), apply the `notification:routing` hook pipeline, resolve content (template or raw body), check enabled+rate-limit, persist the notification record, emit `notification.sent` or `notification.failed` over the internal `EventBus`, and optionally dispatch to a registered `DeliveryChannel` with the routed notification.
#### Scenario: notification with input channel is sent and emitted
- **WHEN** `send({ channel: "email", recipient, body })` succeeds
- **THEN** the result is `{ status: "sent", channel: "email" }` AND `notification.sent` fires on the bus
#### Scenario: disabled channel rejects the send
- **WHEN** the targeted channel has `enabled: false`
- **THEN** `send(...)` rejects with a `SuaveplanError` whose code is `NOTIFICATION_CHANNEL.CHANNEL_DISABLED`
#### Scenario: rate-limit exhaustion rejects the send
- **WHEN** the channel's per-router `rateLimit` is reached within the 60-second window
- **THEN** `send(...)` rejects with a `SuaveplanError` whose code is `NOTIFICATION_CHANNEL.RATE_LIMIT_EXCEEDED`
#### Scenario: routing rule selects channel by priority
- **WHEN** a routing rule with `priority: "urgent"` and `channels: ["sms"]` is configured and `send({ priority: "urgent" })` is called without `channel`
- **THEN** the result has `channel: "sms"`
#### Scenario: cancel after send is rejected
- **WHEN** `cancel(id)` is called against a notification whose status is `sent`
- **THEN** the call rejects with a `SuaveplanError` whose code is `NOTIFICATION_CHANNEL.ROUTING_FAILED`
**Verified by:** `src/router/notification-router.test.ts::"sends a notification to a channel"` AND `src/router/notification-router.test.ts::"emits notification.sent event"` AND `src/router/notification-router.test.ts::"throws when channel is disabled"` AND `src/router/notification-router-routing.test.ts::"enforces rate limit per channel"` AND `src/router/notification-router-routing.test.ts::"routes based on priority rule"` AND `src/router/notification-router.test.ts::"throws when cancelling sent notification"`
---
### Requirement: Hooks pipeline runs before final routing
The package SHALL expose `notificationHooks.hook("notification:routing", handler)` and the convenience `onNotificationRouting(handler)`. Registered handlers MUST receive `{ input, resolvedChannel, metadata }`, MAY return a partial override merged into the data flowing forward, and the router MUST adopt the final `resolvedChannel` for downstream resolution.
#### Scenario: hook overrides the resolved channel
- **WHEN** a handler returns `{ resolvedChannel: "sms" }` for an input whose default would resolve to `"email"`
- **THEN** the notification is routed to SMS
**Verified by:** `src/hooks/hooks.test.ts::"hook handler overrides resolved channel"` AND `src/hooks/hooks.test.ts::"multiple handlers compose left-to-right"`
---
### Requirement: OTel metrics on the router hot path
The package SHALL register four instruments — `notification_channel.routed` (Counter), `notification_channel.delivery_attempts` (Counter), `notification_channel.dead_letters` (Counter), `notification_channel.latency_ms` (Histogram) — on a meter named `@suaveplan/notification-channel`, refreshable via `refreshNotificationMeters()` so tests can rebind them against an in-memory `MeterProvider`. Every `NotificationRouter.send` invocation MUST record `routed` and `latency_ms` with the resolved channel and the outcome.
#### Scenario: router records a `routed` counter on every send
- **WHEN** `send(...)` succeeds
- **THEN** the `notification_channel.routed` counter increments by one with `{ channel, outcome: "ok" }`
**Verified by:** `src/otel/metrics.test.ts::"router records routed counter on send"` AND `src/otel/metrics.test.ts::"router records latency histogram on send"`
---
### Requirement: Zod boundary schemas validate every public option bundle
The package SHALL expose Zod schemas for every public option bundle — `NotificationRouterConfigSchema`, `EmailChannelConfigSchema`, `SmsChannelConfigSchema`, `WebhookChannelConfigSchema`, `PushChannelConfigSchema`, `DigestChannelConfigSchema`, `RetryConfigSchema`, `RateLimitRuleSchema`, `ChannelConfigSchema`, `RoutingRuleSchema`, `NotificationInputSchema`, `NotificationTemplateSchema`, `TemplateRegistrationSchema`, `PreferenceManagerOptionsSchema` — and call `.parse(...)` at the constructor / public-method boundary so malformed inputs are rejected with a structured Zod error before any side effect runs.
#### Scenario: invalid router config is rejected at construction
- **WHEN** `new NotificationRouter({ channels: "wrong" } as any)` is called
- **THEN** a Zod validation error is thrown before any internal state is set up
**Verified by:** `src/schemas/schemas.test.ts::"router config schema rejects malformed channels"` AND `src/schemas/schemas.test.ts::"every public option schema parses a real example"`
---
### Requirement: FakeFetch test double for deterministic HTTP
The package SHALL ship a `FakeFetch` class implementing `FetchLike`, queueing scripted `FakeFetchResponse` entries in FIFO order, recording every call into a `calls` array, and supporting `pending` (never-resolves with abort) and `reject` (synchronous rejection) modes for abort/timeout tests. The double MUST be importable via the `./fakes/fake-fetch` subpath.
#### Scenario: scripted response is consumed in order
- **WHEN** two responses are pushed and the fake-fetch is invoked twice
- **THEN** each call receives the corresponding scripted response and the calls array contains both invocations
**Verified by:** `src/fakes/fake-fetch.test.ts::"consumes scripted responses in FIFO order"` AND `src/fakes/fake-fetch.test.ts::"rejects when queue is empty"`
---
### Requirement: Universal-tier purity
The package SHALL ship as universal-tier — zero `node:*` imports, zero React, zero DOM-only globals at module load — so it loads identically in Bun, Node, and the browser. `"sideEffects": false` MUST hold and the only top-level side effect MUST be the registration of the `notificationHooks` singleton plus the meter-creation calls in `otel/metrics.ts`.
#### Scenario: bundler treeshakes unused channels
- **WHEN** a consumer imports only `EmailChannel`
- **THEN** the produced bundle does not contain `WebhookChannel`/`PushChannel`/`SmsChannel`/`InAppChannel`/`DigestChannel` symbols
**Verified by:** `src/index.ts` (every export is a pure value/class — universal-tier purity is enforced statically by the `tsconfig.lib.json` `lib: ["ES2022", "DOM"]` and the lack of any `node:*` import — checked at lint time via the dependency invariants the package's `package.json` declares)