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:
+18005550100is unique across the whole platform, not scoped to a tenant.
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
+1800prefix 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.”
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.
See also
- Inbound voice routing — the resolution mechanics this page’s tables feed
- Tenant isolation — the shared-catalog section that lists both routing tables as the deliberate exception
- Inbound message resolution — the SMS/MO ownership layer answering the same edge-resolution problem
- Voice call lifecycle — the state transitions that follow the routing decision
- Provisioning and test mode — the readiness window during which routing reads only the shared catalog
- Jambonz softswitch model — the SIP edge where the unscoped routing read executes