CedarCopilot wants to merge 1 commit into staging from fix/salesforce-owner-directory
Live on prod, no production signal yet
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 "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:
ownerEmailById stays empty for SalesforceresolveOwnerUserId needs an email, and an SF OwnerId has no @, so it returns nullownerId is written onto the external_crm entryrecomputeActiveFlag and computeActiveDealDecision both gate on isClosed === false && ownerId, so nothing is ever eligibleactive is never set, and a deal owned by a different rep never reassigns the conversationThe failure is invisible from the data: links keep syncing isClosed and look perfectly healthy.
| provider | deal links | with ownerId |
|---|---|---|
| hubspot | 8,617 | 5,925 |
| salesforce | 4,678 | 0 |
| attio | 1,611 | 0 |
| copper | 36 | 0 |
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.
Adds getOwnerEmailsById to the Salesforce driver and wires it onto salesforceDriver.
SELECT Id, Email FROM User WHERE IsActive = true AND Email != null over REST v60.0nextRecordsUrl, 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 degrademap 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 batchAttio 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.
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 errorspnpm run deps:check , clean, 1,723 modules / 9,016 dependencies, no violationsvitest run src/services/integrations/crm/__tests__ src/services/crm/__tests__ , 803 passed / 69 filessalesforce-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 sLibra has not measured any production surfaces for this change yet.