Unified termination routing
Asubmit_sm from your SMPP bind (or any REST/campaign send) is a message, not necessarily an SMS. Orbit’s unified termination engine decides which channel actually delivers it — the Devotel softswitch’s SMS leg, a WhatsApp template, or any of the other messaging channels — while still billing the send exactly once. The unit of policy is a termination rule; the executor is the terminator; and the write-it-down decision is a single evaluate → claim → deliver flow that runs inside the send preflight.
Everything on this page is tenant-owned configuration. Rules, receipts windows, and per-hop policy live in your tenant schema and are evaluated against your traffic only.
1. Why unified termination — one billed egress
Without a termination layer, an absorbed submit (e.g. SMPP in, WhatsApp out) is billed once by the SMPP preflight and again by the channel send — and whichever downstream provider you pick can’t be trusted to idempotency-dedupe the second charge. The terminator’s claim collapses the second send, just as the charge key collapses the second charge:- The preflight prices and debits
charge:msg:<messageId>before routing decisions run. - The intent envelope carries
messageIdandchargeKeyverbatim; nothing downstream re-mints them. - The terminator executes a conditional
UPDATE, claims the intent, and calls the channel’s send once, passing the pre-existingmessageIdso the second deduct lands on the already-claimed key and silently resolves to a no-op.
2. The terminator in the routing preflight
The terminator is the one code path where a routed message is actually sent. Two guards keep it that way, and both are structural, not discipline:- The decision package (
@devotel/message-routing) depends on@devotel/sharedandzodonly, so it cannot import the messaging router and can therefore never reachrouter.send. This converts the historical unbilled-send class from a review item into a compile error. - A test asserts exactly one
sendMessage(call under the termination module, so any new egress site fails CI.
TerminationPlan is a decision, not a send. The plan names steps (deliver/absorb), the terminator projects it onto whichever channel the plan chose and performs exactly one egress. The split is the same pattern your fallback planes use: fallback decides the next channel; the send pipeline owns billing, consent, and delivery.
3. The rules model: deliver vs absorb hops
A rule either delivers the message onto a channel, or absorbs the message (Orbit takes ownership so the ingress does not deliver). The two shapes are deliberately the only actions the engine can express:
deliverpicks a target channel, whose-account-it-goes-on (prefer_tenant/tenant_only/platform), and per-hop knobs (template_*,max_price_cents,receipt_timeout_seconds,inbox: 'thread' | 'none').absorbis terminal: Orbit has taken ownership, so the original ingress (usually your SMPP bind) must not also deliver. GettingabsorbIngressright in either direction is the difference between a double-send and a silent drop.
deliver step is itself an absorb for the ingress: if the engine is delivering on a channel, the original ingress does not deliver too. The first enabled rule whose match conditions are all true (AND semantics) wins — there is no nesting, no boolean tree, no OR (an “or” is expressed as two rules with their own priorities).
Rules select a channel, never a provider, connector or trunk. Invariant #45 holds by construction: the bootstrap registers every messaging channel with exactly the Devotel wholesale softswitch; the engine’s action vocabulary is a channel, so a tenant rule cannot name a provider. A rule that moves a message off the ingress must also satisfy its own require_dlr_mode (webhook/both) because the ingress-native receipt would otherwise black-hole.
4. Shadow → enforce lifecycle
Every rule starts inmode: "shadow". Shadow is the safety story for letting tenants author rules that spend: the rule is evaluated, the would-be chosen channel is recorded on the intent row, and the submit proceeds on the normal path exactly as if the rule didn’t exist.
- Shadow — the decide outputs a
demotedintent; nothing moves money; theattempted_channelsarray records the hops the rule would have tried. - Enforce — the terminator claims the intent, dispatches the first deliver hop, and records the outcome (
delivered_to_provider,failed, ordemoted).
termination_intents.attempted_channels and reason on the intent row — that is the evidence a would-have-fired rule leaves behind. Flip to enforce once the shadow trace matches your expectation (e.g. the hop choice, the recipient set, the price ceiling). The transition is a PATCH on the same rule endpoint; nothing needs to be re-created.
5. Rules evaluation order + canonical failure modes
Rules are evaluated in ascendingpriority; ties are broken by created-at. First enabled match wins. A rule’s failure_count is stamped by the terminator when its hop fails, so the health/pause badge layers on with no new DDL.
Canonical failure modes, in the order you will see them:
- Passthrough — no rule matched, or every rule errored. The engine degrades to “no rules,” which is today’s behaviour. This is observable: the in-process evaluation-error counter is incremented and a Datadog metric stamped, and the fallback is logged; a malformed rule degrades to “skipped,” not to a thrown preflight.
- Template-parameter unbound — a rule references
{{body}}or{{match.N}}and the regex/substitution can’t satisfy it. The engine returns passthrough rather than absorbing into a blank template send that the provider rejects after the ingress was ACKed. - Receipt black hole — a rule whose ingress was SMPP in
bindmode has a declaredrequire_dlr_mode(webhook/both). Rule-write rejects the offending combo at write time, not at 3am. - Claim loss — the terminator only sends when a conditional UPDATE flips
dispatch_statefrompendingtodispatchingand a re-read confirms it; zero rows returned means a different worker owns the intent and no send happens. - Provider short-circuit bought but not delivered — the terminator’s recursion guard (
skipTerminationflag on its send call) is what preventsdecide → terminate → sendMessage → decide → …from recursing.
6. How LCR dry-run quotes and fallback planes plug in
Termination rules sit after sender resolution and before the BYO-carrier short-circuit; the decision is one hop upstream of the route LCR ranks. The dry-run quote (see Least-cost routing) simulates which carrier the engine would pick and what it would cost, but it never moves traffic — use it to preview a termination candidate’s cost before you flipenforce. For escalation semantics once a hop fails, the fallback planes decide the next channel; termination decides the first hop. Read Fallback and cascade planes for the five-plane map.
7. A worked request — create a rule and move it to enforce
The rule below absorbs inboundsubmit_sm traffic on your otp_esme_01 bind for +44 destinations onto WhatsApp, uses a deliver hop whose inbox: 'none' keeps it out of the conversation queue (see Keep one-time codes out of the Inbox for the full flavour):
actions body shapes the dashboard’s channel-selector flow (Developer → SMPP → termination rules): pick deliver with the target channel, and the selector exposes the same hop fields the API accepts.
See also
- Least-cost routing — the dry-run quote endpoint and the four-axis scorer
- Fallback and cascade planes — the five fallback planes map
- Keep one-time codes out of the Inbox — the absorb-rule
inbox: 'none'flavour - SMPP: the no-delivery receipt window — per-tenant receipt deadline tuning on the deliver hop