Libra CodeHub

CedarCopilot/cedar-mail

Branch: staging

fix(crm): owner-scope deal search so Cedar never links an opp it cannot write

merged#2609CedarCopilot

CedarCopilot wants to merge 5 commits into staging from fix/sherlock-crm-link-owner-scope-20260827-180115

Live on prod, no production signal yetTimeline and evidence
  1. Opened
    Aug 27, 2026, 6:19 PM
  2. Aug 27, 2026, 8:29 PM
  3. Merged
    Aug 27, 2026, 8:39 PM
  4. Live on prod
    Aug 27, 2026, 8:39 PM
  5. Observed 2 days
    Aug 27, 2026, 8:39 PM
  6. Pipelines steady after this deploy
    Aug 27, 2026, 8:39 PM
  7. Unobserved

    Live on prod, no production signal yet

    Aug 30, 2026, 2:51 PM

Behaviors Libra is checking

CRM integration behavior from crm/crm-deal-auto-link.ts, crm/driver.ts, crm/salesforce.ts keeps working in prod.Inconclusivelow confidence

Strict CloudWatch fallback found 6 recent prod failure log lines for [UpdateExternalCrmWorkflow] Driver update failed (fetch failed), but the same failure had 7 log lines in the comparable pre-deploy baseline. Libra is not blaming this PR intent without a post-deploy increase.

prod, checked Aug 30, 2026, 2:51 PM

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.

What was wrong

Cedar's periodic pull has never compared owner ids. getAllDeals sends the caller's email addresses plus include_unowned to Superglue's salesforce-get-deals, which resolves them to a Salesforce User Id and constrains by OwnerId. All four drivers do exactly this.

The link path called a different Superglue tool , crm-search-deals-by-keyword , which accepted no owner parameters at all:

superglueCrmSearchDealsByKeyword({ provider, keyword, access_token, instance_url });

So it returned every opportunity the OAuth token could read. For Listen Labs that is 4,750 opportunities; Willem can read the whole org and edit only his own. Cedar linked conversations to colleagues' records, and every field push after that failed:

HTTP 400 [{"message":"insufficient access rights on object id",
           "errorCode":"INSUFFICIENT_ACCESS_OR_READONLY"}]

35 of Willem's 305 linked Salesforce deals were owned by nine other reps. UserRecordAccess for opportunity 006hm000000GKmpAAG (NRG, owned by Sean Hoag) returns HasEditAccess: false, MaxAccessLevel: "Read"; every deal Willem owns returns "All" and pushed fine in the same window.

What changed

The capability was missing from the search tool, so it was added there rather than reimplemented in Cedar.

Superglue crm-search-deals-by-keyword now accepts sf_user_emails and include_unowned, with the same names, semantics and User-lookup step as salesforce-get-deals:

  • new fetchSalesforceUser step , SELECT Id FROM User WHERE Email IN (...)
  • searchSalesforceDeals gains AND OwnerId IN (...)
  • the keyword clause is now parenthesised. Without it, A OR B AND owner parses as A OR (B AND owner) and every Name match would have leaked through unscoped.

Both inputs are optional. Omitted, the tool behaves exactly as before, so the HubSpot and Attio branches and any other caller are unaffected.

Cedar just passes them through, the way getAllDeals already does:

const includeUnowned = getFetchUnownedDeals(crmRow.metadata);
const hits = await driver.searchDealsByKeyword(userId, term, { includeUnowned });

fetchUnownedDeals on the connection remains the escape hatch, now read through a getFetchUnownedDeals helper sitting beside getDealSyncFilter so both sides read it one way.

Also fixed: a pre-existing SOQL bug in the same query

The apostrophe escape emitted \'' where SOQL wants \', so any keyword containing an apostrophe returned HTTP 400. Verified against the unmodified live tool. Because trySearchAndLinkCrmDeal has no try/catch around searchDealsByKeyword, that exception aborted the entire auto-link run rather than one search , so Rothy's, L'Oreal, Not Your Mother's and every other apostrophe account silently never auto-linked.

Scope

80 lines across 5 files. The gate is one argument.

An earlier version of this PR added a getConnectedOwnerIds driver method, a crm-owner-scope.ts gate, a salesforce-identity.ts helper, and Salesforce 15/18-char id comparison logic , a second, Cedar-local mechanism for a question Cedar already answers elsewhere. All removed. That is also where both defects found in review lived: a lowercase comparison on the case-sensitive 15-char form, and a 15-vs-18 key mismatch.

The link-crm-deal tool gate is likewise dropped: it does not go through search, and keeping it would mean reintroducing the identity mechanism this removes. That path creates the opportunity it links, so the deal is owned by the creating user by construction. Worth revisiting if cross-owner links show up there , the fix would be adding the same two parameters to crm-fetch-deals-by-ids, left alone here because the reconcile depends on it returning unowned deals.

Existing bad links

Already repaired out of band for the only affected account (Willem Ebbinge, 2026-08-28): **35 conversations downgraded to account-only linkage, 305 → 270 linked deals, 0 unowned rem

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__/crm-deal-auto-link.test.tsno production surface mapped
  • apps/server/src/services/crm/crm-deal-auto-link.tsno production surface mapped
  • apps/server/src/services/integrations/crm/driver.tsno production surface mapped
  • apps/server/src/services/integrations/crm/salesforce.tsno production surface mapped
  • apps/server/src/services/integrations/provider-config.tsno production surface mapped