Libra CodeHub

CedarCopilot/cedar-mail

Branch: staging

fix(crm): give the Salesforce driver an owner directory

merged#2666CedarCopilot

CedarCopilot wants to merge 1 commit into staging from fix/salesforce-owner-directory

Live on prod, no production signal yetTimeline and evidence
  1. Opened
    Sep 2, 2026, 11:53 AM
  2. Sep 2, 2026, 12:03 PM
  3. Merged
    Sep 2, 2026, 12:40 PM
  4. Live on prod
    Sep 2, 2026, 12:40 PM
  5. Observed 1 hour
    Sep 2, 2026, 12:40 PM
  6. Unobserved

    Live on prod, no production signal yet

    Sep 2, 2026, 12:40 PM
  7. Pipelines steady after this deploy
    Sep 2, 2026, 12:40 PM

Behaviors Libra is checking

Salesforce CRM refreshes resolve Opportunity OwnerId values to Cedar member owners so open-deal conversations can receive ownerId and follow deal ownership.Not checked
prod
The Salesforce owner directory queries only active Salesforce users that have an email address.Not checked
prod
Owner directory entries are keyed by both Salesforce 18-character IDs and the corresponding 15-character ID prefix so either Opportunity OwnerId format resolves to the same email.Not checked
prod
Owner-directory pagination stops after 25 pages so an endlessly chained nextRecordsUrl cannot hang the inline CRM refresh.Not checked
prod
The Salesforce owner directory follows nextRecordsUrl across all available query pages until Salesforce reports that the result set is complete.Not checked
prod
If a later Salesforce owner-directory page fails, the refresh keeps the owners collected from earlier pages instead of abandoning the entire CRM reconcile.Not checked
prod

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

The bug

The "conversation follows its open deal" rule silently no-ops for every Salesforce org.

An Opportunity carries only OwnerId. crm-pull-refresh.ts:276 maps that id to an email through driver.getOwnerEmailsById before resolveOwnerUserId can match it to a Cedar member , and it guards on driver?.getOwnerEmailsById, so a driver without the method is skipped in silence. Only HubSpot had it.

The chain that broke:

  1. ownerEmailById stays empty for Salesforce
  2. resolveOwnerUserId needs an email, and an SF OwnerId has no @, so it returns null
  3. no ownerId is written onto the external_crm entry
  4. recomputeActiveFlag and computeActiveDealDecision both gate on isClosed === false && ownerId, so nothing is ever eligible
  5. active is never set, and a deal owned by a different rep never reassigns the conversation

The failure is invisible from the data: links keep syncing isClosed and look perfectly healthy.

providerdeal linkswith ownerId
hubspot8,6175,925
salesforce4,6780
attio1,6110
copper360

One customer carried 1,841 Salesforce links with isClosed on every one and ownerId on none, so not a single conversation had active set. It surfaced as a meeting prep routing to the wrong rep's Slack channel: the deal never followed the rep who actually owned the open opportunity.

The fix

Adds getOwnerEmailsById to the Salesforce driver and wires it onto salesforceDriver.

  • SOQL SELECT Id, Email FROM User WHERE IsActive = true AND Email != null over REST v60.0
  • Paginated on nextRecordsUrl, bounded at 25 pages (50k users). This runs inline inside pullRefreshCrmDealsForConversations, which has no timeout of its own, so an endless nextRecordsUrl would hang the whole reconcile rather than degrade
  • Keyed by both the 18- and 15-char id form. Deals carry 18-char today (16,375 of 16,375 sampled), but a 15-char miss is indistinguishable from an unowned deal, which is the exact failure mode being fixed. The 18-char form is the 15-char id plus a 3-char checksum, so the prefix cannot collide
  • Direct REST rather than Superglue, matching the HubSpot implementation , a read of a standard object with no field mapping to resolve
  • Best-effort: map lives outside the try, so a partial or failed fetch returns what it collected and owner resolution degrades to "not a Cedar user" exactly as it did before, rather than failing the reconcile batch

Attio and Copper are deliberately untouched. Both drivers document that they leave isClosed undefined because neither has a trustworthy closed flag, and eligibility requires isClosed === false, so a directory would change nothing for them until they get one.

Verification

Against a live Salesforce org:

owner directory size: 150
005Ux0000055Gu5IAE -> zachary.zander@...  -> WToZycqWuh9OtzDjs9Qperz5zmXlwwqU
005Ux0000085onZIAQ -> sahand.barati@...   -> YRnaQFQFYgBZwGnFWgb7SzR2KtSjEvvc
0051R00000JnEBEQA3 -> integrations.user@. -> null   (correct: not a Cedar user)

Blast radius. Dry-ran the reconcile (pullRefreshCrmDealsForConversations, dryRun: true, no writes) over every conversation in that org with 2+ open deals. All five now get active set for the first time, with zero reassignments and zero cross-owner ambiguities , this turns on active-deal marking without moving any conversation between reps today.

  • pnpm --filter @zero/server run types , clean, 0 errors
  • pnpm run deps:check , clean, 1,723 modules / 9,016 dependencies, no violations
  • vitest run src/services/integrations/crm/__tests__ src/services/crm/__tests__ , 803 passed / 69 files
  • 7 new tests in salesforce-owner-emails.test.ts. The wiring assertion is the one that catches the regression: removing the getOwnerEmailsById, driver line makes it fail with expected undefined to be [AsyncFunction getOwnerEmailsById]. The other six exercise the function directly and s
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/integrations/crm/__tests__/salesforce-owner-emails.test.tsno production surface mapped
  • apps/server/src/services/integrations/crm/salesforce.tsno production surface mapped