Skip to content

Multi-test-phone schema — let each FDE own their own test phone per voice assistant

One-line summary. voice_assistants.test_inbound_call_phone_id is 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: add test_voice_assistant_id + owner_user_id columns to phone_numbers so each FDE owns their own row, with voice_assistants.test_inbound_call_phone_id retained 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:

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

StateLegacy 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 phonesnull (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 phoneall 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.

ShapeStrengthsWeaknessesVerdict
Join table (voice_assistant_test_phones)Explicit, normalized, separate from phone_numbersModels 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_assistantsSmallest 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_numbers row with test_voice_assistant_id = voiceAssistantId, owner_user_id = currentUserId, is_test_phone = true.
  • Only set voice_assistants.test_inbound_call_phone_id = phoneNumberId when 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 / linePurposeMulti-phone behavior
admin/voice-assistants/actions.ts:479 (inside syncTestPhoneAction)Resolves which test phone to syncMust 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)SameSame.
admin/voice-assistants/actions.ts:678,800Fetching/passing assistant dataNo 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,754Editor UI — displays the test phone, writes it on assistant editsUpdates 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,425Query helpers — reverse-lookup by phone id, JOIN expression for primary test phoneMostly 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,91Resolves the right test phone for Hamming simsOpen 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:189Admin call debugger — compares incoming call's phone id to the VA's test phone idNo 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:

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

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_phone semantics 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)

  1. Migration deploys ahead of code change. The schema is forward-compatible: new columns are unread by existing code; nothing breaks.
  2. Backfill runs as part of the migration. Every existing test phone gets test_voice_assistant_id set; no orphan rows.
  3. Code changes deploy. Provisioning + sync flows write to the new structure. Legacy pointer still maintained as primary reference.
  4. 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

  1. Owner identity FK target. phone_numbers.owner_user_id REFERENCES users(id) — is users(id) the right table? Avoca may have a team_members or federated-auth model. Confirm with Avoca team.
  2. 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.
  3. 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?
  4. Promote-non-primary-to-primary UI. v1 leaves the primary as whoever provisioned first. Worth a v2 toggle? Likely yes, but defer.
  5. 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).