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_METADATAare 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,codeis"SMS_INVALID_PHONE_NUMBER", andSmsError.isSmsError(value)istrue
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_LIMITEDis read afterregisterSmsErrorCodes()has run - THEN the entry has
httpStatus === 429,severity === "warning", andretry.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
SmsInvalidConfigErrorwhosecodeisSMS.INVALID_CONFIGand whosecontext.issuesis 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)andservice.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)returnsfalse
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 calledcapacity + 1times in a row without time advancing - THEN the first
capacitycalls returntrueand the last returnsfalse
Scenario: time advances refill tokens
- WHEN the bucket is exhausted and the injected clock is advanced by
1000mswithrefillPerSecond = 1 - THEN the next
tryConsume("k")returnstrue
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
maxAttemptsinvocations
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 + 100msand the injected timer fires - THEN the provider's
sendis invoked exactly once
Scenario: cancelled schedule does not fire
- WHEN
cancel()is called before the timer fires - THEN the provider's
sendis 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 thengetStatus("m1")is called - THEN the returned report deep-equals the ingested report
Scenario: LRU eviction drops the oldest entry
- WHEN
maxEntries = 2and three reportsm1,m2,m3are ingested in order - THEN
getStatus("m1")returnsundefined
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.senthas length 1 and the entry'stois"+15551234567"
Scenario: failOn triggers a thrown error
- WHEN
provider.failOn(msg => msg.to === "+15550000000", new Error("boom"))is set andsend({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_totaland one onsms.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 emitssms.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(codeSMS.ABORTED) and the provider'ssendis 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
statusfield is429
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:underpackages/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/"