Libra CodeHub

CedarCopilot/cedar-mail

Branch: staging

fix(crm): coerce select/list field values to their canonical option spelling

merged#2883CedarCopilot

CedarCopilot wants to merge 2 commits into staging from fix/coerce-select-list-option-casing

What Libra verified

  • Formatting-only select and list values use their canonical option spelling

    Libra ran this behavior against the change and confirmed the check detects when it breaks.

    Technical evidence
    • a select value matching one declared option after case and separator normalization is persisted with that option's declared spelling
    • each list item matching one declared option after case and separator normalization is persisted with that option's declared spelling while unmatched items remain unchanged
    • unmatched or ambiguously matching select values remain unchanged instead of being forced onto a declared option

    Changed code: conversation-field-definitions.ts.

  • List arrays are canonicalized item by item

    Libra ran this behavior against the change and confirmed the check detects when it breaks.

    Technical evidence
    • a JSON-encoded string array independently canonicalizes each item and remains a JSON-encoded array
    • a valid JSON value that is not a string array remains unchanged unless it matches an option as a whole string
    • a malformed value remains unchanged unless it matches one option as a string

    Changed code: conversation-field-definitions.ts.

Live on prod, 0 of 10 surfaces working, 2 days leftTimeline and evidence
  1. Opened
    Sep 22, 2026, 3:27 PM
  2. Sep 22, 2026, 4:10 PM
  3. Merged
    Sep 22, 2026, 4:33 PM
  4. Live on prod
    Sep 22, 2026, 4:33 PM
  5. Observed 2 hours, 10 surfaces, 12 requests
    Sep 22, 2026, 4:33 PM
  6. Watching

    Live on prod, 0 of 10 surfaces working, 2 days left

    Sep 22, 2026, 4:33 PM
  7. Pipelines steady after this deploy
    Sep 22, 2026, 4:33 PM

Behaviors Libra is checking

A user-level custom-field type overrides the organization-level type for the same key so stale organization JSON or select/list classification cannot cause valid writes to be parsed incorrectly or dropped.Not checked
prod
CRM list values represented as JSON arrays have each string item independently coerced to its declared option spelling before persistence.Not checked
prod
Values that match no declared option or match multiple options after normalization remain unchanged instead of being silently assigned to an unrelated or ambiguous option.Not checked
prod
Malformed JSON, JSON scalars, objects, and other non-array list inputs are treated as plain strings and do not throw during CRM field persistence.Not checked
prod
Option matching preserves meaningful punctuation such as plus signs, hyphens, slashes, periods, and dollar signs so options that differ by punctuation are never aliased.Not checked
prod
Select and list option definitions are resolved from the organization AOP and user AOP, with the user AOP's definition taking precedence for the same field key.Not checked
prod

Libra found 10 production surfaces on prod but could not judge any of them yet. 6 surfaces had under 20 requests, so Libra has not judged them; 4 surfaces had no requests at all. Libra checks hourly for 3 days after each deploy.

Summary

Nothing enforced a select/list custom field's declared options at write time, so a real picklist could still accumulate multiple cases/formats of the same value. Confirmed on Warp's current_provider field , a genuine 30-option picklist synced from Salesforce (Current_Provider__c) , which has raw values like gusto/Gusto and rippling/Rippling/rippling_peo all present for the same option. A broader scoping query found the same casing/punctuation-variant pattern on 74 fields across 17 of 43 Cedar orgs (≄5% of distinct values collapsing to the same thing after normalization, on fields with real volume and short/categorical values).

  • Adds coerceToCanonicalOptionValue/coerceSelectOrListValue (conversation-field-definitions.ts): when an incoming value normalizes (case + underscore/whitespace-insensitive) to exactly one declared option, persist that option's real spelling instead. Zero or multiple normalized matches are left unchanged , this corrects how a value is spelled, never what it means.
  • A dedicated normalizeOptionValueForMatch is used for this, not the existing normalizeStageKeyForMatch , that function's own doc comment warns it collapses meaningful punctuation ("Series B" vs "Series B+" would alias). The new normalizer only folds case and the underscore/whitespace separator convention this codebase actually mixes, leaving other punctuation untouched.
  • Wired into updateConversationFieldsAndWorkingMemory, the single persist funnel for custom fields (agent tool, approval-task apply, updateConversation route, CRM mirror) , covered uniformly, not just for the agent path. Field-type classification (json vs. select/list) is resolved per-key with the user AOP's definition overriding a same-key org-level one, fixing a bug caught in review where a field typed differently at each level could keep a stale classification and have valid writes silently dropped.

