Changing a provider, end to end
Only the adapter layer changes. voice_carriers.py is the one place a provider's own words appear; telephony.py knows what a line is and asks telephony.can(provider, capability) rather than branching on a name; everything above names no provider at all. The work is not writing the adapter — it is re-proving the six things that have silently broken before.
Where the boundary is#
| Layer | File | What it knows |
|---|---|---|
| Adapter | voice_carriers.py | One provider's signature scheme, XML dialect and webhook shape. The only place a provider's 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 screens | No provider name at all — and that is asserted by a test, not trusted. |
Mail has the same shape for a different reason. The tenant_* tables hold what the provider adapter stored, in the provider's own shape; the canonical threads, messages and contacts are what the engine and the screens read; connect_core.bridge_* copies between them. The engine never reads a provider table, which is why adding Microsoft Graph beside Gmail and IMAP changed nothing downstream of the bridge.
A phone line is not a new table either: it is a channel_routes row carrying provider, assignment, inbound and outbound permission, hours, routing, recording policy, capabilities, limits and status. Changing provider rewrites fields on that row rather than migrating a schema.
The chain a provider change runs through#
- TRIGGER — a new provider's credentials are saved, or a line is repointed at a different carrier.
- USER / EXTERNAL EVENT — the first real event is a webhook from the new provider, arriving at a route it has never used before.
- AUTH / WORKSPACE RESOLUTION — the signature is verified and then the handler enters the owning workspace. Both, in that order, every time: reading settings before entering the workspace is defect four below.
- INGEST / REQUEST — the adapter normalises the provider's payload into an event the core understands. Nothing above the adapter sees the provider's vocabulary.
- CANONICAL RECORD — the event is stored once in
call_events, with the signature nonce under a partial unique index. That index is the replay protection. - CLASSIFICATION / RESEARCH / REASONING — unchanged. The engine is reading canonical records, so it cannot tell which provider produced them.
- KNOWLEDGE + MEMORY + RULES — unchanged, and this is the test of a good adapter: nothing above it needed editing.
- AUTONOMY / APPROVAL — unchanged. Consent, suppression, autonomy and budget still gate the action.
- ACTION / PROVIDER — the new provider carries the leg. What it can do is asked with
telephony.can, never assumed from its marketing. - RESULT — the provider's own status is written to the
callsrow, keeping the engine'soutcomeseparate from a person'sdispositionso a late webhook cannot overwrite a human decision. - RELATIONSHIP / TIMELINE / MEMORY — the conversation, the lead and the follow-up land exactly as before.
- AUDIT / USAGE / NEEDS YOU — credentials are sealed on save and never echoed back to a screen; the change is audited with actor and before/after states, never with the secret.
- NEXT — read the provider's own call log against
callsfor the first day. ACompletedon their side with no row on yours is a bug every time.
Six things that must be re-proved#
Every one of these was a real production defect, every one was silent, and every one was found by driving the live endpoint or reading the carrier's log against ours. A new adapter earns none of them for free.
- Workspace scope on the verification path. A form check that verified the number outside any workspace scope read the workspace-scoped credentials as empty and answered every real call
403before pickup. - A host in the action URL. With
public_urlunset the turn URL came out host-less: the greeting plays and the call goes silent. - Status is not hangup. The same route served both, and ended the call on any status — a
ringingcallback hung up on a live caller. - Settings read inside the workspace. A handler that read voice settings before entering the workspace saw
enabled=Falsefor a switched-on line, played the goodbye and created noCall. The carrier logged a completed, charged, six-second call. - Something after the gather. With
<Gather>last in the document the carrier ran off the end of the XML and hung up: greeting, fifteen seconds of listening,NORMAL_CLEARING, and the turn URL never requested. A<Redirect>now follows it. - Read the direction. A carrier posts to the same answer URL for a customer calling in and for one of your own endpoints calling out. Ignoring
Directiongreeted an agent dialling from the browser as though they were the customer, and never rang the person they dialled.
What must not be assumed#
- Recording
foundation. Asked of the provider rather than assumed, and not enabled on the live carrier. A new provider that supports it still needs the capability turned on and the policy set on the line.- Human transfer
foundation. An escalation phrase queues a transfer and the supervisor panel can act on a live call; a completed warm transfer depends on a provider capability that is not enabled.- SMS
- The live carrier carries none, and the Phone screen says so rather than showing a thread that cannot send. Outbound SMS also depends on registration in some countries.
- The provider's own documentation
- Not evidence on its own. The softphone registers at an address taken from the provider's browser SDK; their written docs name a different address that answers nothing from any network, and every registration failed while code and CSP both pointed at it.
- Browser reachability
- The content security policy must name the SIP WebSocket or the line silently never registers. Nothing errors visibly; the dialer simply never comes up.
Proving the switch#
Run the regression suites for the area you touched, against a database whose name ends
_test.Result The layering assertion runs: it is a test that fails when a provider's name appears above the adapter.
Place one inbound and one outbound call on the new provider, including one from the browser line.
Result Inbound, outbound and softphone take different paths through the same answer URL. Two of the six defects above only appear on one of the three.
Open the provider's own log beside the
callslist for the same period.Result Every completed call on their side has a row on yours, with a matching duration and status. A mismatch is the fastest audit there is.
Check line health and the Needs You queue an hour later.
Result Calls that never rang (
not_reached) and sessions the model refused escalate on their own; neither needs anybody watching a dashboard.
Questions#
Does changing the mail provider lose the conversation history?
No. History lives on the canonical threads and messages, which the bridge writes into and the engine reads from. Changing the adapter changes where new mail is fetched, not what has already been recorded.
Can two providers run at once?
For mail, yes — a workspace can hold several mailboxes, each with its own role, signature, autonomy and provider. For phone, each line is one channel_routes row with one provider, so running two means running two lines.
How do I know the new adapter is not leaking provider names upward?
The test that asserts it is part of the suite, and it is the reason the assertion exists rather than a comment. Prove it by disabling the layering check once, watching the suite go red, and restoring it.