Skip to content

Customer Authentication

Status: Current | Last reviewed: 2026-10-16

In live callbacks, a model-supplied consent="granted" is checked against caller evidence. A direct request to send the verification link/code can grant permission. A general affirmative answer must follow a message-permission question, using the recorded assistant/caller order; confirming a name or the phone's capabilities does not grant it. The check recognizes the authored consent question as well as the built-in wording. The bounded wait for a late final transcript applies the same check. Declines remain authoritative, and existing recorded consent is reused. Speculative/reasoning captions cannot replace the spoken question used for consent. The publisher binds a caller's final reply to the assistant words present when the caller started speaking. A subsequent “Thanks” acknowledgement cannot erase the permission question, including when that acknowledgement arrives before the final caller caption. New unfinished caller turns must settle before dispatch; interims and synthetic messages do not settle consent.

The usual final-transcript grace is 2.5 seconds. When a caller turn is already active at delegation, CUSTOMER_AUTH_ACTIVE_REPLY_GRACE_SECONDS allows up to 15 seconds for that answer to finish. GPT Live can delegate after “yes” while the caller continues with an identifier. The callback proceeds immediately when the final answer arrives and applies the same grant/decline check. An incomplete answer never grants permission. When native captions split “Yes, please send the link” from the caller's following identity details, the wait retains that completed permission as evidence. A neutral completed continuation preserves it; a refusal overrides it. An unfinished continuation still times out without sending. A name confirmation has no permission to preserve.

After a trusted verified result, all three authentication callbacks publish customer_auth_status before reading the active procedure's next step. When procedure correction is enabled, that authored guidance accompanies the tool result. This gives the actor its next question or action after verification, including a reused verification. It does not complete later steps or bypass their prerequisites. Pending, unsuccessful and consent-required results retain their existing guidance.

