Skip to main content

Channel Router Service — Domain Model

Version: 1.0 Status: Draft Owner: Messaging Core Last Updated: 2026-04-21 Companion: SERVICE_OVERVIEW · APPLICATION_LOGIC · DATA_MODEL · EVENT_SCHEMAS · API_CONTRACTS Related: ADR-0004 §3, docs/07-epics-and-user-stories.md §6.7, EP-CHAN-01..04


1. Bounded Context

Omnichannel Messaging — channel selection, fallback, OTT adapters, MO routing, conversation sessions. The channel-router owns every decision about which channel a notification takes, in what order a fallback ladder progresses, and how an inbound MO returns to the correct tenant and conversation thread. It is the sole authority for ChannelAdapter dispatch and for Conversation lifecycle.

Context boundary:

  • Inside the boundary: RecipientProfile, FallbackPolicy, FallbackExecution, Conversation, MoRouting, ChannelAdapterConfig, DeliveryAttempt, OutcomeEnvelope, TenantInboundRoute, StatusMap, per-channel circuit-breakers, per-OTT token buckets.
  • Outside the boundary: tenant / account identity (auth-service), per-channel consent (consent-ledger-service), content-policy verdicts (compliance-engine), sender-ID authorisation (sender-id-registry-service), SMS transport (smpp-connector), MNO selection (routing-engine), DLR correlation (dlr-processor), per-attempt billing aggregation (billing-service), tenant notification UI (notification-service).

Channel-router is a hot-path data-plane service: both RouteWithFallback (P95 ≤ 50 ms decision) and inbound MO routing (chan-mo-router, P95 ≤ 1 s end-to-end) share the same aggregates and PG/Redis infra but run as separate Deployments for blast-radius isolation.


2. Aggregates

2.1 RecipientProfile

The learned per-recipient capability map. One row per (tenantId, msisdnHash). Written by profile-update consumers (delivery outcome feedback, OTT capability discovery) and read on every RouteWithFallback call.

FieldTypeNotes
profileIdprofile_…External ID
tenantIdUUIDv4Scope
msisdnHashsha256(msisdn ‖ tenantSalt)Never raw MSISDN in profile store
channelPreferencesChannelPreference[] VOPer-channel score (0–100), last-observed, confidence
hasWhatsappBusinessTriStateKNOWN_YES / KNOWN_NO / UNKNOWN
telegramChatIdopaque stringPopulated only when recipient linked a bot
viberIdopaque stringPopulated only when linked
voiceOtpSupportedTriStateInferred from prior ANSWERED / NO_ANSWER voice attempts
emailVerifiedboolOpt-in, double-verified
lastSuccessfulChannelChannel | nullUsed to bias ladder order
lastObservedAtInstantLatest signal across any channel
discoveryStateUNSEEN | LEARNING | STABLEGating for ML-assisted ordering
updatedAtInstantLWW tie-break key

Invariants

  • MSISDN never stored in plaintext at this aggregate; the raw number lives only transiently in the dispatch request and in the delivery_attempts row until lastDispatchAt + 90d.
  • Per-channel preference score is bounded [0, 100]; decays exponentially at half-life 30 d.
  • A profile row MUST NOT be created for an unknown recipient — the first RouteWithFallback uses a synthetic in-memory default; the row is written only on first successful attempt.

2.2 FallbackPolicy

The tenant-scoped ladder definition. One row per (tenantId, useCase) where useCase ∈ {otp, txn, marketing, alert, conversational}.

FieldTypeNotes
policyIdUUIDv4Identity
tenantId, useCasecompositeLookup key
ladderLadderStep[] — ordered, length 1..6Each step: { channel, deadlineSeconds, retryBudget, escalateOn[], costCapNgn? }
strategySEQUENTIAL | PARALLEL | FAILOVERSEQUENTIAL is the default (wait-then-fallback); PARALLEL sends on N channels simultaneously (OTT only, fraud-review-gated); FAILOVER is single-channel primary with hot-backup adapter
costCapPerMessageNUMERICHard per-message upper bound across all attempts; breach short-circuits with REFUSED_COST_CAP
sessionTtlSecondsintOverrides the platform default 24 h for this tenant+useCase
stopKeywordsOverridestring[]Appends to default set
versionintMonotonic; bumped on every mutation
createdBy, updatedBy, createdAt, updatedAtaudit

