Files
2026-05-27 23:29:53 +01:00

18 KiB

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