# 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` 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/"` ---