Libra CodeHub

CedarCopilot/cedar-mail

Branch: staging

fix(crm): stop read-time dedup hiding meeting transcripts

merged#2636CedarCopilot

CedarCopilot wants to merge 1 commit into staging from fix/meeting-dedup-hides-transcripts

Live on prod, no production signal yetTimeline and evidence
  1. Opened
    Aug 31, 2026, 10:00 AM
  2. Aug 31, 2026, 10:48 AM
  3. Merged
    Aug 31, 2026, 12:39 PM
  4. Live on prod
    Aug 31, 2026, 12:39 PM
  5. Observed 0 hours
    Aug 31, 2026, 12:39 PM
  6. Unobserved

    Live on prod, no production signal yet

    Aug 31, 2026, 12:39 PM
  7. Pipelines steady after this deploy
    Aug 31, 2026, 12:39 PM

Behaviors Libra is checking

CRM integration behavior from crm/dedup.ts keeps working in prod.Not checked
prod

Libra has verdicts on 0 of 1 tracked behaviors on prod; 1 is still being checked. Libra checks hourly for 3 days after each deploy.

The bug

Two attendees' notetakers in one room produce two genuinely distinct recordings: different provider external_ids, each with its own transcript , or none, when the bot joined and captured nothing. Read-time dedup collapsed those into a single row, and the row that survived could be the empty one. The transcript then disappeared from every reader, agent included.

Live example, conversation 2925df6c-9de2-4c8a-acb3-8cda006d8185, both rows ingested by the same backfill run and owned by the same user:

eventexternal_idtranscript
a3764348…01M10A6SAJVBEF3797VRPS3P78✅ 2,976 words, notes + ai_notes
e37a9431…01M1512M2AKF7YY05XXFXSQPJ4❌ none, no notes

conversation-read with expandEventId on the good row answered "Event with ID … not found in conversation" , it was filtered off the timeline before the search ran.

Root cause , three compounding defects, all in services/crm/dedup.ts

1. Inverted key hierarchy. getDedupKey returned the title + 10-minute-slot key whenever both were present, and only fell back to meeting:${externalId} when one was missing. The email branch does the opposite and is correct: the definitive rfcMessageId is tier 1, the approximate key tier 2.

2. The approximate key was applied with tier-1 semantics. isWeakKey matched only the email-approx: prefix, so the meeting key was not recognised as weak and dedupEventsForReader collapsed a group even within a single owner , precisely the failure the module's own doc comment warns about at length for email, where collapsing a same-owner group would "silently hide 27 real emails". The meeting branch had no such guard.

3. The survivor was picked by array position. pickWinner step 1 short-circuited on the first row matching the primary owner, which matches both rows when they share an owner. Step 2 ("prefer a row with agent-enriched data") was never reached and could not have discriminated anyway , both live rows carry a non-null summary.

Worth being precise about #3: this was not "the empty one always wins", it was nondeterministic. The pair ties on is_significant and on occurred_at, which is the entire ORDER BY the reader query applies (conversations.ts:3444), so the survivor was decided by the query plan. Verified in prod , both rows: is_significant = t, occurred_at = 2026-08-28 20:30:00.

The fix

  • externalId promoted to tier 1 for meetings. One recording keeps one id however many members mirror it; two notetakers produce two ids because they are two recordings.
  • title+slot demoted to a genuinely weak key, prefixed meeting-approx: so isWeakKey recognises it , a same-owner group now passes through intact, matching the email tier-2 contract. meetingUrl keeps its position as the last-resort fallback.
  • Weak prefixes are declared beside the code that builds them (EMAIL_APPROX_PREFIX, MEETING_APPROX_PREFIX, WEAK_KEY_PREFIXES), so a new weak key cannot be introduced without isWeakKey learning about it. That drift is what let the meeting key be treated as exact.
  • pickWinner prefers content over position. Ownership now narrows the field rather than deciding the winner outright , the reader's copy still carries the per-mailbox ids they can actually open, but when the reader owns several rows in the group, the richest of those wins (contentRank: transcript > notes > summary), ties breaking deterministically on the oldest createdAt.

What is preserved, stated precisely

Every recording still collapses across owners. Measured over 90 days of live data, on per-conversation (title, 10-min slot) groups holding more than one distinct external_id:

groupseventsrows a reader sees
same-owner (the bug)160327160 → 327
cross-owner (must keep collapsing)110372110 → 232

The same-owner row is the fix: 167 genui

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/services/crm/__tests__/dedup-meeting-recordings.test.tsno production surface mapped
  • apps/server/src/services/crm/dedup.tsno production surface mapped