Libra CodeHub

CedarCopilot/cedar-mail

Branch: staging

Persist meeting transcripts inside findOrCreateMeetingEvent

closed#2662CedarCopilot

CedarCopilot wants to merge 1 commit into staging from fix/meeting-transcript-dropped-on-two-ingest-paths

Not deployedTimeline and evidence
  1. Opened
    Sep 2, 2026, 10:45 AM
  2. Sep 2, 2026, 10:54 AM
  3. Merged, live on staging, live on prod, observed
    Pending
  4. Not deployed

    Not deployed

    Pending

Libra has no production signal for this change yet because it has not deployed. Libra checks hourly for 3 days after each deploy.

The bug

A meeting could be recorded in crm_meeting_events with has_transcription = true and a real transcription_word_count, but `transcription_key=[redacted] and the transcript text persisted nowhere. The content was simply dropped.

Every provider driver returns the assembled transcript in fullTranscript and hardcodes `transcriptionKey=[redacted] (attio, caretta, fathom, circleback, askelephant, granola, krisp, recall). Minting the S3 key and storing the text was left to each caller, and only two of the four ingest paths did it:

PathStored the transcript
workflows/step-registry/conversation-sync/meeting-steps.tsyes
lib/integrations/meeting-agent.ts (processMeetingWebhookDirect)yes
mastra/tools/meetings/syncMeetingTool.tsno
mastra/tools/event-execution/createAndLinkEventsTool.tsno

The rows the two broken paths produce are single-write, so no later sync heals them, and buildMeetingHydration only assigns transcriptionKey inside if (incoming.hasTranscription && !stored.hasTranscription), which cannot fire for an already-flagged row.

The fix

The write happens inside findOrCreateMeetingEvent now, so "insert a meeting event" and "persist its transcript" cannot come apart, and a fifth ingest path cannot reintroduce the bug by forgetting. The private persistMeetingTranscript has no other entry point.

storeMeetingInS3 returns the key it wrote. That return value is the point , transcription_key has to be the key of the object that actually holds the transcript, and the only way to guarantee that is for the key to come from the write. Four call sites were formatting ${connectionId}/${meetingId}.json by hand; none do now.

Three guards, each load-bearing:

  • An incoming key already set means an upstream path stored the payload, and we must not clobber it. The webhook path is exactly that case: it stores the FULL payload and then truncates fullTranscript to 5 KB before handing the meeting on, so re-storing inside the service would replace a complete transcript with the truncated copy.
  • No transcript text means no key. A meeting Cedar has notes for but no transcript keeps a null transcription_key, as today.
  • Best-effort. An object-store outage costs the transcript, never the meeting event and the whole agent run behind it.

The store is also skipped for a stored row that already holds a transcript: buildMeetingHydration is upgrade-only, so there is no key for a fresh write to move the row to, and recorders re-deliver the same meeting many times over (Circleback fans each one out to every attendee).

Connection id

The key is ${connectionId}/${meetingId}.json, and findOrCreateMeetingEvent had neither connectionId nor env. Both now arrive in one MeetingContentStorage argument. connectionId is optional because sync-meeting genuinely has none , it reaches the recorder through the user's credential, not a connection row. Object storage does not care which of a user's connections keys the object (findMeetingInS3 tries every connection the user owns), so when the caller has no preference the service picks the user's lowest-id connection , deterministic, so a retry lands on the same key rather than scattering copies across prefixes.

Double-store removed

http/app.ts stored every webhook meeting and then called processMeetingWebhookDirect, which stores it again. The copy app.ts wrote was the worse of the two: taken before the driver's hydrateTranscript could fetch a transcript the webhook omitted. Removed. The meeting-agent.ts store stays and is load-bearing , it is the one that runs while the full transcript is still in hand, before the 5 KB truncation.

Not in this PR: the existing broken rows

Roughly 28 rows in the last 60 days, 15 of them on <email> (provider attio, word counts 2,446 to 11,623). Their text was never stored, so there is nothing in S3 to point at , recovering them means

Show production surfaces and changed-file mapping

Production surfaces

Libra has not measured any production surfaces for this change yet.

Changed files → surfaces

  • apps/server/src/http/app.tsno production surface mapped
  • apps/server/src/lib/__tests__/cf-migration-phase1.test.tsno production surface mapped
  • apps/server/src/lib/integrations/meeting-agent.tsno production surface mapped
  • apps/server/src/mastra/tools/event-execution/__tests__/createAndLinkEvents-meeting-transcript.test.tsno production surface mapped
  • apps/server/src/mastra/tools/event-execution/createAndLinkEventsTool.tsno production surface mapped
  • apps/server/src/mastra/tools/meetings/__tests__/syncMeetingTool.test.tsno production surface mapped
  • apps/server/src/mastra/tools/meetings/syncMeetingTool.tsno production surface mapped
  • apps/server/src/pipelines.tsno production surface mapped
  • apps/server/src/services/crm/__tests__/meeting-transcript-persistence.test.tsno production surface mapped
  • apps/server/src/services/crm/meeting-events.tsno production surface mapped
  • apps/server/src/trpc/routes/crm.tsno production surface mapped
  • apps/server/src/workflows/step-registry/conversation-sync/meeting-steps.tsno production surface mapped