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:

  1. 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.

  2. The post-call pipeline (asynchronous, durable-work). /api/voice/call-ended today runs a multi-step pipeline inline in the webhook handler:

    • generateAndSendRecap (recap SMS/notification)
    • maybeCreateWorkOrderFromTranscript (LLM extracts WO details → handleCreateWorkOrder)
    • resolveProspect (prospect linkage)
    • maybeSendRenewalRecapSms
    • captureVoiceToolErrors

    (Unknown-caller capture, shipped in #1993, is a mid-call tool — not a call-ended step — 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.

Naming (per src/lib/temporal/README.md): the workflow is named by domainvoiceCallWorkflow — 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):

  1. 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).
  2. 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/condition is the right primitive; a cron is the wrong one.
  3. 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:

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

  1. 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.
  2. 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.
  3. 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.