CedarCopilot wants to merge 2 commits into staging from fix/no-obsolete-crm-approval-cards
Live on prod, no production signal yet
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 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.
todo field-approval tasks fleet-wide propose a value that already equals what is stored (up from 40 at diagnosis time). 17 belong to .updated_at = created_at , born obsolete, never touched since.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).
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.
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:
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.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.
Libra has not measured any production surfaces for this change yet.