Libra CodeHub

CedarCopilot/cedar-mail

Branch: staging

fix(crm): stop the vendor lookup resolving into a deal conversation

merged#2702CedarCopilot

CedarCopilot wants to merge 3 commits into staging from fix/vendor-bucket-lookup-skips-deals

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

    Live on prod, no production signal yet

    Sep 9, 2026, 5:23 PM

Behaviors Libra is checking

Concurrent vendor-mail assignments that race to create a bucket converge on the single winning reserved bucket instead of failing the event assignment.Inconclusivelow confidence

Unable to complete verification because telemetry returned no matching OTEL spans and the source read needed to map the operation to concrete production signals was blocked by the source character budget.

prod, checked Sep 9, 2026, 4:22 PM
Vendor buckets created before the reserved scope was introduced continue to be reused only when they are unscoped, null-status, and case-insensitively named for the vendor, while unrelated deal conversations are skipped.Inconclusivelow confidence

Unable to complete verification: telemetry shows 26 concrete post-deploy executions of findOrCreateVendorConversation, including repeated vendor-conversation creation logs, but no confirmed reuse/returned-row success signal or qualifying-row evidence. Source reads were.

prod, checked Sep 9, 2026, 5:21 PM
New vendor buckets are persisted with the reserved `__vendor_bucket__` scope so they can coexist with an unscoped CRM deal and remain distinct from ordinary deal conversations.Inconclusivemedium confidence

Since deployment, CloudWatch shows 26 successful [findOrCreateVendorConversation] vendor-conversation creations across multiple vendors and 3 SQL-parameter log lines containing conversationScope='__vendor_bucket__' for a Luma insert. No exact 'Failed to create or find vendor.

prod, checked Sep 9, 2026, 5:23 PM
An existing marked vendor bucket is reused on subsequent lookups even after another process has populated its status, rather than being treated as a deal or duplicated.Not checked
prod
Inbound mail from a configured vendor is no longer resolved to an older deal conversation on the same vendor company, and is routed to a dedicated vendor bucket instead.Not checked
prod

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

F8, Step 3 , the last code change in the vendor-routing workstream.

The mechanism

services/crm/conversations.ts:1837-1857 (findOrCreateVendorConversation) looked the bucket up by primaryCompanyId and took the oldest match:

const companyRelation = await findOrCreateCompanyRelationship(db, userId, vendorDomain);
const lookup = () => db.select({ id: crmConversations.id }).from(crmConversations)
  .where(and(eq(crmConversations.userId, userId),
             eq(crmConversations.primaryCompanyId, companyRelation.id)))
  .orderBy(asc(crmConversations.createdAt)).limit(1);
const existing = await lookup();
if (existing[0]) return existing[0].id;   // returns early
// ... create a real bucket   <- unreachable once anything is bound

It returns the oldest conversation bound to the vendor's company, whatever that conversation actually is. When a deal conversation was contaminated before the domain was ever added to VENDOR_DOMAINS, that contaminated conversation is by construction the older one, so the vendor route resolves straight back into it and the create branch never runs.

The comment above the lookup states the intent , it guards against a deal the user named "Zoom". It does not guard against a binding that is already wrong, which is the state every pre-contaminated account is in.

Proof that the list-based fix fails: the Aug-20 Fathom natural experiment

fathom.video was added to VENDOR_DOMAINS on 2026-08-20 (6564c3d76). After that date 33 more Fathom emails still landed on <email>'s "Broadband Hospitality" deal , every one of them the first message of a new thread, so the thread short-circuit in assignEventToConversationTool does not explain it , while her correctly named "fathom" conversation received none. Adding a domain to the list after contamination is a no-op by construction.

Why a code guard rather than the naming invariant

The plan originally aimed to enforce the invariant "a conversation's primary domain is its counterparty's domain" and then let the lookup key on domain alone. Three measurements say that is not reachable, so the lookup needs a deterministic guard regardless of how good the naming rule gets. This reverses an earlier design call, deliberately:

  1. Step 2's prompt fix scores 15/18, not deterministic (#2700).
  2. 103 conversations still carry a vendor domain AND a deal status, of which 51 are booked outcomes (won/lost/closed) that must never be auto-repaired , a closed_won whose only trace is 90 DocuSign notices is plausibly a real deal worked off-channel.
  3. 9 conversations are frozen by updateConversationFieldsTool.ts:139-152, which forbids writing name when the primary domain is in VENDOR_DOMAINS , cementing a wrong name as firmly as a right one.

What the fix turned out to require

The one-predicate fix from the design doc (isNull(status) on the lookup) is necessary but not sufficient, and the test caught it before it shipped.

idx_crm_conversations_unique_user_company_scope (db/crm-schema.ts:568-570) permits exactly one conversation per (user_id, primary_company_id, COALESCE(conversation_scope, '')) where the company is set. On a contaminated account the deal already holds that unscoped slot on the vendor's company row, so the newly reachable create raises 23505. The old catch then re-ran the same status-filtered lookup, found nothing, and threw:

Error: [findOrCreateVendorConversation] Failed to create or find vendor conversation for fathom.video

assignEventToConversationTool.ts:589 does not catch that, so the guard alone would have converted "one email lands in the wrong place" into "the whole event assignment fails" , on exactly the population the fix exists for. findOrCreateInternalConversation two functions down already documents the constraint the vendor function forgot ("The unique index on (user_id, primary_company_id) means only one conversation can exist per company relation").

So

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__/conversations.vendor-bucket-lookup.test.tsno production surface mapped
  • apps/server/src/services/crm/conversations.tsno production surface mapped