Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
# Ship @suaveplan/notification-channel
**Package**: `@suaveplan/notification-channel`
**Status**: PROPOSED
**Type**: new-package (migration)
- **Design**: ./design.md (inlined below — single-file change)
- **Spec delta**: ./specs/notification-channel/spec.md
## Why
Genesis needs a unified multi-channel notification router so downstream services (api-plugin-notifications, marketing-ops, alerting) have a single, deterministic surface for delivering messages over email, SMS, push, in-app, and webhooks. The upstream `@suaveplan/notification-channel@1.2.6` ships in `../suaveplan/packages/universal/notification-channel/` with 34 source files and ~95% coverage; this change lands a feature-complete port at 100/100/100/100 coverage, rebuilt against the genesis foundation (`defineCodes` registry, `EventBus`, Zod boundaries, OTel meters, injectable `FetchLike`/`Clock`).
## What changes
This is a brand-new package — every export is ADDED.
- ADDED: `NotificationRouter` — primary entrypoint; routes notifications across channels with template rendering, sliding-window rate limiting, status lifecycle, EventBus emission, optional delivery-channel dispatch.
- ADDED: `EmailChannel`, `SmsChannel`, `PushChannel`, `InAppChannel`, `WebhookChannel`, `DigestChannel` — six `DeliveryChannel` implementations.
- ADDED: `TemplateEngine` — channel-specific template variants with `{{var}}` substitution.
- ADDED: `ChannelRateLimiter` — sliding-window per-channel + per-user rate limits.
- ADDED: `DeliveryRetry` — exponential-backoff retry wrapper with dead-letter queue.
- ADDED: `NotificationScheduler` — schedule-for-future + interval processing.
- ADDED: `PreferenceManager` — per-user opt-in/out + quiet-hours, storage-adapter pluggable.
- ADDED: `NOTIFICATION_CHANNEL_CODES` — `defineCodes("NOTIFICATION_CHANNEL", […])` registry with 11 stable reason codes.
- ADDED: `notificationHooks` / `onNotificationRouting` — pre-routing hook pipeline.
- ADDED: `FakeFetch` test double for `FetchLike`.
- ADDED: OTel meters: `notification_channel.routed`, `notification_channel.delivery_attempts`, `notification_channel.dead_letters`, `notification_channel.latency_ms`.
- ADDED: Zod schemas at every public option boundary.
## Source mapping
This change derives from `../suaveplan/enterprise/notification-channel.md` and the upstream package source.
| Requirement | Source lines | Notes |
|---|---|---|
| Multi-channel router with EventBus + lifecycle | `../suaveplan/packages/universal/notification-channel/src/router/notification-router.ts` L1-L313 | Re-shaped onto genesis `EventBus`; injectable `Clock`. |
| Email / SMS / Push / InApp / Webhook channels | `src/channels/*.ts` (upstream) | Webhook uses injectable `FetchLike`. Push uses injectable `FetchLike` for VAPID POST. |
| DigestChannel | `src/digest/digest-channel.ts` | Injectable `Clock` for interval gating. |
| TemplateEngine with channel variants | `src/templates/template-engine.ts` | Verbatim. |
| ChannelRateLimiter sliding-window | `src/rate-limiter/channel-rate-limiter.ts` | Injectable `Clock`. |
| DeliveryRetry + dead-letters | `src/retry/delivery-retry.ts` | Injectable `Clock`/`TimersLike` sleeper. |
| NotificationScheduler | `src/scheduler/notification-scheduler.ts` | Injectable `Clock`. |
| PreferenceManager + quiet hours | `src/preferences/preference-manager.ts` | Injectable `Clock`. |
| Error codes registry | `enterprise/notification-channel.md` L51-L75 + upstream `error-codes.ts` + `errors/notification-errors.ts` | Reunified onto `defineCodes("NOTIFICATION_CHANNEL", [...])`; the upstream split between `error-codes.ts` (process-wide `ErrorCodes`) and `errors/notification-errors.ts` (`NotificationError` factory) is collapsed into one stable code registry per genesis convention. |
| Hooks pipeline | `src/hooks.ts` | Reimplemented as local `Hooks<Map>` — `@suaveplan/events` does not yet export `Hooks` on genesis. |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| OTel meters + `refreshNotificationMeters` | Enterprise audit §3 requires OTel; upstream has none — added per genesis convention. |
| Zod boundary schemas | Enterprise audit §3 requires Zod at every public type — upstream has none — added per genesis convention. |
| Injectable `FetchLike` in webhook/push | Genesis universal-tier deterministic-fetch convention — upstream uses raw `globalThis.fetch`. |
| `FakeFetch` test double | Replaces `vi.mock`/runtime-monkey-patching tests (no mocks rule). |
## Verification approach
Every requirement in the spec delta carries a `**Verified by:**` line naming concrete test titles.
- `src/error-codes.test.ts` — registry stability, code shape, side-effect import.
- `src/channels/<channel>.test.ts` — one per channel.
- `src/digest/digest-channel.test.ts` — digest queueing/flushing.
- `src/router/notification-router.test.ts` + `src/router/notification-router-routing.test.ts` — send/lifecycle/routing/rate-limit.
- `src/rate-limiter/channel-rate-limiter.test.ts`, `src/retry/delivery-retry.test.ts`, `src/scheduler/notification-scheduler.test.ts`, `src/templates/template-engine.test.ts`, `src/preferences/preference-manager.test.ts`, `src/hooks/hooks.test.ts`, `src/types/notification.types.test.ts`.
- `src/otel/metrics.test.ts` — instruments fire against real in-memory `MeterProvider`.
- `src/fakes/fake-fetch.test.ts` — fake-fetch double.
## Out of scope
- Real provider integrations (Twilio, SendGrid, Amazon SES, Firebase Cloud Messaging) — gated by `EmailChannel.sendFn`/`SmsChannel.sendFn`/`PushChannel.sendFn` callbacks. Provider adapters land in sibling packages (`@suaveplan/emailer`, `@suaveplan/sms-providers`) per the genesis enterprise plan.
- Persistence of notification history — the in-memory `Map<string, Notification>` is intentional; downstream consumers wire their own storage adapter through `EventBus.on("notification.sent", …)`.
### Excluded from this migration
None — the migration is at full functional parity with upstream `1.2.6` plus genesis enterprise enhancements (OTel, Zod boundaries, injectable fetch).
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/notification-channel` to enumerate. This is a brand-new package on genesis — no genesis consumers yet.
- `@suaveplan/api-plugin-notifications` — [x] no action needed (consumer will land in a follow-up change; no genesis code depends on this package today).
## Migration impact
None — additive only.

View File

@@ -0,0 +1,258 @@
<!-- 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)

View File

@@ -0,0 +1,72 @@
# Tasks — add @suaveplan/notification-channel
> Every checkbox MUST reflect work actually done in this change. No retroactive ticking.
## 1. Spec authoring
- [x] 1.1 Read `../suaveplan/enterprise/notification-channel.md` end-to-end
- [x] 1.2 Read upstream `../suaveplan/packages/universal/notification-channel/src/` end-to-end
- [x] 1.3 Read `openspec/AGENTS.md` §§ 0a, 2a, 6a, 7, 12a, 13, 14
- [x] 1.4 Draft `proposal.md`
- [x] 1.5 Fill in `## Source mapping`
- [x] 1.6 Draft `specs/notification-channel/spec.md`
- [x] 1.7 Every requirement carries a `**Verified by:**` line
## 2. Implementation
- [x] 2.1 `package.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`, `tests/setup/polyfills.ts`
- [x] 2.2 `src/error-codes.ts` — `defineCodes("NOTIFICATION_CHANNEL", [...])` + typed subclasses
- [x] 2.3 `src/types/notification.types.ts` — `Notification`, channels, status, transitions
- [x] 2.4 `src/schemas/schemas.ts` — Zod schemas at every public boundary
- [x] 2.5 `src/hooks/hooks.ts` — generic `Hooks<TMap>` pipeline (local — `@suaveplan/events` does not export `Hooks` on genesis)
- [x] 2.6 `src/channels/delivery-channel.ts` — `DeliveryChannel`/`DeliveryResult`
- [x] 2.7 `src/channels/email-channel.ts`
- [x] 2.8 `src/channels/sms-channel.ts`
- [x] 2.9 `src/channels/push-channel.ts` + `push-channel.types.ts` (injectable `FetchLike`)
- [x] 2.10 `src/channels/in-app-channel.ts`
- [x] 2.11 `src/channels/webhook-channel.ts` (injectable `FetchLike`)
- [x] 2.12 `src/digest/digest-channel.ts` (injectable `Clock`)
- [x] 2.13 `src/templates/template-engine.ts`
- [x] 2.14 `src/rate-limiter/channel-rate-limiter.ts` (injectable `Clock`)
- [x] 2.15 `src/retry/delivery-retry.ts` (injectable `Clock` + sleeper)
- [x] 2.16 `src/scheduler/notification-scheduler.ts` (injectable `Clock`)
- [x] 2.17 `src/preferences/preference-manager.ts` (injectable `Clock`)
- [x] 2.18 `src/router/notification-router.ts` (uses genesis `EventBus`, OTel, hooks)
- [x] 2.19 `src/otel/metrics.ts` — counters + histogram + `refreshNotificationMeters`
- [x] 2.20 `src/fakes/fake-fetch.ts` — `FetchLike` test double
- [x] 2.21 `src/index.ts` — explicit named re-exports; side-effect-imports `./error-codes.js`
## 3. Tests
- [x] 3.1 Unit tests co-located beside each module; cover every public method + every error code at least once
- [x] 3.2 OTel test using real in-memory `MeterProvider`
- [x] 3.3 Tests import only from `@suaveplan/testing/runner`
- [x] 3.4 Every `it(...)` carries `{ timeout: 5000 }` (30000 for async I/O)
## 4. Documentation
- [x] 4.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples)
- [x] 4.2 `README.md` at package root linking every co-located `.md` under `## Modules`
## 5. Pre-archive verification (the 5-gate triple)
- [x] 5.1 `bun run check` — Biome 0 errors / 0 warnings
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/notification-channel` — green
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/notification-channel` — 100/100/100/100
- [x] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/notification-channel` — exit 0
- [x] 5.5 `bunx turbo build --filter=@suaveplan/notification-channel` — green
## 6. Pre-archive 7-row checklist (AGENTS.md §13)
- [x] 6.1 Generics threaded; literal-typed `defineCodes` union preserved through public surface
- [x] 6.2 Zod schemas wired at every declared boundary
- [x] 6.3 Every error code in `NOTIFICATION_CHANNEL_CODES` thrown by ≥1 production path
- [x] 6.4 Strict-mode behaviors tested (rate-limit window expiry, dead-letter handler optional, quiet-hours wrap-around)
- [x] 6.5 OTel instruments emit + asserted with real in-memory `MeterProvider`
- [x] 6.6 Lifecycle behaviors tested (cancel after send rejected, scheduler `start`/`stop` idempotent)
- [x] 6.7 No retroactive ticks
## 7. Archive
- [x] 7.1 Fold `specs/notification-channel/spec.md` into `openspec/specs/notification-channel/spec.md`
- [x] 7.2 Move `openspec/changes/add-notification-channel/` → `openspec/archive/2026-05-19-add-notification-channel/`