ADR-0048 — Voice call lifecycle: keep the live turn in PropFlow, move the post-call pipeline to Temporal
Status: Proposed (design spike — answers the open question, recommends a conditional direction, does NOT authorize a greenfield build)
Date: 2026-06-07
Deciders: Gera, Fede
Related: ADR-0025 (Temporal for renewals), ADR-0026 (PII boundary), ADR-0031 (maintenance-on-Temporal), src/lib/temporal/README.md (naming standard), docs/planning/clara-temporal-bridge/README.md (the N1 latency argument)
The open question
Should the voice call lifecycle — ring → personalization → Triage transfer chain → mid-call tools → call-ended → post-call processing — become a single unified Temporal workflow, mirroring the renewal / maintenance / tour workflows?
Context
A voice call has two physically different halves with opposite requirements:
The live turn (synchronous, latency-critical). The personalization webhook (
/api/voice/personalization) runs during the ring; the tool webhooks (/api/voice/tools/[tool]) run mid-utterance. ElevenLabs holds the call open waiting for these responses — they must return in well under a second. This is request/response, not orchestration.The post-call pipeline (asynchronous, durable-work).
/api/voice/call-endedtoday runs a multi-step pipeline inline in the webhook handler:generateAndSendRecap(recap SMS/notification)maybeCreateWorkOrderFromTranscript(LLM extracts WO details →handleCreateWorkOrder)resolveProspect(prospect linkage)maybeSendRenewalRecapSmscaptureVoiceToolErrors
(Unknown-caller capture, shipped in #1993, is a mid-call tool — not a
call-endedstep — and a separate offline backfill reprocesses historical transcripts. It is therefore out of scope for the post-call pipeline below.)Each step does real I/O (LLM, DDB, Twilio, L4). If the handler throws partway, or ElevenLabs' call-ended POST times out, the remaining work is silently lost — there is no retry, no durable record of "this call still needs its WO extracted."
The existing guidance is explicit (clara-temporal-bridge/README.md §N1): "Temporal is durable-async orchestration; a worker round-trip + workflow scheduling is hundreds of ms to seconds, and Temporal is not designed to sit synchronously inside a sub-second voice turn." A bug like the {{pm_phone}} live-transfer failure would not have been caught by — and must not be wrapped in — a Temporal activity.
Decision
Reject the "unified workflow" framing. Split the boundary at call-ended.
The live turn stays in PropFlow, synchronous. Personalization + mid-call tool webhooks remain plain request/response handlers. No Temporal in the call path. Correctness for the live turn is a thin deterministic guard at the tool boundary (Zod-validate args, resolve
{{vars}}to values, reject out-of-enum) — already the chosen N1 fix, not a workflow.The post-call pipeline becomes a durable
voiceCallWorkflowwhen its reliability warrants it (see "Conditions" — not now). Shape mirrors maintenance-on-Temporal (ADR-0031):call-endedbecomes a thin signal: it persists the raw transcript + call metadata to a DDB scratch row (1h TTL) and firesstartVoiceCallWorkflow(conversationId)fire-and-forget, then returns 200 immediately. No inline LLM, no inline sends.voiceCallWorkflow(workflowIdvoice-<conversationId>— the<domain>-<conversationId>form matchingmaintenance-<conversationId>, no redundantcallsegment; task queuepropflow-voiceregistered alongsidepropflow-renewal/propflow-maintenance/propflow-tourinside the existing prod Temporal namespace — never a new namespace; seedocs/architecture/TEMPORAL_INFRASTRUCTURE.md) runs the pipeline as retryable activities, one per current inlinecall-endedstep:extractWorkOrder,sendRecap,resolveProspect,sendRenewalRecap,captureToolErrors. Each activity is idempotent with an explicit idempotency key; the workflow owns retry policy + durable record of in-flight post-call work.
Naming (per src/lib/temporal/README.md): the workflow is named by domain — voiceCallWorkflow — never by persona ("Clara…"). Drift-guarded by temporal-naming.drift.test.ts.
PII boundary (per ADR-0026): activity args carry only (conversationId, runId) + counts/enums. The transcript, tenant names, and phone numbers ride the DDB scratch row (1h TTL) — never workflow history (which stores activity payloads in plaintext). A new activity input field carrying a transcript string is a leak and must be rejected in review.
Conditions — when to actually build this (NOT yet)
This ADR records the direction, not a go. Build it when one of these is true (per the no-premature-infra rule + small user base):
- The inline call-ended pipeline starts losing work in prod (a WO that should have been extracted wasn't, because the webhook 5xx'd or threw past the first step) — i.e., a real reliability incident, the same trigger that justified renewals (ADR-0025) and maintenance (ADR-0031).
- We need a durable post-call timer (e.g., "send the recap 10 min later", "if no human follow-up in 24h, escalate") — Temporal's
sleep/conditionis the right primitive; a cron is the wrong one. - Post-call observability (per-step success/failure across the fleet) becomes a recurring debugging need.
Until then, the inline pipeline is adequate and the cheapest correct option. Do not stand up a worker + queue speculatively.
Consequences
If/when built:
- ✅ Post-call work is durable + retried; no more silent loss on a webhook timeout.
- ✅ Symmetric with renewals/maintenance/tours — same worker fleet, same naming + PII discipline, same operational playbook.
- ✅
call-endedbecomes trivially fast + reliable (persist + signal + 200), removing webhook-timeout risk entirely. - ⚠️ A new task queue (
propflow-voice, in the existing prod namespace — not a new namespace) + worker registration to operate; new search-attribute registration if Cloud-UI filtering is wanted (mirrorsMAINTENANCE_SEARCH_ATTRS_ENABLED's "registerconversationIdfirst" gotcha). - ⚠️
call-endedcan fire more than once per call (ElevenLabs retries) → the signal + every activity must be idempotent onconversationId. - ⚠️ A scratch-row PII boundary must be built for transcripts (the maintenance
scratch.tsis the model). - ⚠️ Static
importof the Temporal client from the webhook path pulls the SDK + worker into the module graph (broke tests on #1505) — lazy-import inside the signal trigger, mirroring the renewal/maintenance path.
Cost of NOT building it (status quo): the inline pipeline keeps working for the current call volume; the only exposure is silent post-call work loss on a webhook error, which is low-frequency and self-limiting at this scale.
Alternatives considered
- Unified workflow spanning the whole call (the literal open question). Rejected: the live turn is sub-second synchronous; a Temporal round-trip in the personalization/tool path adds hundreds of ms of latency the caller hears as dead air, and Temporal is explicitly not designed for that (N1). A "unified" workflow would have to special-case the live turn out anyway — so the real boundary IS call-ended.
- A cron/sweeper that reconciles "calls missing post-processing". Rejected: this is the exact anti-pattern the renewal + maintenance migrations deleted (CLAUDE.md). A durable timer/signal inside the workflow replaces polling; reintroducing a cron is the wrong direction.
- Status quo, harden inline (try/catch per step + a DLQ row on failure). Viable as an interim — it buys per-step isolation without a worker. If we want reliability before the conditions above trigger, this is the cheaper half-step; the workflow is the full answer.
Recommendation
Adopt the split-at-call-ended boundary as the agreed direction. Take no build action now. If post-call reliability surfaces as a real problem, implement voiceCallWorkflow per ADR-0031's maintenance pattern; until then, optionally apply Alternative 3 (per-step try/catch isolation) as a cheap interim. Flip this ADR to Accepted when the first build lands.