Appearance
Multi-test-phone schema — let each FDE own their own test phone per voice assistant
One-line summary.
voice_assistants.test_inbound_call_phone_idis a single FK, so one FDE provisioning a test phone overwrites the previous FDE's. With 10+ engineers and shared blueprint-managed assistants (e.g., EAS Inbound Ponderosa), concurrent local-dev work is impossible — the very next FDE to land on the same VA loses their tunnel wiring. Fix: addtest_voice_assistant_id+owner_user_idcolumns tophone_numbersso each FDE owns their own row, withvoice_assistants.test_inbound_call_phone_idretained as the "primary" pointer for backwards compatibility.
Why this is a proposal, not just a PR
A schema migration on phone_numbers (a heavily-used shared table) plus a backfill plus code changes across provisioning + sync + admin UI is bigger than a quick PR. This doc is the artifact for proposing the change to the Avoca team. Implementation follows acceptance.
Problem statement
After Plan P merges, an FDE can route a test phone's traffic to their own tunnel via NGROK_BASE_URL + TEST_PHONE_POST_CALL env vars. Single-FDE local-dev works.
But the schema below caps it at one FDE per voice assistant:
sql
-- Current shape
CREATE TABLE voice_assistants (
id UUID PRIMARY KEY,
...,
test_inbound_call_phone_id BIGINT REFERENCES phone_numbers(id)
-- ^ single FK; one test phone per VA
);FDE A provisions a test phone via provisionTestPhoneNumberAction → it writes voice_assistants.test_inbound_call_phone_id = <A's phone>. FDE B provisions for the same VA → the action writes test_inbound_call_phone_id = <B's phone>, overwriting A's wiring. A's test calls now route to B's tunnel (if B is online) or to a dead Vapi server URL (if B has since changed env or torn down their tunnel).
Avoca has 10+ engineers. The very first time two of them land on the same blueprint-managed assistant in parallel, this becomes a daily blocker.
Vapi-side check (no conflict)
Worth confirming upfront: Vapi natively supports many phone numbers routing to one assistant. Each phone-number record in Vapi has its own assistantId / squadId field. Vapi has no "test phone" or "primary phone" concept — that's purely Avoca-side bookkeeping. So this schema change is Avoca-internal; no Vapi-side coordination work.
Sources verified 2026-05-10:
- Phone Calling | Vapi — assistantId is per-phone-number, many-to-one supported
- Get Phone Number | Vapi API — no test/prod flag, no primary concept in the schema
Proposed schema
sql
ALTER TABLE phone_numbers
ADD COLUMN test_voice_assistant_id UUID REFERENCES voice_assistants(id) ON DELETE CASCADE,
ADD COLUMN owner_user_id UUID REFERENCES users(id);
CREATE INDEX idx_phone_numbers_test_va
ON phone_numbers(test_voice_assistant_id)
WHERE is_test_phone = true;
-- voice_assistants.test_inbound_call_phone_id stays unchanged.
-- Semantics shift from "the (only) test phone" to "the primary test phone."
-- Single-test-phone state behaves identically to today.Three columns added; one new partial index; no columns removed or modified.
Behavior table
| State | Legacy reader (voice_assistants.test_inbound_call_phone_id) | New reader (phone_numbers WHERE test_voice_assistant_id = X AND is_test_phone = true) |
|---|---|---|
| Zero test phones | null (unchanged) | empty (consistent) |
| One test phone (today's typical) | the single test phone (unchanged) | one row matching the pointer |
| N test phones (post-migration) | the primary test phone | all N |
Legacy code paths continue working without modification. New code reads via the FK; old code keeps reading the pointer; both return consistent state for single-test-phone VAs.
Why this shape (alternatives compared)
Three options were evaluated. The hybrid (chosen) sits in the middle.
| Shape | Strengths | Weaknesses | Verdict |
|---|---|---|---|
Join table (voice_assistant_test_phones) | Explicit, normalized, separate from phone_numbers | Models M-to-N when reality is N-to-1. Adds a table, RLS surface, JOINs to find phones for a VA. | Rejected — over-engineered for the actual cardinality. |
JSON list on voice_assistants | Smallest disruption to phone_numbers. Single column read. | No FK constraints on the JSON refs. Cascade deletes become manual. "Phones owned by user X" requires JSON unnest, hard to index. Two sources of truth (phone metadata on phone_numbers, linkage on voice_assistants). | Rejected — operational lightness traded for referential integrity loss. |
FK on phone_numbers + legacy pointer (chosen) | FK enforces integrity. Indexable for both "all phones for VA" and "phones owned by user X." Single source of truth (linkage co-located with phone metadata). Legacy code keeps working. | Adds columns to phone_numbers, a busy shared table. Provisioning code writes two locations (the new row + the pointer for primary). | Chosen. |
The dominant consideration for choosing the FK shape over the JSON shape was referential integrity: cascade-on-VA-delete is automatic; orphan rows can't exist. The downside (phone_numbers is a shared table, schema changes have broader coordination cost) is mitigated by the impact eval (next section).
Blast radius — what this change touches
Impact eval run against apps/web (avoca-next at main 2026-05-10). Grep targets: test_inbound_call_phone_id, testInboundCallPhoneId, is_test_phone, isTestPhone. Generated types files excluded (auto-regenerate from the migrated schema).
Direct touch points for the new schema (6 non-generated files)
Writer — provision-test-phone.ts (1 file, 1 write site)
apps/web/lib/voice-assistants/test-phone/provision-test-phone.ts:90 currently writes voice_assistants.test_inbound_call_phone_id = phoneNumberId directly, which is the source of the overwrite bug.
Change shape:
- Insert / update a
phone_numbersrow withtest_voice_assistant_id = voiceAssistantId,owner_user_id = currentUserId,is_test_phone = true. - Only set
voice_assistants.test_inbound_call_phone_id = phoneNumberIdwhen it's currently null (so the first provisioning for a VA becomes the primary; subsequent FDEs add rows but don't overwrite the primary).
Readers — 5 files
| File / line | Purpose | Multi-phone behavior |
|---|---|---|
admin/voice-assistants/actions.ts:479 (inside syncTestPhoneAction) | Resolves which test phone to sync | Must change. Resolve via phone_numbers WHERE test_voice_assistant_id = vaId AND owner_user_id = currentUserId AND is_test_phone = true. Fall back to legacy pointer if no row matches the current user. |
admin/voice-assistants/actions.ts:1146 (second syncTestPhoneAction reader) | Same | Same. |
admin/voice-assistants/actions.ts:678,800 | Fetching/passing assistant data | No change — keeps reading the primary pointer for display. New "list all test phones" surface is a separate read. |
components/admin/voice-assistants/VoiceAssistantEditor.tsx:694,754 | Editor UI — displays the test phone, writes it on assistant edits | Updates needed for the new UI panel (list of test phones with owner column + Sync per row). Existing single-phone read for the primary stays. |
lib/supabase/voice-assistants.ts:321,423,425 | Query helpers — reverse-lookup by phone id, JOIN expression for primary test phone | Mostly unchanged. L321's reverse-lookup may need to also union phone_numbers WHERE test_voice_assistant_id = X to find non-primary test phones. L423-425's JOIN still fetches the primary. Add a new helper for "all test phones for VA." |
lib/voice-assistants/test-phone/resolve-hamming-assistant.ts:74,86,91 | Resolves the right test phone for Hamming sims | Open question (see below). If Hamming runs scope is per-FDE, this should pick the current user's test phone. If Hamming runs are shared/team-level, it should keep using the primary. |
app/actions/call-debugger.ts:189 | Admin call debugger — compares incoming call's phone id to the VA's test phone id | No change for v1. The comparison may miss non-primary test phones; minor UX issue, can fix in a follow-up. |
Indirect touch points — is_test_phone (unchanged by this proposal)
is_test_phone is the boolean flag that distinguishes test traffic from production. It flows through the call lifecycle:
lib/call/prepare.ts:89-95— looks upisTestPhonefor the incoming phone, propagates through call preparationlib/workflow/pre-call/assistant-request.ts:165,412— usesisTestPhoneto decide whether to hide the call from default reportinglib/workflow/post-call/EndOfCallReportInngestFunction.ts:663-860— flags the call record as test, may affect downstream processinglib/vapi/with-vapi-tool-call.ts:181— combined withisTestquery param to gate destructive mutationslib/supabase/phone-numbers.ts:151-180— provides the helpers (getPhoneNumberLookupResult,markPhoneNumberAsTestPhone)
No changes needed. The flag stays on phone_numbers; multi-test-phone just means multiple rows with is_test_phone = true for the same VA. The flag's semantics (this row represents test traffic) are unchanged.
The pages/api/responder/*/workflow.ts files using isTestPhoneNumber(callerPhone) use a phone-number-string utility (lib/utils/tests.ts) that's unrelated to the column — they pattern-match on the phone number string itself.
Test coverage
apps/web/lib/call/prepare.test.tsandapps/web/tests/lib/workflow/pre-call/assistant-request-common.test.tsuseisTestPhonein their mocks. No changes needed — these test call-lifecycle behavior, not the schema. They keep passing.- New tests needed for the migrated
provisionTestPhoneNumberActionandsyncTestPhoneActionpaths (per the test plan in the brief).
Background jobs / Inngest functions
EndOfCallReportInngestFunction reads isTestPhone but doesn't touch the schema we're changing. No Inngest functions read/write test_inbound_call_phone_id directly.
Summary
- 6 non-generated files directly touch
test_inbound_call_phone_id. Only 2 file changes are required for v1 multi-test-phone behavior: the writer (provision-test-phone.ts) and the sync resolver (admin/voice-assistants/actions.ts). - UI surface for the admin panel (
VoiceAssistantEditor.tsx) gets a new list view; existing single-phone display can stay as the "primary" indicator. - 1 open question on Hamming resolution (
resolve-hamming-assistant.ts) — settle in proposal review. - No changes to
is_test_phonesemantics or call-lifecycle flow.
Compared to the original brief's "many code paths to update" framing, the actual surface is contained: 2 must-change files + 1 UI panel + 1 design question. The blast radius is smaller than initially feared because most is_test_phone readers don't care about ownership — they just need to know "is this row test traffic?"
Migration story
Schema migration
sql
-- Migration: 20260512000000_phone_numbers_multi_test_phone.sql
ALTER TABLE phone_numbers
ADD COLUMN test_voice_assistant_id UUID REFERENCES voice_assistants(id) ON DELETE CASCADE,
ADD COLUMN owner_user_id UUID REFERENCES users(id);
CREATE INDEX idx_phone_numbers_test_va
ON phone_numbers(test_voice_assistant_id)
WHERE is_test_phone = true;
-- Backfill: every VA with a primary test phone gets its phone_numbers row updated.
UPDATE phone_numbers pn
SET test_voice_assistant_id = va.id
FROM voice_assistants va
WHERE va.test_inbound_call_phone_id = pn.id
AND pn.is_test_phone = true
AND pn.test_voice_assistant_id IS NULL;Deploy ordering (defensive-write pattern)
- Migration deploys ahead of code change. The schema is forward-compatible: new columns are unread by existing code; nothing breaks.
- Backfill runs as part of the migration. Every existing test phone gets
test_voice_assistant_idset; no orphan rows. - Code changes deploy. Provisioning + sync flows write to the new structure. Legacy pointer still maintained as primary reference.
- Admin UI rolls out. New "test phones" panel replaces the single-phone display.
Rollback path
The migration is additive. To roll back:
sql
-- Reverse migration
DROP INDEX IF EXISTS idx_phone_numbers_test_va;
ALTER TABLE phone_numbers
DROP COLUMN IF EXISTS test_voice_assistant_id,
DROP COLUMN IF EXISTS owner_user_id;Legacy voice_assistants.test_inbound_call_phone_id is preserved throughout, so reverting the schema returns the system to its pre-migration state. Any non-primary test phones provisioned post-migration would be orphaned in phone_numbers (still readable, just without their test-VA linkage); cleanup would be a one-line UPDATE phone_numbers SET is_test_phone = false WHERE ... if desired.
Open questions to settle in proposal review
- Owner identity FK target.
phone_numbers.owner_user_id REFERENCES users(id)— isusers(id)the right table? Avoca may have ateam_membersor federated-auth model. Confirm with Avoca team. - Same-user reprovision. If FDE A provisions, then provisions again for the same VA, what happens? Replace their existing row? Add a second? Error? Recommend "replace" with a confirmation prompt — but settle in review.
- Per-VA cap on test phones. Probably yes to prevent runaway Twilio costs. Default soft cap (e.g., 5) with admin override? Or no cap initially and add later if needed?
- Promote-non-primary-to-primary UI. v1 leaves the primary as whoever provisioned first. Worth a v2 toggle? Likely yes, but defer.
- Stale-phone cleanup when FDE leaves. Manual delete in v1. Automate via Linear / off-boarding integration in a future plan?
Why this matters for the FDE program
Plan P's env-var unblocks make Avoca's blueprint-managed assistants locally-developable for a single FDE. With 10+ engineers, "developable by one at a time" caps the value sharply — the very next person to land on the same blueprint hits a wall and loses an afternoon figuring out why their tunnel went dark.
This plan removes the cap. Once it's live, the FDE local-dev loop is genuinely usable at team scale.
Broader pattern (toolkit observation)
The Avoca pattern of "denormalized pointer on the parent + flag on the child" (voice_assistants.test_inbound_call_phone_id + phone_numbers.is_test_phone) is a clean way to model "the primary X" semantics without separate constraint tables. This proposal preserves that pattern (keep the pointer as "primary") and extends with an explicit FK for the full set. Worth noting for similar future work (e.g., if you ever want multiple Twilio voice URLs per assistant, the same shape — pointer for primary, FK on child for the full set — would work).
Related pages
- Plan P brief — env-var unblock that this plan depends on
- Local Dev Setup — the FDE workflow that this plan extends from single- to multi-FDE
- Vapi Squads — context on the squad routing model
- Solutions doc template precedent — same shape used for PR #9877 work