Invariants

  • Ladder length ≤ 6 steps; enforced at write.
  • No step may repeat the same channel within the same ladder (prevents double-charge loops).
  • strategy = PARALLEL permitted only if all steps are OTT (not SMS, not Voice) — enforced on write.
  • costCapPerMessage must be ≥ sum of cheapest path through the ladder; otherwise write rejected with POLICY_UNREACHABLE_COST_CAP.
  • Default ladder when no row exists (per use-case):
    • otp: [sms(60s), whatsapp(30s), voice(45s)]
    • txn: [sms(60s), whatsapp(30s)]
    • marketing: [sms(120s)]
    • alert: [sms(30s), voice(30s)]

2.3 FallbackExecution

A per-message append-only trace of the ladder evaluation. Written once per RouteWithFallback.

FieldNotes
executionIdUUIDv4 (exec_…)
notificationId, recipientId, tenantId, useCaseScope
policyId + policyVersionSnapshot of which policy governed this execution
ladderSnapshotJSONB of the resolved ladder at decision time (consent-filtered, circuit-breaker-filtered)
attemptsDeliveryAttempt[] — appended as ladder progresses
outcomeDELIVERED | FAILED | REFUSED_NO_CHANNEL | REFUSED_COST_CAP | REFUSED_CONSENT
finalChannelChannel | null
totalCostNgnNUMERIC
fallbackPathFallbackPathEntry[] — human-readable explainer
startedAt, terminatedAtInstants

Invariants — append-only at DB level (Postgres rule); terminal outcome is emitted exactly once (outbox table UNIQUE(notificationId, recipientId)).

2.4 Conversation

The sticky correlation across one (senderId, msisdnHash, tenantId) triple for two-way messaging.

FieldNotes
conversationIdconv_… (ULID-based; time-sortable)
tenantId, senderId, msisdnHashIdentity triple
statusOPEN | CLOSED_STOP | CLOSED_IDLE | CLOSED_MANUAL
openedAt, lastSeenAt, expiresAt, closedAtInstants
turnCountint — monotonic; incremented on every MT or MO
lastMtMessageId, lastMoMessageIdBack-pointers for debug
channelWhich channel initiated the session (SMS for the default flow; OTT otherwise)

Invariants

  • turnCount is strictly monotonic; a race that would violate monotonicity is resolved via Redis WATCH/MULTI.
  • Closure is terminal — a STOP keyword during CLOSED_* state creates a fresh conversation only after consent is re-granted.
  • TTL sliding on every MO/MT success; Redis key TTL and Postgres expiresAt must be within 30 s of each other (reconciliation job).

2.5 MoRouting

The inbound-number → tenant static mapping. Used only on session miss.

FieldNotes
routeIdUUIDv4
tenantIdOwner
inboundShortcode or long-code E.164; platform-unique
webhookUrlHTTPS URL
secretRefVault reference to HMAC secret; never exposed
gracePeriodEndsAtInstant | null — 24 h soft-delete window
activebool

Invariants — number uniqueness enforced by unique index; cross-check with numbering-service.GetLease(tenantId, inbound) on write.

2.6 ChannelAdapterConfig

Per-provider / per-tenant credentials + adapter-level behaviour.

FieldNotes
adapterConfigIdadcfg_…
tenantIdOwner (platform-wide adapters are tenantId = null)
providerWHATSAPP_CLOUD | TELEGRAM_BOT | VIBER | VOICE_OTP_GATEWAY | SMTP | SMPP_CONNECTOR
phoneNumberIdOrBotHandleProvider-specific routing key
secretRefVault path (secrets/data/chan/ott/{tenantId}/{provider})
circuitStateCLOSED | OPEN | HALF_OPEN — adapter circuit-breaker
rateLimitPerSecond, rateLimitPerDayToken-bucket sizing (per-provider defaults; see §5)
regionalEgressIpPoolFor allow-listed providers (WhatsApp Cloud)

