# Designing telephony that does not name its provider

Telephony code that names its provider cannot later remove that provider. Connect keeps one carrier's own words in a single adapter, keeps the meaning of a call in a core that differs between providers only through a capability table, and lets nothing above either name a provider at all — a rule asserted by test rather than trusted. Six production defects came from crossings of that boundary, and every one was silent.

- **Status:** Available
- **Audience:** developer
- **Channels:** phone
- **Last verified:** 2026-09-10
- **Canonical:** https://connectbyjbrh.com/research/provider-independent-telephony/

## The three layers

| Layer | File | What it knows |
|---|---|---|
| Adapter | `voice_carriers.py` | One provider's signature scheme, XML dialect and webhook shape — the only place its own words appear |
| Core | `telephony.py` | What a line is, what an event means, who may be called when. Providers differ only through `CAPABILITIES` |
| Everything above | `voice.py`, the consoles, the UI | No provider at all — asserted by test, not trusted |

`telephony.can(provider, capability)` is how anything asks whether recording, transfer or WebRTC exists on a line. Branching on a provider's name instead is how a product ends up unable to change carrier: the name spreads into screens, into conditionals nobody remembers, and eventually into the parts that would have to be rewritten to leave.

The capability table also keeps the documentation honest. Call recording is **not** enabled on the live carrier and a completed warm transfer depends on a capability that is not enabled either; both are asked of the provider rather than assumed, so a screen shows what the line can actually do.

## A line is a row that already existed

`channel_routes` already answered *whose endpoint is this and what is it for* for every channel, so the phone facts live on it: provider, assignment, inbound and outbound permission, hours, routing, recording policy, capabilities, limits and status. A person's browser line is a row there too, with `channel='softphone'`. No new table, and therefore no second answer to the question of what a workspace's endpoints are.

The canonical call record is `calls`: ring and answer times, provider status, cost in minor units, recording, transfers, participants, quality — and a human `disposition` kept deliberately apart from the engine's `outcome`, so a webhook arriving late cannot overwrite what a person decided.

## Six defects, all from the same boundary

1. `_carrier_form` verified the operator's own number **outside any workspace scope**, so the workspace-scoped credentials read as empty and every real call was answered 403 before pickup.
2. `_turn_url` emitted a **host-less** action URL when `public_url` was unset: the greeting plays and the call goes silent.
3. `/voice/{carrier}/status` was both the hangup URL and the status URL and ended the call on **any** status, so a `ringing` callback hung up on a live caller.
4. `carrier_incoming` read the voice settings **before entering the workspace**, saw a switched-on line as disabled, said the goodbye message and created no `Call` — while the carrier logged a completed, charged six-second call.
5. The `<Gather>` was the **last element in the document**, so when nothing usable was heard the carrier ran off the end of the XML and hung up: two real calls of greeting, fifteen seconds of listening, `NORMAL_CLEARING`, and the turn URL never requested once.
6. **`Direction` was never read.** A carrier posts to the same answer URL for a customer calling in and for one of the business's own SIP endpoints calling out, so an agent dialling from the browser was greeted as the customer and the person they dialled never rang.

Every handler now runs inside the owning workspace rather than only inside the signature check, and a `<Redirect>` follows `</Gather>` back to the turn handler, which answers empty speech with *sorry, I did not catch that*.

## What the six have in common

None raised an error. Each produced a call that a monitoring dashboard would call fine: answered, completed, charged. They were found by driving the live endpoint and by reading the carrier's own call log beside the `calls` table — a `Completed` on their side with no row on yours is a bug every time, and it is the cheapest telephony audit there is.

Three of the six are scope or ordering errors rather than protocol errors: code that read a setting, verified a credential or resolved a route *before* entering the workspace that owns it. That is a boundary crossing in the other direction — not naming a provider, but forgetting a tenant.

## What the boundary does not remove

- The adapter still has to be written per provider, and provider documentation can be wrong: the softphone registers at an address taken from the carrier's own browser SDK, while their written docs name a different address that answers nothing from any network.
- Some provider knowledge is environmental. The Content-Security-Policy must name the SIP WebSocket or the browser line silently never registers, and a registration that fails silently reads as a user error.
- Provider behaviour can be counter-intuitive in ways no abstraction hides: the carrier does not dial the number in a browser INVITE — it posts to the application's answer URL with `Direction=outbound` and does whatever the XML says, so an endpoint not attached to the voice application registers fine and can never place a call.
- A capability nobody exposes stays unavailable. Recording and human transfer are foundation here for that reason, not because the layering hides them.

## Questions

### Is one adapter file per provider not just a plugin system with extra steps?

The difference is which direction the knowledge flows. A plugin system usually lets a provider's concepts reach the caller through an interface shaped like that provider. Here the core defines what a line and an event are, and the adapter translates into that vocabulary — so a second provider changes one file, and the test that nothing above names a provider is what keeps it that way.

### How is 'no provider name above the core' actually enforced?

By a test rather than by convention. A grep-style assertion over the upper layers is trivial to write and impossible to argue with, and it catches the reasonable-looking special case that would otherwise be added during an incident and never removed.

### Which of the six defects would have been caught by unit tests?

Few of them, honestly. Each depended on the real request a carrier sends — an unset `public_url`, a `ringing` status, a missing `Direction` field, a document whose last element was consumed. Driving the live endpoint and reconciling against the carrier's log found them, which is why that reconciliation is now part of the routine rather than an incident response.

## Related

- [Idempotency for retried telephony webhooks](https://connectbyjbrh.com/research/idempotent-telephony-webhooks/)
- [When a call is answered in silence](https://connectbyjbrh.com/research/silent-failure/)
- [Phone and voice in Connect](https://connectbyjbrh.com/docs/phone/)
- [Three independent layers of tenant isolation](https://connectbyjbrh.com/research/three-layers-of-isolation/)
- [Technology reference](https://connectbyjbrh.com/docs/technology/)

## What this page is based on

- `docs-source/sources/PHONE.md` §1 — the three layers, `channel_routes` and `calls`
- `docs-source/sources/PHONE.md` §2 — the six production defects
- `docs-source/sources/PHONE.md` §11 — the softphone, its registrar and the CSP requirement
- `docs-source/sources/PHONE.md` §12 — capabilities not enabled on the live carrier
