> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Unified termination routing

> One billed egress for every inbound submit: how termination rules decide between a deliver hop and absorbing a message onto another channel, the shadow-to-enforce lifecycle, and where LCR and fallback planes slot in.

# Unified termination routing

A `submit_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:

1. The preflight prices and debits `charge:msg:<messageId>` **before** routing decisions run.
2. The intent envelope carries `messageId` and `chargeKey` verbatim; nothing downstream re-mints them.
3. The terminator executes a conditional `UPDATE`, claims the intent, and calls the channel's send **once**, passing the pre-existing `messageId` so the second deduct lands on the already-claimed key and silently resolves to a no-op.

Because pricing runs *before* the BYO-carrier short-circuit and *after* the deny gates, the decision is made on the priced row — never on a \$0 placeholder. A rule that moves a message off the ingress channel (e.g. SMS→WhatsApp) is the rule that makes a second, **cheaper** leg possible. That is the entire point of the engine's existence.

## 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/shared` and `zod` only, so it **cannot** import the messaging router and can therefore never reach `router.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.

That means the `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:

```json theme={null}
{
  "actions": [
    {
      "kind": "deliver",
      "channel": "whatsapp",
      "account": "prefer_tenant",
      "on_failure": "next",
      "template_name": "verify_user"
    },
    { "kind": "absorb" }
  ]
}
```

* **`deliver`** picks 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'`).
* **`absorb`** is terminal: Orbit has taken ownership, so the original ingress (usually your SMPP bind) must not also deliver. Getting `absorbIngress` right in either direction is the difference between a double-send and a silent drop.

Any `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 in `mode: "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 `demoted` intent; nothing moves money; the `attempted_channels` array 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`, or `demoted`).

You read shadow hints from `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 ascending `priority`; 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:

1. **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.
2. **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.
3. **Receipt black hole** — a rule whose ingress was SMPP in `bind` mode has a declared `require_dlr_mode` (webhook/both). Rule-write rejects the offending combo at write time, not at 3am.
4. **Claim loss** — the terminator only sends when a conditional UPDATE flips `dispatch_state` from `pending` to `dispatching` and a re-read confirms it; zero rows returned means a different worker owns the intent and no send happens.
5. **Provider short-circuit bought but not delivered** — the terminator's recursion guard (`skipTermination` flag on its send call) is what prevents `decide → 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](/concepts/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 flip `enforce`. For escalation semantics once a hop fails, the fallback planes decide the next channel; termination decides the **first** hop. Read [Fallback and cascade planes](/concepts/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 inbound `submit_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](/guides/transactional-inbox-no-conversation) for the full flavour):

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/messaging/termination/rules \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "OTP via WhatsApp — no inbox thread",
    "priority": 10,
    "enabled": true,
    "mode": "shadow",
    "direction": "mt_absorb",
    "ingress_kind": "smpp_submit",
    "ingress_ref": "otp_esme_01",
    "match": { "all": [ { "field": "dest_addr", "op": "prefix", "value": "+44" } ] },
    "actions": [
      {
        "kind": "deliver",
        "channel": "whatsapp",
        "account": "prefer_tenant",
        "template_name": "verify_user",
        "template_language": "en",
        "on_failure": "next",
        "inbox": "none"
      },
      { "kind": "absorb" }
    ]
  }'
```

Submit a real OTP out of scope of the preview (still in shadow) to watch what the rule would have chosen. When the trace matches, flip the rule to enforce:

```bash theme={null}
curl -X PATCH https://api.orbit.devotel.io/api/v1/messaging/termination/rules/tmr_9f2c41ac… \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "mode": "enforce" }'
```

The same `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](/concepts/least-cost-routing) — the dry-run quote endpoint and the four-axis scorer
* [Fallback and cascade planes](/concepts/fallback-and-cascade-planes) — the five fallback planes map
* [Keep one-time codes out of the Inbox](/guides/transactional-inbox-no-conversation) — the absorb-rule `inbox: 'none'` flavour
* [SMPP: the no-delivery receipt window](/guides/smpp-receipt-timeout-window) — per-tenant receipt deadline tuning on the deliver hop
