Native call-recording attachment
API2 can download provider call recordings, write the Rails-compatible
ConversationPartCallDetail recording Active Storage rows, and enqueue call
transcription. The call_recording_attachment_native PostHog flag is default
off and fail-closed to the existing Rails internal endpoint.
Contract
The native path validates the organization and call detail with an explicit
organization fence, returns the existing recording attachment without remote
work, validates Zendesk recording hosts and encrypted access tokens, downloads
with a 60-second deadline, 256 MiB body cap, and five manual redirects, then
writes the object and Rails Active Storage rows in the shared cloudflare
service. Remote storage and queue work never runs inside a database
transaction. A FOR UPDATE re-check makes mixed-version races idempotent.
Automatic-policy calls enqueue the deterministic BullMQ call job using the
conversation_part_call_detail and conversation prefix IDs. An enqueue
failure after commit is logged and reported as
attached_transcription_enqueue_failed; it does not roll back the attachment.
Structured native_call_recording_attachment logs never include recording
URLs, credentials, headers, or bodies.
Object lifecycle / orphan recovery
API2 inserts a call_recording_upload_intents row before writing the recording
object. The intent therefore remains the durable owner if the process stops
after upload or an inline cleanup attempt fails. uploading means the upload
has not completed, attached means the Active Storage blob owns the object,
cleanup_pending means the object must be deleted, cleaned means deletion
succeeded, and abandoned means the five-attempt retry budget was exceeded and
the row needs reconciliation.
A maintenance worker sweeps these intents every five minutes. Uploads older than 15 minutes are considered stale; if no matching Active Storage blob exists, the object is deleted. Cleanup retries run up to five attempts, and all storage calls remain outside database transactions.
The following read-only query lists orphan candidates for reconciliation:
SELECT id, rails_organization_id, conversation_part_call_detail_id,
bucket, object_key, status, attempts, last_error, created_at, updated_at
FROM public.call_recording_upload_intents
WHERE status IN ('cleanup_pending', 'abandoned')
OR (status = 'uploading' AND created_at < now() - interval '15 minutes')
ORDER BY created_at;Deviations
- Blob metadata is
{identified:true, analyzed:false}and no Rails analyze job is scheduled. - Native downloads are bounded to 60 seconds, 256 MiB, and five redirects; Rails/Faraday was unbounded. Empty responses are rejected.
- Transcription always uses the API2 BullMQ call queue. The Rails
api2_conversation_transcriptiongate is 100%, so the GoodJob fallback is not reproduced. - Post-commit enqueue failure relies on existing untranscribed-recorded-call reconciliation rather than a GoodJob retry.
Rollback
The flag call_recording_attachment_native defaults off and fails closed to
Rails. CALL_RECORDING_ATTACHMENT_OWNER=rails forces Rails per deployment.
The Rails route/controller is untouched. Rollback is flag-off (within the
approximately 20-second flag cache), with no data migration because both
writers produce identical Active Storage rows in the same cloudflare bucket.
During mixed versions, both owners may handle one call detail. The native
FOR UPDATE re-check and Rails attached? check make the loser report
already_attached; the deterministic BullMQ job ID
(conversation_transcription_call_<runId>_<callDetailId>) collapses duplicate
transcription dispatch.
Production rollout plan
Baseline (rolling 24 hours ending 2026-09-17T23:18Z): 2,741 Rails Internal::
ConversationPartCallDetailRecordingsController#create calls (2,577 201,
162 200, 2 502), concentrated in EU; approximately 16,146 calls / 7 days.
Callers are Aircall import, provider-sync native effects, and
Zendesk/Aircall matching (QA recovery reaches this path through provider sync).
- Deploy with the flag off.
- Enable for one internal EU organization.
- Reconcile 24 hours per region, then widen gradually.
- Hold zero-Rails windows for 24 hours and seven days before proposing a separate package to remove the Rails route.
Compare native_call_recording_attachment outcome distributions by region to
the Rails baseline (201 ≈ attached, 200 ≈ already_attached, 502 ≈
download_failed).
Read-only reconciliation queries
-- Native vs Rails attachment volume proxy: recording attachments created per hour (both owners write the same rows)
SELECT date_trunc('hour', a.created_at) AS hour, count(*) AS attachments
FROM public.active_storage_attachments a
WHERE a.record_type = 'ConversationPartCallDetail' AND a.name = 'recording'
AND a.created_at >= now() - interval '24 hours'
GROUP BY 1 ORDER BY 1;
-- Orphaned recording blobs (object written, no attachment): must stay at zero
SELECT b.id, b.key, b.created_at
FROM public.active_storage_blobs b
WHERE b.filename LIKE 'call_recording_%'
AND b.created_at >= now() - interval '24 hours'
AND NOT EXISTS (SELECT 1 FROM public.active_storage_attachments a WHERE a.blob_id = b.id)
ORDER BY b.created_at;
-- Duplicate recording attachments on one call detail: must be zero
SELECT a.record_id, count(*)
FROM public.active_storage_attachments a
WHERE a.record_type = 'ConversationPartCallDetail' AND a.name = 'recording'
GROUP BY a.record_id HAVING count(*) > 1;
-- Attached under automatic policy but never transcribed (enqueue gap candidates)
SELECT cd.id, cd.organization_id, a.created_at
FROM public.active_storage_attachments a
JOIN public.conversation_part_call_details cd ON cd.id = a.record_id
JOIN public.organizations o ON o.id = cd.organization_id
WHERE a.record_type = 'ConversationPartCallDetail' AND a.name = 'recording'
AND a.created_at BETWEEN now() - interval '24 hours' AND now() - interval '1 hour'
AND o.call_transcription_policy = 'automatic'
AND cd.transcribed_at IS NULL AND cd.transcription_failed_at IS NULL
ORDER BY a.created_at;Local checks
cd rulebase-web/rulebase-api2
pnpm vitest run src/lib/call-recordings src/lib/provider-conversation-sync/native-effects-call-recordings.test.ts src/lib/aircall src/lib/zendesk-aircall-call-matching src/lib/qa-sampling-recovery
pnpm typecheck