2.7 DeliveryAttempt

Single-step dispatch record (child of FallbackExecution).

FieldNotes
attemptIdattempt_…
executionId, stepIndexParent FK
channel, adapterConfigIdUsed
providerMessageIde.g. wamid.HBgL... for WhatsApp, SMSC messageId for SMS
statusaccepted | sent | delivered | delivered_read | failed_temp | failed_perm | rejected_by_provider | rejected_by_recipient | step_skipped
reasonCanonical reason string (mapped by chan.adapter_status_map)
costNgnNUMERIC
durationMsFrom dispatch to terminal status
startedAt, terminatedAtInstants
rawProviderPayloadJSONB — retained 30 d for debug, redacted for PII

3. Value Objects

VOShapeInvariants
Channelenum SMS | WHATSAPP | TELEGRAM | VIBER | VOICE | EMAILAdditive only; unknown → UNKNOWN
ChannelCapability{ channel, supported: TriState, confidence: [0..1], lastObservedAt }confidence = 1 for direct API probes, ≤ 0.8 for inferred
FallbackStrategySEQUENTIAL | PARALLEL | FAILOVERSee §2.2 constraint on PARALLEL
DeliveryConfidenceDEFINITIVE | PROBABLE | AMBIGUOUSDEFINITIVE = provider ACK; PROBABLE = sent only; AMBIGUOUS = timeout
ConversationIdULID-based conv_…Time-sortable
TurnNumberint ≥ 0Monotonic per conversation
FallbackPathEntry{ channel, status, reason, durationMs, costNgn }Redacted — no raw body
MessageContextRef{ notificationId, recipientId, tenantId, useCase }No PII
OutcomeEnvelope{ final, channel, attempts, fallback_path[], occurredAt, executionId }Emitted exactly once per (notificationId, recipientId)
ReasonCodecanonical stringSee docs/standards/ERROR_CODES.md (CHAN_*)

4. Domain Events (produced)

Full schemas in EVENT_SCHEMAS.md.

EventTrigger
channel.delivery.attempted.v1An adapter dispatch() call starts
channel.delivery.confirmed.v1Terminal positive provider status (DELIVERED or DELIVERED_READ)
channel.delivery.failed.v1Terminal negative status
channel.fallback.taken.v1Ladder progression (step N → step N+1)
channel.mo.inbound.v1Inbound MO routed — also re-published to sms.mo.inbound for consent-ledger + sms-firewall consumers
channel.conversation.started.v1First MT on a new (senderId, msisdn, tenantId) key
channel.conversation.ended.v1Any terminal state on a Conversation
channel.recipient.profile.updated.v1Profile LWW merge from delivery feedback or capability probe
channel.billing.event.v1Per-attempt metering feed to billing-service
notification.delivery.outcome.v1Single canonical outcome per (notificationId, recipientId) — consumed platform-wide
channel.tenant_policy.changed.v1FallbackPolicy CRUD
channel.inbound_route.changed.v1MoRouting CRUD

Events consumed

SubjectProducerPurpose
notification.dispatch.requested.v1sms-orchestratorTrigger RouteWithFallback on durable consumer
mo.allowed.v1sms-firewall-servicePre-filtered MO for tenant-webhook routing
sms.mo.received.v1smpp-connector (MO)Ingress from MNO SMSC (when firewall bypass in effect)
sms.dlr.inbounddlr-processorCarrier DLR → terminates SMS step of ladder
consent.revoked.v1consent-ledger-servicePer-channel consent invalidation; update recipient profile
sender.id.suspended.v1sender-id-registry-serviceStop dispatches from that sender
WhatsApp Cloud API webhookMeta Graph APIProvider status (sent/delivered/read/failed)
Telegram Bot API webhookTelegramInbound updates + message confirmations
Viber webhookViberMessage + event updates

5. Per-Provider Rate-Limit Baselines

