/** * Pure routing logic for the voice call setup phase. * * Given a setup context (call session, gateway trust verdict, voice config, * ACL policy), returns a discriminated union describing what the call session * should do next — without performing any side effects itself. * * The gateway verdict is the sole caller-trust source. An unusable verdict * (missing, `resolutionFailed`, or member-unresolvable) fails closed — * matching the text path's posture: inbound calls are denied, outbound setup * aborts. */ import type { AdmissionPolicy, TrustVerdict } from "@vellumai/gateway-client"; import { getPendingSession } from "../channels/gateway-verification-sessions.js"; import { getConfig } from "../config/loader.js"; import type { ActorTrustContext } from "../runtime/actor-trust-resolver.js"; import { DAEMON_INTERNAL_ASSISTANT_ID } from "../runtime/assistant-scope.js"; import { type AdmissionPolicyResult, enforceAdmissionPolicy, trustedContactPromotionClearsFloor, } from "../runtime/routes/inbound-stages/admission-policy.js"; import { actorTrustContextFromVerdict, verdictUsability, } from "../runtime/trust-verdict-consumer.js"; import { getLogger } from "../util/logger.js"; import { getActiveVoiceInvite } from "./gateway-invite-reader.js"; import type { CallSession } from "./types.js"; import { TRUST_UNAVAILABLE_DENY_MESSAGE, unresolvedActorTrust, } from "./unresolved-caller-trust.js"; const log = getLogger("call-setup-router"); // ── Setup context ──────────────────────────────────────────────────── interface SetupContext { callSessionId: string; session: CallSession | null; from: string; to: string; customParameters?: Record; /** * Per-channel inbound admission floor for the `phone` channel, supplied by * the caller. When absent/`null`, the floor check is skipped entirely — * preserving all pre-admission behavior. */ admissionPolicy?: AdmissionPolicy | null; /** * Gateway-stamped caller trust verdict — the sole caller-trust source. * A missing/failed/member-unresolvable verdict fails closed (inbound deny, * outbound setup abort). */ verdict?: TrustVerdict | null; } // ── Setup outcomes ─────────────────────────────────────────────────── export type SetupOutcome = | { action: "normal_call"; isInbound: boolean } | { action: "verification"; assistantId: string; fromNumber: string; } | { action: "outbound_verification"; assistantId: string; sessionId: string; toNumber: string; } | { action: "callee_verification"; verificationConfig: { maxAttempts: number; codeLength: number }; } | { action: "invite_redemption"; assistantId: string; fromNumber: string; /** * Display name of the invitee. For inbound redemptions, supplied by the * gateway's active-voice-invite read (bound contact `displayName` * preferred, invite `friendName` fallback). For outbound invite calls, * carries the session-recorded `inviteFriendName`. When null/empty, the * relay uses a neutral "Hi there" greeting instead of substituting the * channel address. */ inviteeName: string | null; } | { action: "name_capture"; assistantId: string; fromNumber: string } | { action: "unverified_caller"; assistantId: string; fromNumber: string; displayName: string; isGuardian: boolean; } | { action: "deny"; message: string; logReason: string }; // ── Resolved context produced alongside the outcome ────────────────── export interface SetupResolved { assistantId: string; isInbound: boolean; otherPartyNumber: string; actorTrust: ActorTrustContext; } // ── Router ─────────────────────────────────────────────────────────── /** * Determine the setup outcome for a starting call session. * * This function is pure routing logic — it reads state (including the * gateway's active-voice-invite view) but performs no side effects (no * call-session mutations, no event recording, no WS messages). The caller * (the media-stream server's start handler) is responsible for acting on * the returned outcome. */ export async function routeSetup(ctx: SetupContext): Promise<{ outcome: SetupOutcome; resolved: SetupResolved; }> { const assistantId = DAEMON_INTERNAL_ASSISTANT_ID; const isInbound = ctx.session?.initiatedFromConversationId == null; const otherPartyNumber = isInbound ? ctx.from : ctx.to; // The gateway verdict is the sole caller-trust source; an unusable one // fails closed, mirroring the text path's resolutionFailed deny // (acl-enforcement.ts): inbound is denied with the unavailable copy and no // stranger-lane side effects; outbound (guardian-initiated) aborts setup // loudly via the transport's setup-failure teardown. const usability = verdictUsability(ctx.verdict); if (!usability.usable) { const { reason } = usability; if (!isInbound) { throw new Error( `Voice setup: caller trust verdict unavailable (${reason}) — aborting outbound setup`, ); } log.warn( { callSessionId: ctx.callSessionId, from: ctx.from, reason }, "Inbound voice ACL: trust verdict unavailable — denying fail-closed", ); return { outcome: { action: "deny", message: TRUST_UNAVAILABLE_DENY_MESSAGE, logReason: `Inbound voice ACL: trust verdict unavailable (${reason}) — fail-closed deny`, }, resolved: { assistantId, isInbound, otherPartyNumber, actorTrust: unresolvedActorTrust(otherPartyNumber), }, }; } const { verdict } = usability; const actorTrust = actorTrustContextFromVerdict(verdict, { sourceChannel: "phone", conversationExternalId: otherPartyNumber, actorDisplayName: undefined, }); const resolved: SetupResolved = { assistantId, isInbound, otherPartyNumber, actorTrust, }; // ── Outbound flow selection based on persisted call mode ────────── const persistedMode = ctx.session?.callMode; // ── Outbound invite redemption (persisted mode) ───────────────── if (persistedMode === "invite") { return { outcome: { action: "invite_redemption" as const, assistantId, fromNumber: ctx.to, inviteeName: ctx.session?.inviteFriendName ?? null, }, resolved, }; } // ── Outbound guardian verification (persisted mode) ────────────── const persistedVsId = ctx.session?.verificationSessionId; const customParamVsId = ctx.customParameters?.verificationSessionId; const verificationSessionId = persistedVsId ?? customParamVsId; if (persistedMode === "verification" && verificationSessionId) { return { outcome: { action: "outbound_verification", assistantId, sessionId: verificationSessionId, toNumber: ctx.to, }, resolved, }; } // Secondary signal: custom parameter without persisted mode (pre-migration) if (!persistedMode && customParamVsId) { log.warn( { callSessionId: ctx.callSessionId, verificationSessionId: customParamVsId, }, "Guardian verification detected via setup custom parameter (no persisted call_mode) — entering verification path", ); return { outcome: { action: "outbound_verification", assistantId, sessionId: customParamVsId, toNumber: ctx.to, }, resolved, }; } // ── Outbound callee verification ──────────────────────────────── const config = getConfig(); const verificationConfig = config.calls.verification; if (!isInbound && verificationConfig.enabled) { return { outcome: { action: "callee_verification", verificationConfig, }, resolved, }; } // ── Outbound normal call ──────────────────────────────────────── if (!isInbound) { return { outcome: { action: "normal_call", isInbound: false }, resolved, }; } // ── Inbound call ACL evaluation ───────────────────────────────── // Gateway read; throws on transport failure (control-plane posture — // setup fails loudly rather than mis-routing past a pending challenge). // Skipped when the verdict stamps `hasInterceptableVerificationSession: // false` — the channel-scoped stamp is authoritative only as a negative // (same rule as the text path); `true`/absent falls back to the read. const pendingChallenge = verdict.hasInterceptableVerificationSession === false ? null : await getPendingSession("phone"); // An admission floor is "active" only when a policy applies and no pending // verification challenge is in flight. While active, an admitted caller // bypasses the legacy identity flows (unverified_caller / name_capture) and // connects directly. When inactive (null policy, flag off, exempt channel, // or a pending challenge), those legacy flows are preserved unchanged. const floorActive = ctx.admissionPolicy != null && !pendingChallenge; // Whether a guardian approval could lift a below-floor caller past this // floor. On floors it can clear, a floor deny routes the caller into the // guardian access-request flow instead of hanging up on them (see the // unknown-caller branch below). const approvalCouldClearFloor = ctx.admissionPolicy != null && trustedContactPromotionClearsFloor(ctx.admissionPolicy); // Inbound admission floor verdict; defaults to admitted when inactive. const floorVerdict = floorActive ? enforceAdmissionPolicy({ sourceChannel: "phone", trustClass: actorTrust.trustClass, memberStatus: actorTrust.memberRecord?.status, policy: ctx.admissionPolicy!, }) : ({ admitted: true } as const); // Floor-deny outcome shared by the unknown-caller and member-caller branches. // Live calls cannot await async re-verification, so the floor's // `shouldChallenge` upgrade UX is not surfaced. const floorDeny = ( denyVerdict: Extract, ) => { log.info( { callSessionId: ctx.callSessionId, from: ctx.from, trustClass: actorTrust.trustClass, effectivePolicy: denyVerdict.effectivePolicy, }, "Inbound voice ACL: admission floor denied caller", ); return { outcome: { action: "deny" as const, message: "This number is not authorized to reach the assistant right now.", logReason: `Inbound voice admission floor: ${denyVerdict.effectivePolicy}`, }, resolved, }; }; // Members whose channel the guardian set to `policy: 'deny'`. This gate is // trust-class independent and runs ahead of every inbound flow below: the // gateway derives trust class from channel STATUS alone, so a denied channel // still resolves to `unverified_contact` (status `unverified`/`pending`) or // `trusted_contact` (status `active`). Gating per class would leave the deny // unenforced for whichever classes the branch order happened to exclude. // // It also outranks an active voice invite: an explicit channel-level deny is // the guardian's standing ruling, and an invite must not redeem past it into // a trusted contact. if (actorTrust.memberRecord?.policy === "deny") { log.info( { callSessionId: ctx.callSessionId, from: ctx.from, channelId: actorTrust.memberRecord.channel.id, trustClass: actorTrust.trustClass, }, "Inbound voice ACL: member policy deny", ); return { outcome: { action: "deny", message: "This number is not authorized to use this assistant.", logReason: "Inbound voice ACL: member policy deny", }, resolved, }; } if ( (actorTrust.trustClass === "unknown" || actorTrust.trustClass === "unverified_contact") && !pendingChallenge ) { // Check for blocked caller if (actorTrust.memberRecord?.status === "blocked") { log.info( { callSessionId: ctx.callSessionId, from: ctx.from, trustClass: actorTrust.trustClass, }, "Inbound voice ACL: blocked caller denied", ); return { outcome: { action: "deny", message: "This number is not authorized to use this assistant.", logReason: "Inbound voice ACL: caller blocked", }, resolved, }; } // Check for an active voice invite. The gateway row is the lifecycle // authority; the reader fails soft to `null` on any gateway failure, so a // gateway blip falls through to the unverified-caller flows below instead // of stalling setup. const voiceInvite = await getActiveVoiceInvite(ctx.from); if (voiceInvite) { log.info( { callSessionId: ctx.callSessionId, from: ctx.from }, "Inbound voice ACL: unknown caller has active voice invite — entering redemption flow", ); return { outcome: { action: "invite_redemption", assistantId, fromNumber: ctx.from, inviteeName: voiceInvite.inviteeName, }, resolved, }; } // A caller who clears the floor connects directly, skipping // unverified_caller / name_capture. Invites (handled above) bypass the // floor as an explicit grant. // // A caller BELOW the floor is not simply hung up on: when a guardian // approval could admit them, fall through to the legacy identity flows // below, which capture the caller's name, raise a guardian access request, // and hold the line for the decision. The floor still governs the outcome, // because the flow only ever admits a caller the guardian approved into a // trusted contact. // // Two cases keep the hard deny. Floors a trusted-contact promotion could // not clear (`guardian_only`, `no_one`), where an approval flow would // promise a decision that cannot admit the caller. And `blocked` / // `revoked` members, who should hear the denial rather than an invitation // to identify themselves. (The third governance signal, a channel // `policy` of `deny`, is gated ahead of this whole branch.) if (floorActive) { if (floorVerdict.admitted) { return { outcome: { action: "normal_call" as const, isInbound: true }, resolved, }; } if ( !approvalCouldClearFloor || floorVerdict.reason === "member_blocked" || floorVerdict.reason === "member_revoked" ) { return floorDeny(floorVerdict); } log.info( { callSessionId: ctx.callSessionId, from: ctx.from, trustClass: actorTrust.trustClass, effectivePolicy: floorVerdict.effectivePolicy, }, "Inbound voice ACL: caller below admission floor, routing to guardian access request", ); } // Known caller whose channel hasn't passed verification yet — // mirrors the gateway's pre-intercept (twilio-voice-webhook.ts) so // calls slipping past it (e.g. canonicalization mismatch between // gateway and assistant DBs) still get useful guidance instead of // the "I don't recognize this number" name-capture script. const unverifiedStatuses = new Set(["unverified", "pending"]); const member = actorTrust.memberRecord; if (member && unverifiedStatuses.has(member.status)) { log.info( { callSessionId: ctx.callSessionId, from: ctx.from, channelId: member.channel.id, channelStatus: member.status, }, "Inbound voice ACL: known but unverified caller — returning verification guidance", ); return { outcome: { action: "unverified_caller", assistantId, fromNumber: ctx.from, displayName: member.contact.displayName, isGuardian: member.role === "guardian", }, resolved, }; } // Unknown caller — name capture flow log.info( { callSessionId: ctx.callSessionId, from: ctx.from, trustClass: actorTrust.trustClass, }, "Inbound voice ACL: unknown caller — entering name capture flow", ); return { outcome: { action: "name_capture", assistantId, fromNumber: ctx.from, }, resolved, }; } // Guardian verification challenge if (pendingChallenge) { return { outcome: { action: "verification", assistantId, fromNumber: ctx.from, }, resolved, }; } // Admission floor: deny member/guardian callers below the floor (e.g. // `guardian_only` denies a trusted_contact). if (!floorVerdict.admitted) { return floorDeny(floorVerdict); } // Guardian and trusted-contact callers proceed normally return { outcome: { action: "normal_call", isInbound: true }, resolved, }; }