Native conversation transcription state
ENG-3235
moves the three Rails round-trips that the conversation-transcription call
job still made — GET /internal/conversations/:id/transcription/context,
POST …/transcription and POST …/transcription/failure — into API2. API2
already owned the provider call and the post-transcription continuation; this
package makes it the writer of record for transcript rows, the audio-attempt
counter, permanent failure state, credit usage and the callback effects Rails
ran on those writes. The Rails endpoints stay deployed as the rollback path.
Baseline
Rolling 24 hours ending 2026-09-17T23:18Z: 688 successful Rails conversation
transcription context/writeback calls. Seven days: 9,539. Both numbers count
the internal endpoints above across both regions. Re-measure per region before
enabling the flag anywhere; do not extrapolate from these values.
Ownership contract
- Owner is resolved once per call job by
resolveConversationTranscriptionStateOwner(organizationSlug):CONVERSATION_TRANSCRIPTION_STATE_OWNER=api2|railsoverrides everything; outside production API2 owns; in production the organization-scoped PostHog flagconversation_transcription_state_nativedecides and fails closed to Rails. The flag does not exist yet; this package ships default-off. - Context (
native-context.ts) reads the conversation, organization, ordered call details, transcript presence,recordingattachment and key phrase sources without a transaction. The conversation must belong to the job’s organization slug; a mismatch isnot_foundwith no writes. - Recording URLs are presigned only after every read completes, only for
blobs on the Rails production Active Storage service (
cloudflare), through the samegetStorageBlobSignedUrlhelper the native blob routes already use, with the six-hour expiry Rails passes to the provider. Any otherservice_namethrows instead of recording a failure. - Key phrases mirror
ConversationPartCallDetail::Transcription.key_phrases_forper call (organization greeting, per-call employee first name, agents, customers, Zendesk brand, company) and are capped at 100 entries. Expected languages followmultilingual_support_enabledandorganization.languages. - Persistence (
native-writes.ts) runs one short transaction: lock the call detailFOR UPDATEscoped to organization and conversation, re-checktranscript_available?, replace transcript rows using the Rails sequence, mapcustomer→client/agent→agent/other→unknown, default confidence to0.0, resolve duration as Rails’update_duration_from_transcription!, recomputeconversations.handling_timeandtranscription_confidence_score, insert the idempotent transcriptioncredit_usagesrow, settranscribed_at/language, and apply the transcript-message callback effects (conversation_parts.body/redacted_bodywith.presencesemantics, first agent/customer response timestamps fortype = 'call'parts, excluded employees skipped). - Failure (
native-writes.ts) incrementstranscription_attempt_countunder the same lock; non-permanent errors returnretryuntil three attempts, then settranscription_failed_atand a 500-character truncatedtranscription_error. Provider-confirmed permanent input failures record immediately. Sentry capture happens after commit and only for non-permanent terminal failures, as in Rails. - Telemetry,
call.transcribedPostHog capture (org:<slug>distinct id, organization group) and Sentry all run after commit. No provider, storage, Redis or HTTP work happens while a lock is held. - Queue payloads, the stable
runId, the completion fan-in and the Railsanalysis/translation_fallbackendpoints are unchanged, so any mix of API2 revisions and either owner is safe during rolling deploys.
Telemetry
emitConversationTranscriptionTelemetry logs one structured line per phase
with owner (api2/rails), phase (context/persist/failure),
outcome (loaded, not_found, transcribed, already_transcribed,
retry, permanent), region, environment, organization slug, conversation
and call-detail prefix IDs, and attempt counts. No text, URLs or names are
logged.
Deploy, reconcile, expand
- Deploy in both regions with the flag absent. Confirm the Rails path is still
serving: the three internal endpoints keep tracking the baseline and the
owner=railstelemetry appears. - Create the PostHog flag off. Enable it for one organization with recent call volume in one region.
- For that cohort, compare per region and per outcome against the retained
Rails path and the baseline:
owner=api2transcribed+already_transcribed+retry+permanent+not_foundcounts versus the Rails endpoint volume the cohort used to produce;- transcript row counts per call,
transcribed_at,language,duration_ms,handling_time,transcription_confidence_score,conversation_parts.body, first-response timestamps andcredit_usagesfor API2-owned calls versus Rails-owned calls of similar shape; transcription_attempt_countdistributions and permanent-failure rate;- duplicate transcript rows or duplicate
credit_usages(must be zero); not_foundand tenant-mismatch warnings (must be zero for real jobs);- Sentry events from the native path;
- signed URL provider errors (
403/404on the recording URL) versus the Rails cohort, which proves signing/host parity.
- Zero Rails calls with fewer business outcomes is a regression. Expand only when the distributions match for a complete 24-hour window, then repeat per region before organization-wide enablement.
- Rails endpoint retirement requires a complete zero-call 24-hour window in both regions with clean reconciliation.
Rollback
Set CONVERSATION_TRANSCRIPTION_STATE_OWNER=rails (immediate, region-wide)
or disable the PostHog flag (per organization, ≤20 s cache). In-flight jobs
retry through Rails with the same payload; Rails re-checks
transcript_available? under its own lock and the attempt counter is shared,
so replay after rollback does not duplicate rows, credits or attempts. Do not
edit transcript rows, attempt counts or queue payloads during rollback.
Owner: Platform. Flag conversation_transcription_state_native is proposed
(not created) on 2026-09-17; review or remove by 2026-12-16.