Libra CodeHub

CedarCopilot/cedar-mail

Branch: staging

fix(crm): stop silently dropping unsupported conversation-lookup filters

merged#2882CedarCopilot

CedarCopilot wants to merge 3 commits into staging from worktree-agent-a0f9ba015e36f0475

What Libra verified

  • Date custom-field comparisons include the requested calendar boundary

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

    Technical evidence
    • gt, gte, lt, and lte on wm_ date fields compare current values as timestamps
    • a bare YYYY-MM-DD custom-field comparison input is normalized to noon UTC
    • a custom-field comparison input that already has a time component is preserved

    Changed code: findCRMConversationsTool.ts.

  • Empty membership filters retain their set semantics

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

    Technical evidence
    • an empty standard-field in filter produces a real condition that matches no conversations
    • an empty standard-field notIn filter produces a real condition that excludes no conversations
    • an empty custom-field in filter produces a real condition that matches no conversations
    • an empty custom-field notIn filter produces a real condition that excludes no conversations

    Changed code: conversation-lookup.ts.

  • Malformed custom-field text does not disrupt date-filtered lookups

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

    Technical evidence
    • each date comparison guards the stored custom-field text before casting it, so arbitrary or malformed content cannot disrupt the lookup
    • only values matching the date shape participate in each custom-field date comparison

    Changed code: conversation-lookup.ts.

Live on prod, watching, 2 days leftTimeline and evidence
  1. Opened
    Sep 22, 2026, 3:16 PM
  2. Sep 22, 2026, 4:02 PM
  3. Merged
    Sep 22, 2026, 4:26 PM
  4. Live on prod
    Sep 22, 2026, 4:26 PM
  5. Observed 0 hours
    Sep 22, 2026, 4:26 PM
  6. Watching

    Live on prod, watching, 2 days left

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

Behaviors Libra is checking

Conversation lookups can apply gt, gte, lt, and lte filters to date-shaped custom-field values such as wm_close_date, while non-date custom-field values match nothing instead of causing a timestamp cast error.Not checked
prod
Bare YYYY-MM-DD values used in custom-field date comparisons are normalized to noon UTC so lte filters include records stamped later on the same day, while values that already include a time remain unchanged.Not checked
prod
Empty in filters match no conversations and empty notIn filters match all conversations for both standard and custom fields instead of disappearing from the WHERE clause.Not checked
prod
Unsupported custom operators, unknown fields or operators, and non-array in/notIn values now fail the conversation lookup explicitly instead of being silently removed and running an unfiltered query.Not checked
prod

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

Summary

A chat-agent query filtering {field: "wm_close_date", operator: "gte", value: "2026-09-21"} (+ a paired lte) to answer "show me deals closing this week" silently ran unfiltered against the entire Deals AOP instead of erroring , the tool reported success: true with a wrong result set, and the agent had no signal that its date filter never applied. It then improvised a confused, hedged answer over data it assumed was already filtered.

Root cause: close_date is a real, correctly-mapped custom field , the field reference was right. But buildCustomFieldCondition in conversation-lookup.ts only implemented eq/neq/like/ilike/in/notIn/isNull/isNotNull for custom fields; its default branch for gt/gte/lt/lte did console.warn(...) (server logs only, invisible to the caller) and return null. lookupConversations's if (condition) conditions.push(condition) then silently dropped that null from the WHERE clause. The only filter left standing was aopName = 'Deals', so the query ran against the whole AOP.

The same silent-null-drop pattern also existed for: an unrecognized field/operator on a standard field, a non-array value on in/notIn, and , more subtly , an empty array on in/notIn (which used to silently drop the filter entirely, turning "match nothing" into "no filter applied").

Fix

buildFilterCondition / buildCustomFieldCondition now return a real SQL condition for every case, or throw , never a silent null. lookupConversations already had a top-level try/catch that turns a thrown error into { success: false, errorMessage }, so no new error-handling plumbing was needed; a bad filter now fails loudly through the existing path instead of vanishing.

Also adds real gt/gte/lt/lte support for date-shaped custom field values , the actual capability gap behind the incident, since "deals closing this week" against a date custom field is a completely reasonable query. Guarded with a regex check in the SQL itself (content ~ '^\d{4}-\d{2}-\d{2}') before casting to timestamptz, so a non-date field's content just matches nothing rather than throwing a Postgres cast error.

One bug caught by the new test itself, worth calling out: the date-guard regex initially used un-doubled \d inside a JS template literal , \d is not a recognized JS escape sequence, so JS silently strips it to d, which would have sent Postgres the regex ^d{4}-d{2}-d{2} (four literal d characters, never matching a real date) instead of a real guard. The test's SQL-text assertion caught this immediately; fixed to \\d.

Scoped intentionally: buildEventCountCondition/buildEventTypeDateCondition (the eventFilter path) have the identical silent-null-drop pattern for an invalid event type or unparseable date, surfaced during review. Left untouched here on purpose , same bug class, but a different, unrelated code path, and this PR stays focused on the incident it was scoped to fix. Filed as a separate follow-up (Linear ticket linked below once created).

Test plan

  • New regression test (conversation-lookup.filter-conditions.test.ts, 15 cases) reproduces the exact filter shape from the live incident and asserts a real gte/timestamptz condition is built, not a dropped one
  • Covers: gt/gte/lt/lte on date-shaped custom fields, a genuinely unsupported operator now throwing instead of silently dropping, empty-array in/notIn on both custom and standard fields, non-array value on in/notIn throwing, computed binary fields (crmSynced/hasFutureCalendar) still building correctly
  • pnpm --filter @zero/server run types , 0 errors
  • pnpm --filter @zero/server exec vitest run on the new test file + findCRMConversationsTool.test.ts + findCRMConversationsTool.default-aop-scope.test.ts + table-source.test.ts , 63 tests, all pass
  • Verified all 4 callers of lookupConversations (findCRMConversationsTool.ts, resolveAndAttachTool.ts, `ta
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/tools/conversation/findCRMConversationsTool.tsno production surface mapped
  • apps/server/src/services/crm/__tests__/conversation-lookup.filter-conditions.test.tsno production surface mapped
  • apps/server/src/services/crm/conversation-lookup.tsno production surface mapped