Customer Authentication is a per-agent capability that verifies the caller (the tenant's customer, not the platform user) during a conversation. Read this page when you touch the capability card, the conversation-api customer_auth package, the orchestrator's auth tools, the surface-app ceremony panel, or the schema behind them. For the end-user view, see the user guide page; for control mappings, see compliance/customer-authentication-controls.md.

Architecture in one line

conversation-api is the authentication authority (challenges, verification, lockout, audit, provider seam); the orchestrator exposes LLM tools that call it; surface-app is a thin share-code-bound passkey UI; ai-manager provides the capability card, publish validation, and the tenant settings page.

caller ── voice ──> ai-orchestrator ── LLM tools ──> conversation-api /conversations/{id}/auth/*
                        │  (surface-internal HMAC stamp)         │
                        │                                        ├── customer_auth_challenges (evidence)
                        │                                        ├── customer_auth_events (append-only audit)
                        │                                        ├── Redis lockout counters (cross-conversation)
                        │                                        └── session_variables + conversations.metadata (state)
caller ── SMS link ──> surface-app /s/{code} ── per-code routes ──> same service methods (WebAuthn ceremonies)
                                                                  └── inject → agent speaks the completion

Enabling the capability

Same mechanics as External Consult: the capability card (CustomerAuthenticationCapability.tsx) adds builtin:customer_authentication to the stage's config.tools[] and seeds config.builtin_tool_configs.customer_authentication. Unlike the single-owner capabilities it is not in SINGLETON_BUILTIN_IDS, so the prompt editor's dropdown offers its tools as {{TOOL:...}} tokens.

The disabled card must be render-only. Existing stages may legitimately omit config.tools; derive their empty tool-option list without writing a fresh array into effect-owned state, and key option-loading effects by tool-ID content rather than array identity. Otherwise opening the Behavior tab can create an effect → state update → render loop.

Two gates, both required at runtime:

  1. Per-agent — the built-in on the stage (enabled in config.py is presence in tools[]; there is no enabled field).
  2. Per-tenant — the customer_auth key in tenant_settings (Settings → Tenant Settings → Customer Authentication, customer-auth-settings.ts). A missing row is the disabled default. OIDC client secrets in it are AES-256-GCM under MASTER_ENCRYPTION_KEY.

A request against a conversation whose agent (or tenant) never opted in is a 409 (capability_disabled / tenant_disabled), not a 500 — the request is well-formed, the policy just doesn't exist.

Which version's config the policy comes from. load_node_policy resolves the stage config through NODE_POLICY_SQL (config.py), and two properties of that query are load-bearing because it fails closed: any miss returns NodeAuthPolicy(), so every auth tool answers 409 capability_disabled — the same answer as a capability the author never enabled, so the symptom points at the config and never at the query. It must split_part(c.agent_id, ':', 1) (a conversation started from the editor stores 3032:draft; agent_versions is keyed on the bare id) and pin to conversations.config_version (the version the orchestrator actually built the session from), with published-first surviving only as the fallback. Full rule + the two sibling sites in conversation-api.md.

The three methods and what each proves

Method Assurance What it proves Notes
Passkey via multimodal link aal2_pr Possession of a WebAuthn credential + on-device biometric/PIN Phishing-resistant; the only single method that satisfies AAL2
OTP to phone/email aal1 Possession of the delivery channel SMS is a NIST 800-63B restricted channel; ANI-defaulted delivery proves possession of the calling number, which is spoofable
Tool-based KBA not_a_factor The tenant's system of record confirmed the caller's answers Never a factor alone (FFIEC); composes with a possession factor to reach aal2

Composition lives in assurance.py: methods are grouped into factor categories (two OTPs are one factor), compose() derives the achieved level, and satisfies() compares it against the agent's required_assurance. aal2_pr outranks aal2, so a passkey satisfies an aal2 requirement.

Preview simulation

The capability's passkey.simulate_in_preview switch defaults to false. On passkey link issuance, conversation-api selects simulation only when that setting is exactly true, delivery is demo_notification, and the persisted conversation belongs to the tenant, has is_test=true, and is active. A real SMS or live call uses the existing WebAuthn path even if the saved switch is on.

For Connect Telephony Preview through Voice Connector → ARI, the server-owned contact binding supplies both demo delivery and runtime source ai-manager-telephony-preview. Conversation persistence classifies that source as a test without allowing draft routing. An inbound caller-supplied source cannot grant this classification. Without the persisted test flag, a demo SMS still contains a real passkey link that opens a separate browser tab. See Telephony Preview.

Simulation challenges carry metadata.purpose='preview_simulation'; the share code and demo notification carry auth_mode='preview_simulation'. The shared phone's requiresTopLevelBrowser keeps these links in its Safari renderer. PhoneCustomerAuthSimulation shows a labelled Face ID rehearsal and calls Studio's authenticated, phone-owner-checked POST /api/phone/conversations/{id}/auth/preview proxy after the scan animation. The proxy signs a conversation-bound request to POST /conversations/{id}/auth/preview/complete. The auth authority rechecks the node/tenant policy, active test classification, link tenant/conversation, challenge purpose, delivery mode, expiry, and single-use claim. The SQL claim also checks active test status atomically. No request field supplies a verification outcome.

Completion persists status='verified', simulated=true, satisfied=true, and assurance_level='not_a_factor' for the test conversation, emits Identity verification simulated, and uses the existing completion injection to let the agent continue. load_state derives this outcome from the completed simulation challenge and rechecks the stored test classification. Simulated challenges are excluded from verified_methods_for_conversation alongside enrollment challenges, and simulated state cannot authorize real passkey enrollment. The rehearsed passkey method in the runtime response preserves the existing message-consent behavior; it does not claim cryptographic evidence.

After the server confirms the simulated result, the phone stays on the multimodal landing page when that capability is enabled; auth-only tests return to the call. Cancellation clears the animation timer; failures show a retry action. The UI remains in the phone's existing recording composition, including Demo Mode. Service regressions are in test_customer_auth_passkey_service.py; the real SQL is exercised against temporary tables by test_customer_auth_preview_store.py. Studio's e2e/customer-auth-preview.spec.ts checks API access, the in-phone recording composition, successful completion, retry, and cancellation without requesting WebAuthn or opening another tab.

Idempotence and the state of record

Verification happens at most once per conversation. The guarantee is server-side, not prompt-side: issue_otp / issue_passkey_link / verify_otp / verify_passkey all short-circuit on an existing grant before any side effect. Passkey issuance has an additional pending-state guarantee: when a repeated call supplies no explicit replacement target, it returns the still-live challenge's share code and sends nothing, so provider retries or a confused model cannot put two plausible bearer links on the caller's phone. An explicit target remains the correction path for a mis-addressed first request.

State lives in three places with different jobs:

  • customer_auth_challenges rows are the evidence. load_state recomputes the achieved assurance from verified_methods_for_conversation + compose() on every read — the session_variables blob is writable via the /variables and /context routes, so a blob claiming aal2 is never trusted. Only customer_ref (display convenience) comes from the blob.
  • session_variables['customer_auth'] (JSON blob) + the flat customer_auth_status mirror are the runtime cache the prompt reads. {{customer_auth_status}} resolves the flat variable with no JSON parsing. The variable is published on every auth tool result (publish_auth_status_variable) — and, since 2026-09-01, also when the out-of-band ceremony completes: the trusted customer_authentication injection triggers _refresh_auth_status_variable (tool_wiring's mark_external_flow_activity), which re-reads the auth authority and publishes the fresh state. Without that refresh the variable sat at pending after a passkey ceremony while the injected notice told the model the caller was verified, so every available_when gate on == "verified" kept vetoing and the agent re-verified an already-verified caller.
  • conversations.metadata.customer_auth is the durable mirror a transcript review reads after session-variable cleanup. Status/method/assurance/timestamps only — never a code, an assertion, or a full target.

Orchestrator runtime

customer_auth_tool_helper.py registers three tools when the stage carries the built-in:

  • authenticate_customer(method?, channel?, target?, consent) — start or report verification. Idempotent. The model-facing method enum contains only the node's enabled initiable methods, and the OTP channel enum contains only enabled channels. Omitting them selects the first/highest-priority configured value. A provider that nevertheless submits a disabled value is confined to that default before the consent gate, so it cannot ask about one method and then fail against another. consent is required in the model-facing schema so a streaming model cannot call first, receive consent_required, and repeat the question; see Consent to be messaged.
  • verify_otp_code(code) — forwards the spoken code verbatim (the server normalizes digits-as-words); refuses an empty code locally without burning an attempt.
  • get_customer_auth_status() — cheap read for prompt branching.

When an AAL2 policy offers passkeys, the generated system guidance orders the model to start with method='passkey'. A returning caller's existing credential satisfies AAL2 on its own. For a first-time caller, bootstrap_required tells the model to wait while the same secure link handles enrollment and assertion; it must not issue a second message or an automatic OTP. OTP remains available only when the caller explicitly requests it and the authored policy permits it. A satisfied=true or status='verified' result is terminal for the call, so the model must not issue another factor.

The tool names are duplicated in CUSTOMER_AUTH_TOOL_NAMES — node-builtins.ts (prompt dropdown) and the helper (runtime registration) — and must stay identical: token_resolver.py leaves an unknown {{TOOL:...}} literal, so a rename silently turns a designer's prompt into text the model reads aloud. test_customer_auth_kba.py asserts the lockstep.

The model can never assert verification. No tool sets state; the inputs are "send something" and "here is what the caller said". No tool schema exposes a verified/status/aal parameter (also asserted in the tests).

Auth to conversation-api is the surface-internal HMAC stamp (conversation_api_internal_headers) — the same credential the A2UI tool uses for multimodal SMS. Its authority is being bound to one conversation, which is exactly the scope these routes need.

Both a passkey link and an SMS/email OTP put something on the caller's phone, which makes authenticate_customer a send before it is an auth step. Two capabilities do that on a voice call — this one and the A2UI multimodal handshake — and they share one consent fact per conversation, held in a2ui_tool.py (grant_message_consent / has_message_consent / message_consent_source on MultimodalState). The caller is asked once, by whichever capability gets there first; the other sends silently.

authenticate_customer resolves consent before any HTTP call, in this order — local record, the latest genuine caller utterance, then the auth authority's own rows. The required model consent argument is a claimed decision, not evidence: when the runtime has caller speech, consent='granted' is accepted only if those words are affirmative or directly request/expect the send. A neutral latest utterance returns consent_required; a negative one becomes declined. This prevents a provider from sending first and asking permission afterward. One timing subtlety is load-bearing: on S2S providers the model's tool call races the caller's final transcript by a fraction of a second, so at call time the recorded "latest" utterance is often still the previous turn — judging that alone re-asked a caller who had just said "sure, go ahead" (the observed 3032 double-ask). When the model asserts granted but the recorded words don't grant it, the gate now waits out that race — re-reading the shared utterance for up to CUSTOMER_AUTH_CONSENT_GRACE_SECONDS (default 2.5 s) and settling on whatever the late reply actually says (grant, decline, or a subject-change → still consent_required). A grant that never arrives is still refused, so the security posture is unchanged; the window only runs on the claimed-but-unconfirmed path, so ordinary turns pay nothing. The gate permits only one consent_required retry per registered session. If caller-turn evidence is still unavailable on the retry, it returns terminal message_consent_evidence_unavailable, sends nothing, and instructs the model to continue without verification instead of asking “yes” forever.

Situation Result handed to the model
Consent already held (any source) proceeds
consent='granted' and latest caller words grant the send proceeds, records source customer_auth
consent='declined' status=consent_declined, code=message_consent_declined, next_action=continue_without_verification — do not retry
A completed passkey/otp_sms/otp_email challenge on this conversation proceeds, records source customer_auth_verified
Nothing known status=consent_required, code=message_consent_required, consent_prompt, next_action=ask_consent_then_retry — nothing was sent

Refusing before the HTTP call is the point: no challenge is minted, no send budget is spent, and the caller can be asked and the tool retried in the same breath. The remote read is last so the ordinary "they just said yes" turn pays no round trip.

The inference direction that matters for the caller's experience is the other one: a completed texted ceremony IS consent. Someone who opened a link we texted, or read back a code we texted, has demonstrated agreement more convincingly than any spoken "sure" — so a later multimodal card must not ask again. sync_message_consent_from_auth adopts a verified state whose methods include passkey, otp_sms, or otp_email (CONSENT_PROVING_AUTH_METHODS). kba and oidc send nothing and can never stand in for consent. A pending challenge is not consent either — a link nobody opened proves nothing.

The question itself can be authored with consent_prompt on builtin_tool_configs.customer_authentication (Ask permission to send in the capability form). When it is blank, the runtime words it from the already-resolved method and channel: secure link for passkey, texted code for SMS OTP, or emailed code for email OTP. The generated prompt puts that exact question in the agent's first authentication response, then tells it to wait and call the tool with consent='granted' only after the caller agrees. A direct response that clearly expects the send—such as “did you send it?” or “where is the link?” immediately after that question—also counts as agreement; the agent sends rather than asking again. This ordering matters for streaming S2S providers: returning consent_required after the provider has already spoken can leave the result waiting for another caller turn. The tool schema therefore requires the decision, while the callback retains the same consent_prompt fallback for malformed or legacy calls. The field is in the generated config schema like every other key — see config-jsonb.md.

Auto-grant: a text channel (set_text_channel) records consent on sight — the chat window is the surface, so auth never invents a permission question on a chat conversation.

Suppressing the A2UI link is NOT an auto-grant. set_sms_suppressed says only "don't send a share link" — the visual surface is already attached to this session, so there is nothing to send. It says nothing about authenticate_customer, which texts a secure identity link on the same conversation regardless. Auto-granting consent there deleted the one question the caller is owed, and it did so in Agent Studio's emulator — the only surface where an author ever rehearses the flow — so the gate was live everywhere except where it gets tested. The rule is the narrow one: a suppressed link needs no consent because nothing is sent; anything that does send still asks. The emulator now runs the real handshake, with only the carrier simulated (demo_notification puts the message in the test phone's tray instead of on the wire).

The record is deliberately per-process, like connected/sms_sent beside it: it is the turn-by-turn memory of what was said out loud. ConversationAPIPublisher also keeps the latest genuine caller utterance in the shared runtime context (never a synthetic UI/platform message). The publisher imports ai-orchestrator's own generated Conversation API client; it must not prepend apps/conversation-api to sys.path, because the two deployable services may use different grpcio-tools versions even though their wire schema is identical. A generated-client/runtime mismatch disables both transcript streaming and the caller-turn evidence used here. If an S2S provider violates the required schema and omits consent, the callback conservatively recovers only explicit answers or direct send-expectation phrases; an unrelated or unclear reply still returns consent_required. The durable half is the challenge rows, re-derived through sync_message_consent_from_auth, so a session that moves between orchestrator pods re-asks at worst and never wrongly authorizes a send. Every read of the auth authority fails open to "unknown", which is treated as not granted — conversation-api stays the only security boundary; all of this is UX.

Tests: test_message_consent.py (both A2UI execution paths + the auth gate). The transcript-order regression exercises the publisher and registered auth callback together, including acknowledgements before/after the final answer, refusals, unrelated questions and unfinished turns.

KBA is server-attested

customer_auth_tool_handler.py is a pure frame observer appended after the LLM service. It watches FunctionCallResultFrames for the tools named in kba.tool_refs (UUIDs resolved to function names by resolve_kba_tool_names, deduped case-insensitively because the observer matches that way) and reports each outcome to POST /conversations/{id}/auth/kba-result. The KBA tool signals a pass with {"kba_verified": true, "customer_ref": "..."} in its JSON result.

The contract is strict: only a JSON true (or the string "true", which a response_path extraction produces) is a pass. bool() coercion would turn "no"/"pending"/"failed" into granted factors — this was a real defect, fixed with a table-driven test. Anything else, including a missing key or an unparseable result, counts as a failed check. Each tool_call_id is counted exactly once (provider retries re-emit result frames), every frame is forwarded unchanged in its original direction, and observation can never interrupt the stream (a raise inside the observer is logged, the frame still forwarded). The audit detail carries only {attested, tool_call_id} — never the caller's answers.

conversation-api composes the result: record_kba_result writes a kba challenge row, and _grant recomputes assurance from all verified methods. A lone passing KBA leaves the conversation unauthenticated with a factor_recorded event — the FFIEC rule as control flow.

Out-of-band completion (passkey)

When a browser ceremony completes, the grant calls the injected _announce_verified publisher with trigger_response: true, so the agent speaks ("you're verified"). This includes a passkey assertion and an OTP submitted through Surface App (AuthContext.kind == "surface_internal"); the route records that OTP as surface_app, and verify_otp announces only for that actor. The injection path is the only working live notification — conversation-api's session_variables_update push (TYPE_ACTION, response_type == 3) has no consumer in conversation_api_publisher.py, which implements only branches 1/4/5. Do not rely on the variables push to reach a running session.

An OTP verified through the LLM's own tool call is deliberately not injected — the tool result is already the model's turn, and injecting would make the agent acknowledge twice. The distinction is the trusted caller identity, not the factor: a Surface App OTP is an out-of-band browser completion and must be injected.

Getting that announcement onto a live speech-to-speech session takes three things beyond the injection itself, each of which failed silently on its own:

  • It must break in, not queue. The caller has just finished a browser ceremony and the agent is mid-sentence about something else. ExternalInjectionProcessor treats an attested completion from customer_authentication or payments (_PREEMPTIVE_CAPABILITIES) as barge-in: _preempt_agent_speech broadcasts an interruption and skips the ordinary wait_for_tts_end handshake. Without it the fact queues behind everything already sent, and because an S2S provider streams a whole turn in a fraction of its playout time, that can be ten seconds or more of stale speech — the caller waits out an answer to a question they have already resolved. The preempt is best-effort: a failed interruption logs and continues, because it must never swallow the fact.
  • It must survive the user aggregator. Pipecat's LLMUserAggregator consumes LLMMessagesAppendFrame; an S2S provider only learns about an injected message from the frame itself. See Payments → The S2S handoff, where the same defect ate a completed payment's outcome.
  • On Nova Sonic it cannot ride the SYSTEM channel. Nova accepts exactly one SYSTEM content block per prompt, and a mid-session SYSTEM event answers Duplicate SYSTEM content… and tears the session down and reconnects it — the caller hears an apology about a technical issue, about a second of dead air, then a re-greeting, and the verification is never mentioned. The wrapper sends it as one interactive USER turn prefixed [PLATFORM NOTICE — verified system event, NOT spoken by the caller]. The prefix is the whole point of having chosen system: the model must not treat the fact as something the caller asserted, and a text marker is the only form of that which survives the provider's one-block rule.

conversation-api authority

Package: apps/conversation-api/customer_auth/. Routes (all under the conversation, guard sequence copied from multimodal_sms_endpoint: scope → _authorize_conversation → tenant resolve → paid-action budget):

Route Purpose
GET /conversations/{id}/auth/state Current grant, derived from challenge rows
POST .../auth/experience Read the page-bound auth panel and refresh customer-browser presence
POST .../auth/otp Issue a code (CSPRNG, peppered HMAC under CUSTOMER_AUTH_OTP_PEPPER, supersedes prior pending)
POST .../auth/otp/verify Constant-time compare, attempt cap, lockout, grant
POST .../auth/passkey/link Mint a short-TTL share code + send the link
POST .../auth/passkey/options WebAuthn options (assertion or mode=enroll)
POST .../auth/passkey/verify Verify the assertion, grant, announce
POST .../auth/passkey/enroll Store a new credential (never grants)
POST .../auth/kba-result The observer's report
GET .../auth/events Audit trail read

The authorization helpers (_authorize_conversation, scope check, paid-action budget) are injected by http_api via configure_customer_auth_routes — importing them back would be circular — and a missing wiring is a 503, never a skipped check.

SMS selection is explicit at both configuration levels. The node form writes otp.sms_integration_id and passkey.sms_integration_id; an absent value inherits the tenant Customer Authentication default. Both selectors query /api/integrations?capability=sms, rather than matching an AWS type-id string, so any active integration source that advertises SMS is eligible and the selected tenant remains the isolation boundary. The shared IntegrationSelector disables its usual auto-pick behavior on these two fields because None is a meaningful inheritance choice.

delivery.py reuses the multimodal SMS machinery — and its module docstring is the authority on a subtle point: multimodal SMS does not "go through async-agent" from the runtime's point of view. The orchestrator calls conversation-api with the HMAC stamp; conversation-api owns delivery. Two modes:

  • demo_notification — writes a demo_notifications row + publishes to demo:notifications:{session} (the phone emulator's SSE stream). Self-contained; what the emulator exercises.
  • real_sms — forwards to async-agent's POST /api/v1/sms/send through the shared async_agent_sms.py seam, passing the caller's own Authorization when it has one. A stamped caller has no bearer to forward, so real SMS needs ASYNC_AGENT_API_TOKEN on conversation-api. Real email has no async-agent HTTP route at all and returns an explicit "unsupported".

real_sms is the default, and Vanguard has no SMS integration — so a Customer Authentication turn there fails with delivery_failed (a 502 out of async-agent) unless the session carries x-multimodal-sms-delivery: demo_notification in its metadata. Agent Studio Preview stamps that demo mode on every call by default, not only when Multimodality is enabled. Its environment-gated SMS instead stamps real_sms and refuses to start until the tester supplies an ANI, which lets the same auth flow reach a real handset. x-demo-notification-session may override the notification stream key; when absent, delivery uses the conversation id, which is the stream Agent Studio's phone emulator follows. That is not a workaround for a broken environment: demo_notification is the only mode that leaves the code anywhere readable, which is what makes the challenge-response beat testable at all. An automated test must read it back from demo_notifications after the agent's tool call (the code is minted per call, so reading earlier gets the previous run's) and speak or type it as its next turn — nothing in this system ever logs an OTP, deliberately.

One seam sends the SMS, because sharing the config is not sharing the code

Customer Authentication and the multimodal A2UI handshake both text a link, and they always shared the configuration — same ASYNC_AGENT_API_TOKEN, same route, same AWS SMS integration, same phone number. They did not share the sending code, and that is why on one telephony call the auth link arrived and the multimodal link did not. delivery.py had a bearer fallback (caller header → ASYNC_AGENT_API_TOKEN) and read the HTTP status; multimodal_sms_endpoint forwarded only request.headers.get("authorization") — and the orchestrator calls conversation-api with the internal HMAC stamp, which carries no bearer at all — then read only the response body, so async-agent's 401 {"detail": "Tenant-scoped token required"} surfaced to the model as "SMS send failed: Unknown SMS error" and it retried the same failing call.

Both callers now go through async_agent_sms.py (async_agent_bearer + send_sms_via_async_agent → an SmsSendOutcome). Four properties, one per way it went wrong: the caller's own header wins when there is one (the token that authorized the request is the token that pays for the SMS); an absent header falls back to ASYNC_AGENT_API_TOKEN, which is the only way an internal-stamp caller reaches real SMS; neither sends nothing and returns a refusal that names the missing env var (async-agent's own answer talks about tenant scoping, which reads as a tenant problem rather than as this service having nothing to present); and a failure reports the real reason — error → detail → HTTP {status} — so the model never sees "Unknown SMS error" again. Transport faults are reported, never raised, because both callers hand the reason to a model that can retry. Regression: test_async_agent_sms.py, whose last test is a deliberate text read asserting that both call sites import the seam and neither builds the URL itself — the defect was two copies, so the thing worth pinning is that there is one.

Which id the share code is minted against

The link is a share code, and conversation_share_codes.conversation_id is a uuid FK — while the orchestrator mints on a telephony call by session_id, which for an Asterisk leg is a channel id like 1788188503.4. Passing that verbatim made asyncpg refuse the argument before the statement ran, mint_auth_share_code swallow the driver error, the tool answer delivery_failed, and the challenge row close as expired with {"reason": "delivery_failed"} — the agent then said "I wasn't able to send the verification link" on a call where the ANI had resolved perfectly (target_masked '•••• 8211'). Every read path in the service already used the tolerant id::text = $1 OR session_id = $1; the write path did not.

db_queries.create_share_code now resolves the id itself, which covers all three minters (auth links, POST /share-codes, multimodal SMS) at one seam — an id matching nothing raises ValueError naming it, rather than a DataError about UUID lengths that reads identically to a shape bug. The two directions are separate helpers on purpose, because the capability tables go the other way: canonical_conversation_id normalizes to session_id, since customer_auth_challenges/_events match conversation_id as exact free text and the injection that tells a live agent "they verified" publishes to Redis inject:{conversation_id}, which the orchestrator subscribes to under its session id. Both _guards (customer-auth and payments) canonicalize at the request boundary, because the two ends of one ceremony arrive addressed differently — the orchestrator issues by session id, surface-app's proxy forwards the uuid its share code resolved to. Browser sessions use one uuid for both addresses, which is why none of this ever bit Preview. Regression: test_share_code_conversation_address.py.

Lockout (cross-conversation)

The per-challenge attempt cap is bounded by the conversation; lockout.py is the layer that stops hang-up-and-retry. Redis counters keyed on tenant + peppered target fingerprint (a keyspace dump does not enumerate who was challenged): FAIL_LIMIT failures in the window lock the target for LOCK_SECONDS; a separate send budget caps SMS spend per target. The failure-mode asymmetry is deliberate and tested: issuing fails closed on a Redis outage (unthrottled guessing against a six-digit code is a control bypass), verifying fails open (the challenge row's own cap still bounds it; denying would turn a cache outage into "nobody can authenticate"), counting and clearing are best-effort.

Passkey ceremony authorization

verify_passkey layers three independent checks around the signature, each answering a different question: the signature proves possession of a key; the credential row's tenant_id proves whose key it is (the only place cross-tenant can be caught — get_credential_by_id cannot be tenant-scoped because the browser sends only the credential id); the challenge proves which conversation it was asked for. Unknown credential, cross-tenant, and wrong-subject all return the same vague message and code (the distinguishing reason lives only in the audit row) so the endpoint is not an oracle.

Enrollment vs authentication is a purpose metadata convention on the challenge row: issue_passkey_link writes no purpose key and _load_passkey_challenge defaults to "", while enrollment rows carry purpose: "enroll". Adding any purpose key to the link's metadata would 409 every authentication ceremony — test_customer_auth_passkey_service.py locks the convention in. Enrollment is gated on identity proofing: an existing conversation grant, a successfully verified OTP bound to the customer, or—only when the customer has no registered credential—the still-live SMS auth link. Direct enrollment records the source challenge id and requires the same share code again at registration, so another link cannot authorize it. Enrollment never grants or contributes a factor itself: verified_methods_for_conversation explicitly excludes purpose='enroll', and the browser must assert the newly stored credential against the original authentication challenge afterward.

Clone detection: only a positive sign_count is held to monotonicity (0 means the authenticator keeps no counter — the common case for platform passkeys); a positive counter that fails to advance is refused as sign_count_regression.

Error shape

CustomerAuthError carries status_code, code, retry_after. FastAPI nests it: routes.py raises HTTPException(detail={"error", "code", "retry_after"}), so the wire body is {"detail": {...}}. The orchestrator's _error_payload unwraps detail first; surface-app's readers check body.detail.code with a top-level fallback. retry_after is echoed both in the body (the LLM speaks it) and as a Retry-After header.

surface-app ceremony

A passkey link resolves through the existing share-code flow — the browser never holds a conversation id; the per-code server routes under app/api/share/[code]/auth/ (passkey/options|verify|enroll, otp/verify) proxy to conversation-api, so conversation binding is structural. customer-auth-panel.tsx renders when the share session carries session_params.auth_mode, drives @simplewebauthn/browser, and offers OTP numeric entry as the fallback.

The passkey options effect stores the in-flight preparation promise, keyed by share code. This is required by React development Strict Mode: its setup → cleanup → setup replay cancels the first continuation, so an alreadyRequested boolean causes the second setup to exit and strands the panel on “Preparing secure verification…”. Both setups now await one request; only the live continuation commits it. Browser-capability detection and the options request run together so the page still learns whether OTP is actually allowed before rendering a fallback. A passkey-only agent therefore never displays code entry or tells the caller to ask for a code.

Authentication and registration are separate WebAuthn ceremonies: credentials.get() cannot create a credential. When assertion options report known_credentials: 0, surface-app therefore does not open the native authentication dialog (which only says “You don't have any passkeys saved”). If enrollment is allowed, it opens Create your passkey directly. Save a passkey runs credentials.create() under the proof carried by the short-lived SMS link; no OTP is generated, embedded in the URL, or sent as a second message. The stored enrollment provenance remains the bounded label customer_auth (enrolled_via is varchar(20), so workflow prose must never be written there). If registration cannot be persisted, Try saving again requests a fresh one-time enrollment challenge; the failed challenge remains consumed to preserve replay protection. Verify with your new passkey then runs credentials.get() against the original challenge; only that assertion grants the passkey factor and finishes AAL2. Later calls start at the ordinary assertion path. The older verified-OTP enrollment route remains valid when OTP is independently selected and allowed, but it is no longer an automatic passkey bootstrap.

The phone emulator is a special host for this link. Its Safari normally ignores the tapped multimodal URL and renders the active conversation's A2UI surface directly in React. An auth link emits no A2UI surface, so routing it through that path yields a blank/"waiting for a component" screen. Demo notifications already carry the trusted discriminator metadata.purpose='customer_auth'; requiresTopLevelBrowser uses it to open the existing Surface App ceremony in a top-level browser tab. This is intentional: it avoids a second auth implementation and lets WebAuthn reach the real platform authenticator. The simulated call remains connected, while ordinary multimodal_sms links remain in-tree.

One customer page for authentication and multimodal content

When both capabilities are enabled on the conversation's pinned agent version, they share one browser page in both real_sms and demo_notification delivery:

  • Multimodal first: send_auth_link reuses an open multimodal share code for the same recipient and delivery mode. It attaches the short-lived passkey challenge to that code and returns delivery_status=connected; no SMS transport is called. The existing page shows the platform-owned auth panel over its mounted components. Successful server verification dismisses the panel without losing form state.
  • Authentication first: the first link carries session_params.customer_experience=true and the normal 24-hour multimodal page lifetime. Authentication still uses the configured short challenge TTL, including first-use enrollment proof. After the authority confirms the required assurance, the panel disappears and the same URL shows the multimodal landing page. A later multimodal handshake returns sms_status=connected, so it does not send another link.

POST /conversations/{id}/auth/experience accepts only the page's share_code. The normal tenant/conversation guard runs first, then the service checks the code against both the UUID row address and runtime session address. It returns that page's auth_mode, challenge_id, multimodal_enabled, and authority-derived status. Both browser hosts poll every four seconds. A per-code Redis heartbeat expires after 15 seconds; a generic transcript/SSE subscriber never counts as a reusable customer page. Recipient matching uses a fingerprint (older multimodal rows can use their stored recipient). Closed, expired, revoked, wrong-recipient, different-delivery-mode, and ended-conversation pages are not reused; a page too close to expiry cannot host a new auth challenge. A presence-store outage falls back to ordinary delivery.

The Surface App proxy accepts no browser-selected target or conversation. Its modal traps focus and leaves the underlying component tree mounted. Studio's PhoneCustomerExperience consumes the same state through the authorized phone proxy: opted-in Preview simulation stays inside Safari and returns to the multimodal landing page. Real WebAuthn still uses the top-level Surface App; if requested after opening native emulator Safari, Continue verification opens that same share URL. Auth-only agents retain the completion screen/return-to-call flow. OTP delivery remains a separate one-time-code message, not a code revealed inside the page.

The agent must still never generate an A2UI authentication card. Both A2UI execution paths retain their pending-auth guard to avoid starting a parallel link while verification is underway. Tool responses distinguish a reused page from an actual SMS; a replacement link, when needed, still uses the existing once-per-call message consent.

Regressions: test_customer_experience.py covers both transports and recipient/lifetime boundaries; Surface App's customer-experience-auth.test.tsx covers server-controlled panel removal and refresh; Studio's e2e/customer-experience.spec.ts runs both orders in the real phone components with one mocked notification and preserved component state.

An out-of-band success is published on inject:{conversation_id} as a trusted source_capability=customer_authentication SYSTEM event with trigger_response=true. The live injection processor resumes the named idle suspension and resets its retry budget before dispatch. Context-based providers receive the system append normally; Nova Sonic appends it as SYSTEM content and sends a separate fact-free interactive wake-up turn because a system append alone does not prompt live audio. The verification fact is never converted to a user-role assertion. If Redis reports zero live subscribers the grant remains durable and the service logs that nothing could be spoken; otherwise the agent acknowledges the result without waiting for the caller to say they finished.

RP policy is webauthn_ops.py: the RP ID is surface-app's hostname (port dropped; CUSTOMER_AUTH_RP_ID may override only to a registrable suffix of it), expected origins are exactly the configured origin (loopback siblings added only when the origin is itself local — a production verifier must never accept a dev-machine assertion).

Publish validation

validate-node-capabilities.ts: ≥1 method; error on aal2 with no path to two factors (KBA-only never satisfies it unless counts_as_factor composes with a possession method); warning on SMS-OTP as the sole AAL2 route (restricted channel); KBA tool_refs must exist in the stage's config.tools[]. Runtime re-clamps everything anyway (parse_node_policy — the authored value is a request; the bound is law), so a config written by hand or by the generation MCP cannot widen the OTP TTL past the 800-63B ceiling.

Schema

Migration 027_customer_auth.sql (also in the migrate-city-db.sh Phase C loop): customer_identities, customer_webauthn_credentials (globally-unique credential_id, by design — see above), customer_auth_challenges, customer_auth_events (append-only via trigger; INSERT-only by contract), all tenant-RLS'd; cleanup_expired_auth_challenges(); and a fix for the pre-existing session_variables_source_check gap (share_code was inserted but not allowed). See docs/06-schema/tables.md.

Testing

The two apps run tests differently — this is deliberate, not drift:

# conversation-api: plain-assert suites with a __main__ runner
# (the venv has asyncpg + webauthn but NO pytest; system python has pytest but no asyncpg)
cd apps/conversation-api
.venv/bin/python test_customer_auth_otp.py
.venv/bin/python test_customer_auth_assurance.py
.venv/bin/python test_customer_auth_webauthn.py
.venv/bin/python test_customer_auth_lockout.py
.venv/bin/python test_customer_auth_passkey_service.py
# needs a reachable DATABASE_URL (they execute the real SQL); SKIP loudly without one
.venv/bin/python test_customer_auth_node_policy.py
.venv/bin/python test_share_code_conversation_address.py

# ai-orchestrator: ordinary pytest
cd apps/ai-orchestrator
.venv/bin/python -m pytest tests/test_customer_auth_kba.py tests/test_message_consent.py

ai-manager: npx tsx --test lib/validate-node-capabilities.test.ts.

See also

The shared PhoneCustomerAuthPresentation uses Identity Verification as its heading in both Phone Preview and Director Scenes. The visible label does not alter simulation gates or authentication transport.