Libra CodeHub

CedarCopilot/cedar-mail

Branch: staging

fix(routing): prefer the participant domain when the agent returns a company name

closed#2693CedarCopilot

CedarCopilot wants to merge 3 commits into staging from fix/company-name-stored-as-domain

Not deployedTimeline and evidence
  1. Opened
    Sep 6, 2026, 12:21 PM
  2. Sep 6, 2026, 12:53 PM
  3. Merged, live on staging, live on prod, observed
    Pending
  4. Not deployed

    Not deployed

    Pending

Libra has no production signal for this change yet because it has not deployed. Libra checks hourly for 3 days after each deploy.

The mechanism

selectPrimaryCompany takes whatever the domain-identification agent puts in primaryDomain and runs it through normalizePrimaryDomain:

When the agent answers with a company NAME rather than a domain , infoports, ws strategy, eluve-inc, skyline , getDomain returns null, the raw string falls through verbatim, and findOrCreateCompanyRelationship stores it as a company domain. The zod schema for primaryDomain (company-domain-assignment.ts:60-65) is z.string().nullable() with prose only, so nothing rejects it.

232 such rows exist across 48 users. 39 were created in Aug 2026 and 9 in Sep 2026 , it is ongoing, not historical.

The corruption is self-reinforcing

This is the strongest argument for a guard, and it is why the population is not static. findOrCreateCompanyRelationship inserts the value into companies_global as both domain and name. On a later run lookup-company-fuzzy's name lookup (lookupCompanyFuzzyTool.ts:394-404) matches that row and returns {found: true, domain: "preet patel"} to the agent , which is then handed its own earlier mistake as evidence that the name is a domain.

Why a code guard rather than a prompt or schema change

A parallel eval established that the prompt/schema route does not fix this. Schema-only moved one case by one trial in eight; schema plus instruction fixed one case and failed its near-identical sibling. The guard is deterministic; the instruction is not.

Why only 14 of the 232 are this fix's target

The 232 split three ways, and only one of them is the defect this PR closes. The other two are the reason the change is narrow:

populationrowswhat it is
no external sender on the conversation175The domain was genuinely unknowable. <email> is an insurance broker whose prospects are small LLCs with no counterparty address; his conversations carry only his own <email> and are named pristine cleaning professionals llc, quote trade market llc, seafarer consulting llc. The business name is what gives each prospect a distinct identity , forcing these to null or "Unknown" would collapse a real customer's whole book into one company, strictly worse than today. Preserved exactly.
the only external sender is a vendor/notifier43zoom.us ×15, otter.ai ×8, google ×5, fathom ×3, gong ×2, calendly ×2, circleback ×2, plus spotdraft/swapcard/linkedin/stripe/commonpaper. The agent invented a company from a display name or subject. These land on the vendor domain under this change, which is the correct outcome: the company row records zoom.us instead of a company minted from a display name. Note this fixes the COMPANY DOMAIN only. Whether the conversation itself reaches the vendor bucket is decided in assignEventToConversationTool, which this PR does not touch. They belong to Step 3 of the workstream, and vendor domains are deliberately not special-cased out of this path.
the external sender is a real counterparty14The genuine defect.

That constraint is what determines where the fix goes. normalizePrimaryDomain is unchanged: its || hostOnly fallback is precisely what the 175 depend on, and it is shared with external-CRM paths that have no participants to consult. The

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/mastra/utils/__tests__/company-domain-assignment-name-not-domain.test.tsno production surface mapped
  • apps/server/src/mastra/utils/company-domain-assignment.tsno production surface mapped
  • apps/server/src/services/crm/internal-domains.tsno production surface mapped
  • apps/server/src/services/crm/utils.tsno production surface mapped