Skip to main content

Inbound voice routing: why the tables are shared

Most of your data in Orbit lives in one PostgreSQL schema per tenant — messages, contacts, recordings, and flows never appear in any other tenant’s schema (Tenant isolation lays out that split). Inbound voice routing breaks that pattern on purpose: its two tables, dnis_routes and inbound_routes, live in the shared platform catalog. This page is the design decision behind that exception — why the tables are shared, and what enforces the tenant boundary anyway when schema separation is deliberately absent.

What is actually shared

Exactly two routing tables sit in the shared catalog, and both carry an organization id on every row: Shared here means one physical table in the platform catalog that every tenant’s rows live in — not that rows become visible to other tenants. Every routing row belongs to exactly one organization because each carries its owning organization’s id.

The one problem a shared table solves: resolution before tenancy exists

An inbound call arrives at the SIP edge as a SIP INVITE addressed to a dialed number. In that moment:
  • The dialed number is the only input — there is no API key, no session, no authenticated caller on the platform side yet.
  • The edge must produce a routing decision at call setup, before media flows, or the caller lands on dead air.
  • A dialed number is a global identifier: +18005550100 is unique across the whole platform, not scoped to a tenant.
Per-tenant read paths authenticate first and then pin a tenant schema for the query. That works for the dashboard and the API, where a credential exists before any query runs. The SIP edge has no such moment: tenancy does not exist until the dialed number tells it which organization the call belongs to. A per-tenant routing table would answer that question only after the edge already knew the answer — a circular lookup that cannot resolve. So the tables that answer “which organization owns this number” and “where do calls to this number terminate” must live in one catalog reachable without a tenant context. Routing data is in the shared schema for the same reason the organizations table is: it is part of the lookup that establishes tenancy, not part of tenant data.

How the decision gets made

Once the catalog layout settled the where, the remaining design points are about how a resolver behaves across tenant lines it can physically touch:
  • Org-first resolution. A DNIS match is only ever evaluated within the organization that currently owns the dialed number. The resolver asks which organization the number belongs to, then reads that organization’s pattern rules and per-number rows. Your +1800 prefix rule can never catch another tenant’s toll-free DID, and that tenant’s rules are never candidates for your number — even when a regex would match. Tenant precedence to a dialed number is decided by ownership, never by rule generality.
  • The stale-route guard. The per-number route row and the number-ownership record are read separately, and the route is honored only when its organization agrees with the current owner. A released-then-rebought DID can leave a stale route behind from the prior owner; on organization mismatch the route is rejected on the spot and the call re-resolves through the remaining chain (pattern rules, org default, safe default). The shared table physically holds both tenants’ eras of the same number; the guard is what keeps routing obedient to the current owner only.
  • Ownership is re-validated per call, not cached across calls. The ownership read runs at every call setup, so a number that changes hands starts resolving to the new owner from the first call after the change — no stale-attribution window.
  • Suspended and deleted organizations are rejected at the same gate. A resolved organization that is soft-deleted, cancelled, or suspended fails the resolution and the call takes the safe-default path — a shared catalog never routes into a frozen tenant.
  • The fail-open floor is the safe default. When the resolution database is briefly unreachable, the call degrades to a polite “line not available” announcement, never to a dropped INVITE. For inbound routing, safe default + org-first + per-call stale validation is the full answer to “shared but safe anyway.”
The mechanics of the chain — verb plans, caller-id filters, business hours, the four-step ordering — live in Inbound voice routing; this page is only about where the tables sit and why that is safe.

What this costs, and what stays per-tenant anyway

The trade accepted by putting routing rows in one catalog:
  • Tenant discipline moves from the schema layer to the row layer. Nothing structurally prevents a cross-tenant read; the org-first rule, the ownership-consistency guard, and per-call re-validation carry the whole boundary. The Tenant isolation page lists both tables explicitly in its shared-catalog section so the exception is auditable rather than implicit.
  • Reads at the SIP edge are unscoped by design, and that property is exercised on every call — a regression in the shared-catalog resolver is visible in call behavior, not only in a permission check.
  • Aggregation stops at the route-config cell. Only the routing decision (target type plus target config) lives in the shared catalog. Once the decision resolves to an organization, the content of the call — conversation record, recording, voicemail, queue interaction — is written into that tenant’s schema in the ordinary way, so live-call data never sits in the shared tables.
What deliberately was not done: colocating routing tables in per-tenant schemas behind an edge-side schema-selection step. That alternative adds latency to every call setup and still fails the circular-lookup problem at number-acquisition time; the row-level guards were preferred over schema placement. Work through the walkthrough in Inbound number routing for the operator-facing side of per-number and DNIS routes; the SMS analogue of this same shared-resolution problem lives in Inbound message resolution.

See also