Files
suaveplan/openspec/archive/2026-05-25-enterprise-sms/specs/sms/spec.md
2026-05-27 23:29:53 +01:00

437 lines
18 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: sms (sms)
**Package**: `@suaveplan/sms`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: Public package surface
The package SHALL export, from its root barrel `src/index.ts`, the
full upstream surface: `SmsService`, the `SmsProvider` interface,
`SmsMessage`, `SmsDeliveryReport`, `SmsDeliveryStatus`,
`OptOutEntry`, `SmsTemplate`, `SmsEventMap`, the legacy `SmsError`
class with its factory methods, `SmsErrorCode`, `SmsErrorContext`,
`SmsErrorOptions`, `respondProblemJson`, `PROBLEM_JSON_CONTENT_TYPE`,
`registerSmsErrorCodes`, `SMS_ERROR_METADATA`, and
`SmsRegisteredErrorCode`. It SHALL additionally export the genesis
additions: `SMS_CODES`, every leaf error class, every Zod schema,
`TemplateRegistry`, `OptOutRegistry`, `RateLimiter`, `RetryPolicy`,
`MessageScheduler`, `DeliveryTracker`, `InMemorySmsProvider`,
`renderTemplate`, `isValidE164`, and the OTel `record*` helpers.
#### Scenario: every upstream symbol is re-exported
- **WHEN** a consumer imports from `@suaveplan/sms`
- **THEN** `SmsService`, `SmsProvider` (type), `SmsMessage` (type),
`SmsDeliveryReport` (type), `SmsDeliveryStatus` (type),
`OptOutEntry` (type), `SmsTemplate` (type), `SmsEventMap` (type),
`SmsError`, `respondProblemJson`, `PROBLEM_JSON_CONTENT_TYPE`,
`registerSmsErrorCodes`, `SMS_ERROR_METADATA` are all defined
**Verified by:** `src/service/sms-service.test.ts::"re-exports the full upstream surface"`
---
### Requirement: Stable SMS error-code registry
The package SHALL register exactly thirteen error codes via
`defineCodes("SMS", [...])`: `INVALID_NUMBER`, `INVALID_CONFIG`,
`PROVIDER_NOT_CONFIGURED`, `PROVIDER_ERROR`, `SEND_FAILED`,
`TEMPLATE_NOT_FOUND`, `RECIPIENT_OPTED_OUT`, `RATE_LIMITED`,
`FORBIDDEN`, `CONFIG_MISSING`, `SCHEDULE_CANCELLED`, `ABORTED`,
`RECEIPT_NOT_FOUND`. Every code SHALL be reachable from at least
one production call site and SHALL be asserted by a test that
triggers the real call site.
#### Scenario: every code is reachable from a production call site
- **WHEN** the package is fully built
- **THEN** for each of the thirteen codes there exists at least one
test that triggers the production call site whose `throw` (or
caught error) carries that code
**Verified by:** `src/error-codes/error-codes.test.ts::"every SMS code is reachable from a production call site"`
---
### Requirement: Legacy SmsError back-compat
The package SHALL preserve the upstream `SmsError` class with the
exact `SmsErrorCode` union (`SMS_INVALID_PHONE_NUMBER`,
`SMS_SEND_FAILED`, `SMS_TEMPLATE_NOT_FOUND`,
`SMS_RECIPIENT_OPTED_OUT`, `SMS_PROVIDER_NOT_CONFIGURED`) and the
five static factory methods (`invalidPhoneNumber`, `sendFailed`,
`templateNotFound`, `recipientOptedOut`, `providerNotConfigured`)
plus the `isSmsError` type guard. The legacy class SHALL also carry
the same `SmsErrorContext` and `SmsErrorOptions` shapes.
#### Scenario: legacy factories return SmsError instances
- **WHEN** `SmsError.invalidPhoneNumber("xx")` is called
- **THEN** the returned value is `instanceof SmsError`, `code` is
`"SMS_INVALID_PHONE_NUMBER"`, and `SmsError.isSmsError(value)` is
`true`
**Verified by:** `src/legacy-errors/legacy-errors.test.ts::"factory methods return the right code"` AND `src/legacy-errors/legacy-errors.test.ts::"isSmsError narrows the type"`
---
### Requirement: Legacy metadata registration
The package SHALL ship `SMS_ERROR_METADATA` containing the six
upstream metadata entries (`SMS_SEND_FAILED`, `SMS_PROVIDER_ERROR`,
`SMS_INVALID_NUMBER`, `SMS_RATE_LIMITED`, `SMS_CONFIG_MISSING`,
`SMS_FORBIDDEN`) with the same `httpStatus` / `severity` / `retry` /
`userMessageKey` / `category` / `description` fields, and SHALL
register every entry on the shared `ErrorCodes` singleton via
`registerSmsErrorCodes()` at module load time. The function SHALL
be idempotent.
#### Scenario: SMS_RATE_LIMITED maps to HTTP 429
- **WHEN** `SMS_ERROR_METADATA.SMS_RATE_LIMITED` is read after
`registerSmsErrorCodes()` has run
- **THEN** the entry has `httpStatus === 429`, `severity ===
"warning"`, and `retry.retryable === true`
**Verified by:** `src/legacy-metadata/legacy-metadata.test.ts::"every metadata entry preserves the upstream shape"`
---
### Requirement: E.164 phone validation
`isValidE164(value)` SHALL return `true` only when `value` matches
`/^\+[1-9]\d{1,14}$/`. The service SHALL reject every `send()` call
whose `message.to` fails this check by throwing
`SmsInvalidNumberError` (code `SMS.INVALID_NUMBER`).
#### Scenario: valid +15551234567 passes
- **WHEN** `isValidE164("+15551234567")` is called
- **THEN** the return value is `true`
#### Scenario: missing leading + is rejected
- **WHEN** `isValidE164("15551234567")` is called
- **THEN** the return value is `false`
#### Scenario: leading +0 is rejected
- **WHEN** `isValidE164("+0123456789")` is called
- **THEN** the return value is `false`
**Verified by:** `src/validation/phone.test.ts::"accepts E.164 numbers"` AND `src/validation/phone.test.ts::"rejects non-E.164 inputs"`
---
### Requirement: Zod boundary on public inputs
`schemas/schemas.ts` SHALL export Zod schemas for `SmsMessage`,
`SmsTemplate`, `SmsDeliveryReport`, and `SmsServiceOptions`. The
parser helpers `parseSmsMessage` and `parseSmsTemplate` SHALL throw
`SmsInvalidConfigError` (code `SMS.INVALID_CONFIG`) with
`context.issues` containing the Zod issues on rejection.
#### Scenario: empty body is rejected
- **WHEN** `parseSmsMessage({ to: "+15551234567", body: "" })` is
called
- **THEN** the call throws `SmsInvalidConfigError` whose `code` is
`SMS.INVALID_CONFIG` and whose `context.issues` is a non-empty
array
**Verified by:** `src/schemas/schemas.test.ts::"rejects empty body"` AND `src/schemas/schemas.test.ts::"rejects non-E.164 to"`
---
### Requirement: Template registry and render
`TemplateRegistry` SHALL provide `register(template)`,
`get(id)`, `has(id)`, `unregister(id)`, and `list()`. `renderTemplate(template, vars, defaults?)` SHALL replace every `{{var}}` placeholder declared in `template.variables` with `vars[var] ?? defaults?.[var] ?? ""`.
#### Scenario: rendering substitutes declared variables
- **WHEN** `renderTemplate({ id: "x", body: "Hello {{name}}", variables: ["name"] }, { name: "Alice" })` is called
- **THEN** the returned string is `"Hello Alice"`
#### Scenario: missing variables default to empty string
- **WHEN** `renderTemplate({ id: "x", body: "Hi {{name}}", variables: ["name"] }, {})` is called
- **THEN** the returned string is `"Hi "`
#### Scenario: defaults map fills missing variables
- **WHEN** `renderTemplate({ id: "x", body: "Hi {{name}}", variables: ["name"] }, {}, { name: "World" })` is called
- **THEN** the returned string is `"Hi World"`
**Verified by:** `src/templates/templates.test.ts::"substitutes declared variables"` AND `src/templates/templates.test.ts::"missing variables fall back to defaults then empty string"` AND `src/templates/templates.test.ts::"registry register/get/has/unregister/list"`
---
### Requirement: Opt-out registry
`OptOutRegistry` SHALL provide `add(phoneNumber, channel?)`,
`remove(phoneNumber)`, `has(phoneNumber)`, `list()`,
`importEntries(entries)`, and `clear()`. `SmsService` SHALL reject
every `send()` to an opted-out recipient with
`SmsRecipientOptedOutError` (code `SMS.RECIPIENT_OPTED_OUT`).
#### Scenario: opt-out blocks send
- **WHEN** a recipient is added via `optOut(phone)` and `service.send({to: phone, ...})` is called
- **THEN** the send throws `SmsRecipientOptedOutError`
#### Scenario: opt-in clears the registry entry
- **WHEN** `optIn(phone)` is called for an opted-out recipient
- **THEN** `isOptedOut(phone)` returns `false`
**Verified by:** `src/opt-out/opt-out.test.ts::"add/remove/has/list/clear"` AND `src/service/sms-service.test.ts::"opt-out blocks send"` AND `src/service/sms-service.test.ts::"opt-in unblocks send"`
---
### Requirement: Rate limiter
`RateLimiter` SHALL implement a token bucket with `capacity` and
`refillPerSecond`, keyed by an arbitrary string key. `tryConsume(key)` SHALL return `true` when a token is available and `false` otherwise, refilling tokens against an injectable `Clock`. `SmsService` SHALL throw `SmsRateLimitedError` (code `SMS.RATE_LIMITED`) when the limiter rejects.
#### Scenario: bucket exhausts after capacity tokens
- **WHEN** `tryConsume("k")` is called `capacity + 1` times in a row without time advancing
- **THEN** the first `capacity` calls return `true` and the last returns `false`
#### Scenario: time advances refill tokens
- **WHEN** the bucket is exhausted and the injected clock is advanced by `1000ms` with `refillPerSecond = 1`
- **THEN** the next `tryConsume("k")` returns `true`
**Verified by:** `src/rate-limiter/rate-limiter.test.ts::"exhausts after capacity"` AND `src/rate-limiter/rate-limiter.test.ts::"refills on clock advance"` AND `src/service/sms-service.test.ts::"rate-limited recipient throws SmsRateLimitedError"`
---
### Requirement: Retry policy
`RetryPolicy` SHALL retry a thrown attempt up to
`maxAttempts` times with exponential backoff
(`initialDelayMs * 2^(attempt - 1)`, capped at `maxDelayMs`) and
full jitter. Each retry SHALL be awaited against an injectable
`Timers.delay(ms, signal)`. An aborted retry SHALL throw
`SmsAbortedError` (code `SMS.ABORTED`).
#### Scenario: succeeds on second attempt
- **WHEN** a callback fails once then succeeds
- **THEN** the policy returns the success value and emits one retry event
#### Scenario: exhausts attempts and rethrows
- **WHEN** a callback fails on every attempt
- **THEN** the policy rethrows the last error after `maxAttempts` invocations
#### Scenario: abort signal halts retries
- **WHEN** the signal aborts between attempts
- **THEN** the policy throws `SmsAbortedError`
**Verified by:** `src/retry/retry.test.ts::"succeeds on second attempt"` AND `src/retry/retry.test.ts::"exhausts attempts"` AND `src/retry/retry.test.ts::"abort halts retries"`
---
### Requirement: Message scheduler
`MessageScheduler.scheduleAt(date, fn)` SHALL invoke `fn` at the
target time through an injectable `Timers.setTimeout`-like seam and
return a `ScheduleHandle` with `cancel()`. A cancelled handle SHALL
not invoke `fn`. `SmsService.scheduleAt(date, message)` SHALL emit
`sms.scheduled` on the bus when the message is queued, and route
the eventual send through the full pipeline. A cancelled schedule
SHALL emit `sms.failed` with code `SMS.SCHEDULE_CANCELLED`.
#### Scenario: scheduled message fires at the target time
- **WHEN** a message is scheduled for `now + 100ms` and the injected timer fires
- **THEN** the provider's `send` is invoked exactly once
#### Scenario: cancelled schedule does not fire
- **WHEN** `cancel()` is called before the timer fires
- **THEN** the provider's `send` is not invoked
**Verified by:** `src/scheduler/scheduler.test.ts::"fires at the target time"` AND `src/scheduler/scheduler.test.ts::"cancel prevents firing"` AND `src/service/sms-service.test.ts::"scheduleAt routes through the pipeline"`
---
### Requirement: Delivery tracker
`DeliveryTracker` SHALL accept incoming delivery receipts via
`ingest(report)` and return the latest cached status via
`getStatus(messageId)`. The cache SHALL be bounded by an LRU policy
with configurable `maxEntries` (default `1024`). `lookupOrFetch(messageId, provider)` SHALL fall back to the provider's `getStatus`
when no cached entry exists, caching the result. Cache miss with no
provider SHALL throw `SmsReceiptNotFoundError` (code
`SMS.RECEIPT_NOT_FOUND`).
#### Scenario: ingested receipt is retrievable
- **WHEN** a report with `messageId: "m1"` is ingested then
`getStatus("m1")` is called
- **THEN** the returned report deep-equals the ingested report
#### Scenario: LRU eviction drops the oldest entry
- **WHEN** `maxEntries = 2` and three reports `m1`, `m2`, `m3` are
ingested in order
- **THEN** `getStatus("m1")` returns `undefined`
**Verified by:** `src/delivery-tracker/delivery-tracker.test.ts::"ingest/getStatus round-trip"` AND `src/delivery-tracker/delivery-tracker.test.ts::"LRU eviction"` AND `src/delivery-tracker/delivery-tracker.test.ts::"lookupOrFetch falls back to provider"` AND `src/delivery-tracker/delivery-tracker.test.ts::"miss without provider throws receipt-not-found"`
---
### Requirement: In-memory provider
`InMemorySmsProvider` SHALL implement `SmsProvider` with `name` =
`"in-memory"`, deliver every message synchronously into a `sent`
log, and return a `SmsDeliveryReport` with `status` controlled by
the constructor's `defaultStatus` (default `"sent"`). It SHALL
expose `sent`, `clear()`, `setStatus(messageId, status)`, and
`failOn(predicate, error)` so tests can simulate provider failures
and status transitions without external services.
#### Scenario: send appends to the sent log
- **WHEN** `provider.send({ to: "+15551234567", body: "x" })` is
called
- **THEN** `provider.sent` has length 1 and the entry's `to` is
`"+15551234567"`
#### Scenario: failOn triggers a thrown error
- **WHEN** `provider.failOn(msg => msg.to === "+15550000000", new Error("boom"))` is set and `send({to: "+15550000000", body: "x"})` is called
- **THEN** the call rejects with the configured error
**Verified by:** `src/in-memory-provider/in-memory-provider.test.ts::"send appends to log"` AND `src/in-memory-provider/in-memory-provider.test.ts::"failOn triggers"` AND `src/in-memory-provider/in-memory-provider.test.ts::"setStatus transitions delivery state"`
---
### Requirement: OpenTelemetry instruments
The package SHALL register four OTel instruments on the
`@suaveplan/sms` meter: counters `sms.sent_total`,
`sms.failed_total`, `sms.retry_total`, and histogram
`sms.send_duration_ms`. The module SHALL expose `record*` helpers
and a `refreshSmsMeters()` re-bind for tests.
#### Scenario: successful send records sent_total + send_duration_ms
- **WHEN** `service.send(validMessage)` is called against an
in-memory provider
- **THEN** an in-memory metric exporter reads at least one data
point on `sms.sent_total` and one on `sms.send_duration_ms`
**Verified by:** `src/otel/metrics.test.ts::"refresh re-binds against the global provider"` AND `src/service/sms-service.test.ts::"send records sent_total and send_duration_ms"`
---
### Requirement: Typed EventBus + legacy on/off mirror
`SmsService` SHALL expose a typed `EventBus<SmsEventMap>` field
named `bus`, AND a legacy `on/off/emit` surface that mirrors against
the bus. Topics: `sms.sent`, `sms.delivered`, `sms.failed`,
`sms.opted_out`, `sms.opted_in`, `sms.scheduled`, `sms.retry`,
`sms.rate_limited`.
#### Scenario: legacy on/off mirrors the bus
- **WHEN** a listener is registered via `service.on("sms.sent", h)`
and the service emits `sms.sent`
- **THEN** the handler is invoked exactly once with the report
#### Scenario: bus.on receives the same payload
- **WHEN** `service.bus.on("sms.sent", h)` is registered before a
send
- **THEN** the bus handler is invoked with the same report payload
**Verified by:** `src/service/sms-service.test.ts::"legacy on/off mirrors the bus"` AND `src/service/sms-service.test.ts::"bus receives emitted reports"`
---
### Requirement: Abort signal propagation
`SmsService.send(message, { signal })` SHALL propagate the abort
signal through the rate limiter, retry policy, and provider. An
abort BEFORE the provider call SHALL throw `SmsAbortedError`. An
abort that lands inside a retry delay SHALL throw `SmsAbortedError`
without invoking the next attempt.
#### Scenario: pre-aborted signal halts send immediately
- **WHEN** `service.send(msg, { signal: AbortSignal.abort() })` is
called
- **THEN** the call throws `SmsAbortedError` (code `SMS.ABORTED`)
and the provider's `send` is not invoked
**Verified by:** `src/service/sms-service.test.ts::"pre-aborted signal halts send"` AND `src/retry/retry.test.ts::"abort halts retries"`
---
### Requirement: Hono problem+json helper
`respondProblemJson(c, err)` SHALL serialise any `BaseError` /
`HttpError` / `SuaveplanError` / unknown throwable into an RFC 7807
`application/problem+json` response on the Hono `Context`-like
surface. The helper SHALL preserve the `httpStatus` registered by
`SMS_ERROR_METADATA` when the error's code is registered in the
shared `ErrorCodes` singleton, and SHALL fall back to `500` for
unknown errors.
#### Scenario: registered SMS code preserves its httpStatus
- **WHEN** `respondProblemJson(c, new SmsError({code: "SMS_RATE_LIMITED", message: "x"}))` is called
- **THEN** the response body's `status` field is `429`
**Verified by:** `src/routes/problem-json.test.ts::"registered code preserves httpStatus"` AND `src/routes/problem-json.test.ts::"unknown error falls back to 500"`
---
### Requirement: Subpath exports per feature
The `package.json` `exports` map SHALL expose a tree-shakeable
subpath for every feature module: `./error-codes`,
`./legacy-errors`, `./legacy-metadata`, `./schemas`,
`./validation/phone`, `./templates`, `./opt-out`,
`./rate-limiter`, `./retry`, `./scheduler`,
`./delivery-tracker`, `./in-memory-provider`, `./service`,
`./routes/problem-json`, `./otel`.
#### Scenario: feature subpaths resolve
- **WHEN** the package is fully built
- **THEN** `Object.keys(pkg.exports).filter(k => k.startsWith("./")).length >= 15`
**Verified by:** `src/service/sms-service.test.ts::"feature subpaths are declared in package.json"`
---
### Requirement: Universal-tier purity
The package SHALL not import any Node-only or Bun-only API
(`node:*`, `bun:*`). It SHALL rely only on `globalThis.setTimeout`
/ `clearTimeout` (universally available) via an injectable
`Timers` seam.
#### Scenario: no node: / bun: imports in src/
- **WHEN** the repository is grepped for `from ['\"]node:|from ['\"]bun:`
under `packages/communication/messaging/sms/src/`
- **THEN** the grep yields zero matches
**Verified by:** `src/service/sms-service.test.ts::"no node: or bun: imports leak into src/"`
---