Libra CodeHub

CedarCopilot/cedar-mail

Branch: staging

fix(crm): stop creating approval cards for changes that already happened

merged#2679CedarCopilot

CedarCopilot wants to merge 2 commits into staging from fix/no-obsolete-crm-approval-cards

Live on prod, no production signal yetTimeline and evidence
  1. Opened
    Sep 3, 2026, 7:30 PM
  2. Sep 3, 2026, 7:55 PM
  3. Merged
    Sep 3, 2026, 8:24 PM
  4. Live on prod
    Sep 3, 2026, 8:24 PM
  5. Observed 0 hours
    Sep 3, 2026, 8:24 PM
  6. Unobserved

    Live on prod, no production signal yet

    Sep 3, 2026, 8:24 PM
  7. Pipelines steady after this deploy
    Sep 3, 2026, 8:24 PM

Behaviors Libra is checking

The cleanup job must perform no writes by default and must only cancel tasks when invoked with `APPLY=1`.Not checked
prod
The obsolete-approval cleanup job must identify open field-approval tasks whose every non-null typed or legacy proposal already matches the live conversation and mark them `agent_deleted`, while leaving stub-only or unparseable tasks open.Not checked
prod
Before cancelling each historical card, the cleanup job must re-read its live proposal and require the task still to be `todo`, leaving cards open if the CRM or a user changed them between discovery and cancellation.Not checked
prod
A matching proposal must not suppress a second proposal for the same field when that second proposal requests a different value.Not checked
prod
CRM-required companion fields that are represented as null approval stubs must not be re-added to a card after the corresponding real proposal has already matched the stored value.Not checked
prod
A proposal must remain eligible for approval when its current value cannot be positively read, when the conversation row or custom-field row is absent, or when the proposal is an empty clear request.Not checked
prod

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

The bug

The CRM background sync ingests a HubSpot/Salesforce deal that is already at a terminal stage and seeds crm_conversations.status; seconds later the crm-updater agent proposes that same stage and updateConversationFieldsTool writes a field-approval card for it, because nothing on the creation path compared the proposed value against the current one. The card is born obsolete , typically ~70 seconds after the conversation row is created , and asks the user to approve a change that has already happened.

Verified numbers (re-queried 2026-09-03, read-only)

  • 52 open todo field-approval tasks fleet-wide propose a value that already equals what is stored (up from 40 at diagnosis time). 17 belong to .
  • 50 of the 52 have updated_at = created_at , born obsolete, never touched since.
  • 0 of the 52 have ever been read.
  • Of 179 open stage-batch cards: 163 carry status alone, 16 carry 2,5 fields. (The single 19-field card in the original diagnosis is gone; the shape holds , almost every card is a lone status proposal.)

Cross-checked two ways that agree exactly: a hand-written SQL query against prod, and the cleanup script's own dry run (below).

The fix

updateConversationFieldsTool §2.7 now drops every deferred approval field whose proposed value already equals the stored one, before the stage-batch / single-field branch is chosen , so both branches are covered by one guard. If nothing real is left, no card is created at all and the tool returns a userMessage telling the agent not to retry.

Comparison goes through the normaliser that the CRM→Cedar mirror already used (lowercase, whitespace→underscore, semicolon-delimited multi-selects sorted), so Architectural, MEP vs architectural;mep is not read as a difference. That normaliser, convRowValueToString, and the CONVERSATION_METADATA_FIELDS set moved out of crm-field-sync.ts into services/crm/conversation-field-comparison.ts , the guard and the mirror now share one definition of "already equals" instead of two that can drift.

findUnchangedProposals matches per PROPOSAL, not per field name: nothing dedupes the incoming fields array, so one call can carry two approval proposals for the same field, and reporting by name would let the matching one silence the differing one. It is otherwise deliberately conservative , it reports a proposal unchanged only when it positively read a current value that matches a non-empty proposal. A field whose row it could not read, a list field (which accumulates rows, so equality is not single-valued), and an empty proposal are all reported as changed, so an unreadable field can never silence a card.

Decision on null stubs

A value: null stub is a passenger, never a reason to keep a card alive.

The stubs come from resolveStageRequiredFields and exist to prompt the user for a value the CRM's validation requires at the target stage. They are never compared (equality against null is meaningless), and they cannot by themselves keep a card alive, because:

  1. A stub only exists inside a stage batch, and a stage batch is only built when the status entry survives the guard. If the stage move is a no-op, there is no batch and therefore no stubs. The card's own description reads "move to Closed Won" , with the move already done, it is asking the user to approve nothing.
  2. The stub is injected unconditionally whenever the mapped Cedar field is not already in the batch, so a card of only stubs is not evidence that anything is actually missing.
  3. If the deal is already at that stage in the CRM, the CRM already accepted it , its required-field rule is satisfied.

The corollary, also implemented: a companion field dropped as already-matching is not re-added as an empty stub (updateConversationFieldsTool.ts, the requiredCrmFields loop). Otherwise the guard would replace a correct pre-filled value with a blank prompt , strictly worse than before.

Clean

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/scripts/cancel-obsolete-field-approvals.tsno production surface mapped
  • apps/server/scripts/run-cancel-obsolete-field-approvals.mjsno production surface mapped
  • apps/server/src/mastra/tools/conversation/__tests__/updateConversationFieldsTool.unchanged-approval.test.tsno production surface mapped
  • apps/server/src/mastra/tools/conversation/updateConversationFieldsTool.tsno production surface mapped
  • apps/server/src/services/crm/__tests__/conversation-field-comparison.test.tsno production surface mapped
  • apps/server/src/services/crm/conversation-field-comparison.tsno production surface mapped
  • apps/server/src/services/crm/crm-field-sync.tsno production surface mapped