Scoped conservatively on purpose: this never rejects an unrecognized value, never guesses between ambiguous matches, and doesn't touch existing rows already in the database , only new writes going forward.

Test plan

  • pnpm --filter @zero/server run types , clean
  • New unit tests (conversation-field-definitions.test.ts, 14 cases) covering: case/underscore coercion, exact-match no-op, no-match no-op, free-hand-text no-op, ambiguous-options no-op, the "Series B" vs "Series B+" non-aliasing case, list-type JSON-array per-item coercion, malformed-JSON and non-string-array safety, and no-options (free text) no-op
  • Existing regression tests for the same persist path (conversations.json-persist.test.ts, conversations.field-approval-reconcile.test.ts) , all pass, no behavior change for non-select/list fields
  • Reviewed via /code-review high , both findings (punctuation-aliasing risk, stale field-type classification) fixed and re-verified
  • No live-write verification against a real database , out of scope per the request that produced this PR; all coverage is unit-level against the pure coercion functions and the existing DB-mocked regression suite

šŸ¤– Generated with Claude Code

RetriggerConfidence Score: 4/5

The PR is not yet safe to merge because list/select shape disambiguation remains incomplete and the new test code violates an explicit repository requirement.

Findings

  1. <img alt="P1" src="https://greptile-static-assets.s3.amazonaws.com/badg
Show production surfaces and changed-file mapping

Production surfaces

SurfaceRequestsErrorsp95UsersVerdict
/api/trpc/crm.searchConversationsMinimal0 → 30 → 0 (0%)not measured → 135 ms0Insufficient traffic
3 requests, under the 20 Libra needs
/api/trpc/crm.listConversations0 → 30 → 0 (0%)not measured → 1016 ms0Insufficient traffic
3 requests, under the 20 Libra needs
POST /api/trpc/crm.searchConversationsMinimal0 → 20 → 0 (0%)not measured → 135 ms0Insufficient traffic
2 requests, under the 20 Libra needs
POST /api/trpc/crm.listConversations0 → 20 → 0 (0%)not measured → 1016 ms0Insufficient traffic
2 requests, under the 20 Libra needs
trpc.crm.searchConversationsMinimal0 → 10 → 0 (0%)not measured → 123 ms1Insufficient traffic
1 request, under the 20 Libra needs
crm.searchConversationsMinimal0 → 10 → 0 (0%)not measured → 122 ms1Insufficient traffic
1 request, under the 20 Libra needs
trpc.crm.loadCalendarConversationsBatch0 → 00 → 0 (0%)not measured0No traffic
No requests recorded since this deploy.
/api/trpc/crm.loadCalendarConversationsBatch0 → 00 → 0 (0%)not measured0No traffic
No requests recorded since this deploy.
POST /api/trpc/crm.loadCalendarConversationsBatch0 → 00 → 0 (0%)not measured0No traffic
No requests recorded since this deploy.
/api/trpc/crm.getConversations0 → 00 → 0 (0%)not measured0No traffic
No requests recorded since this deploy.

Changed files → surfaces

  • apps/server/src/services/crm/conversations.ts/api/trpc/crm.searchConversationsMinimal/api/trpc/crm.listConversationsPOST /api/trpc/crm.searchConversationsMinimalPOST /api/trpc/crm.listConversationstrpc.crm.searchConversationsMinimalcrm.searchConversationsMinimal+4
  • apps/server/src/services/crm/__tests__/conversation-field-definitions.test.tsno production surface mapped
  • apps/server/src/services/crm/__tests__/conversations.json-persist.test.tsno production surface mapped
  • apps/server/src/services/crm/conversation-field-definitions.tsno production surface mapped