Sourced from published vendor docs; reflected in ChannelAdapterConfig defaults. These are starting thresholds — production tuning per account tier.

ProviderPer-secondPer-dayNotes
WhatsApp Cloud API80 msg/s per phone-number-id (Tier-1 default)1K–100K unique recipients depending on quality ratingMeta raises tier on low block rate; see developers.facebook.com/docs/whatsapp/cloud-api/overview#rate-limits
Telegram Bot API30 msg/s per bot, 1 msg/s per chat~soft-capped by Telegramcore.telegram.org/bots/faq#broadcasting-to-users
Viber Business40 msg/s per PA (default)Negotiated per PAdevelopers.viber.com/docs/api/rest-bot-api/#rate-limiting
Voice OTP20 call setups/s per outbound trunkTrunk CPS contract3GPP TS 22.171 voice-assisted auth pattern
SMTP egress50 msg/s per IP reputation tierPer-ISP dependentRFC 5321
SMS (via smpp-connector)Governed by routing-engine / bind TPS

6. Global Invariants

  1. One canonical outcome per recipient. Exactly one notification.delivery.outcome.v1 per (notificationId, recipientId); enforced via chan.delivery_outbox UNIQUE(notificationId, recipientId).
  2. Consent-aware ladders. The ladder is consent-filtered before the first dispatch (consent-ledger-service.CheckConsent(channels[])); a channel missing consent is excluded with reason recipient_opt_out and audit-trailed in fallback_path.
  3. Cost-capped fallbacks. FallbackPolicy.costCapPerMessage is evaluated at each step transition; breach short-circuits with REFUSED_COST_CAP and a single outcome event.
  4. Idempotent dispatch. Per-adapter dispatch must honour Idempotency-Key = {notificationId}:{recipientId}:{stepIndex}; duplicate provider calls are prevented by the adapter.
  5. Session authority. For inbound MO, session lookup precedes static routing; session key scope is per senderId so one MSISDN may simultaneously hold sessions with multiple tenants.
  6. No silent channel drop. Any channel excluded from the live ladder (compliance, consent, circuit-breaker, unavailable adapter, no-link) contributes an entry to fallback_path[] with a canonical reason.
  7. Profile writes are LWW. Two concurrent profile updates for the same (tenantId, msisdnHash) resolve by updatedAt monotonic timestamp; causally-related updates are batched via the profile-update worker.
  8. Append-only executions + audit. fallback_executions and channel.audit reject UPDATE/DELETE at the Postgres schema level; retention is by partition drop.
  9. PII hygiene. MSISDN never appears in events, logs, or profile storage; only sha256(msisdn ‖ tenantSalt). Message bodies are emitted only to providers and to the tenant webhook — never to analytics.
  10. Fail-closed on consent lookup. If consent-ledger-service is unreachable on the hot path beyond the retry budget, the RouteWithFallback call returns REFUSED_CONSENT_UNKNOWN and no dispatch occurs.
  11. Fail-degraded on adapter failure. An OTT adapter breaker in OPEN state causes ladder-step skip, not call refusal.

7. Cross-Service References

  • sms-orchestrator/SERVICE_OVERVIEW.md — upstream producer of notification.dispatch.requested.v1
  • consent-ledger-service/DOMAIN_MODEL.md — per-channel scope semantics (MARKETING / TRANSACTIONAL / OTP / EMERGENCY)
  • sender-id-registry-service/API_CONTRACTS.mdVerifySender(tenantId, senderId) consumed at dispatch time
  • sms-firewall-service/EVENT_SCHEMAS.mdmo.allowed.v1 producer contract
  • webhook-dispatcher/SERVICE_OVERVIEW.md — downstream HMAC v2 signing implementation
  • billing-service/EVENT_SCHEMAS.md — consumer of channel.billing.event.v1
  • compliance-engine/API_CONTRACTS.mdEvaluateChannelCompliance per-channel verdict
  • fraud-intel-service/EVENT_SCHEMAS.mdfraud.detected.* signals gating PARALLEL strategy
  • ADR-0004 §3 (new bounded contexts; channel-router's position in the national backbone)