Libra CodeHub

CedarCopilot/cedar-mail

Branch: staging

feat(crm): per-connection deal write guard

merged#2917CedarCopilot

CedarCopilot wants to merge 2 commits into staging from worktree-send-it-deal-write-guard

What Libra verified

  • Conversation field updates tell agents when CRM fields were not written

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

    Technical evidence
    • a partial CRM result names every field whose push was skipped
    • an all-skipped CRM result names every field that was not pushed

    Changed code: updateConversationFieldsTool.ts.

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

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

    Sep 23, 2026, 12:48 PM
  7. Pipelines steady after this deploy
    Sep 23, 2026, 12:48 PM

Behaviors Libra is checking

On atomic CRM writes, skipped fields are not saved in Cedar, are identified to the agent as skippedCrmFields, and an all-skipped result is reported as an expected non-alerting failure rather than success.Not checked
prod
On fire-and-forget CRM writes, skipped fields remain saved in Cedar but are explicitly reported as savedButNotPushedFields so the user is told that the CRM was not updated.Not checked
prod
When the guard matches, agent, chat, and UI CRM writes are skipped before the provider driver is called while the linked Cedar fields remain unmodified on the atomic path.Not checked
prod
Each blocked deal push produces a structured deal_write_guard_blocked log containing the conversation, deal, matched guard pairs, snapshot creation time, and attempted field names.Not checked
prod
Operators can configure or explicitly clear a per-connection HubSpot deal write guard containing field/value pairs through the account configuration tool.Not checked
prod
A configured guard does not block a CRM write when no usable latest CRM snapshot exists.Not checked
prod

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

Summary

  • Adds an optional per-connection dealWriteGuard (same field -> value shape as dealSyncFilter). When every pair matches the deal's last stored snapshot, the CRM push is skipped before the driver is called. It is enforced at the single updateOpportunityFields call site in update-external-crm-workflow.ts, so agents, chat, approvals and UI edits all pass through it.
  • Why: a signed renewal wrote contract start, renewal date, TCV and amount onto the original closed-won deal. The renewal deal is owned by another HubSpot user, so it never syncs, and the original was the only deal linked to the conversation. Guarding { "hs_is_closed": "true" } stops writes to closed deals.
  • A block returns { success: false, skipped: true, skipReason: 'deal_write_guard' }, so callers record skipped, not pushed. An absent guard key or a missing snapshot lets the write through (logged).
  • Callers now handle a skipped push honestly: updateConversation / upsertWorkingMemory treat only the guard skip as a non-error (all other skips still throw as before); updateExternalCrm reports the real workflow result; update-conversation-fields tells the agent when a push was skipped (see below).
  • Design, decisions and known limitations: docs/design/deal-write-guard.md.

Behaviour changes that are NOT guard-scoped (apply to every customer)

  • On the event-execution path, update-conversation-fields now returns success: false with isExpected: true (no "Tool Call Failed" alert) when every field's CRM push was skipped for any reason (no connection, ownership mismatch, guard...). Before, it returned a silent success: true; nothing was saved in Cedar either way, the agent just was not told. The partial case reports skippedCrmFields. On the chat/MCP/approval path Cedar is written first, so a skipped push returns savedButNotPushedFields.
  • updateExternalCrm (tRPC) no longer reports success for a skipped or failed workflow. It has no callers under apps or packages.
  • readCrmObjectSettings reads dealSyncFilter through a validating parser (malformed stored value reads as {}) instead of a cast.
  • A shared lib/edit-distance.ts replaces the duplicate Levenshtein in kb/helpers.ts (fuzz-compared against the old implementation, no mismatches).

Known limitations (documented in the design doc)

  • The snapshot refreshes every few hours, so a deal that closes and is written to before the next sync is not yet guarded.
  • Approval-card apply (user-tasks.ts) fires the push without recording the result: on a guarded deal the task shows as applied while the CRM is not written. Follow-up.
  • Only HubSpot's snapshot shape was verified. Do not set a guard on Salesforce or Attio connections yet.
  • The real fix for the trigger case is linking renewal deals to the company's conversation regardless of owner; the guard is the safety net. Separate design.

Test plan

  • pnpm dlx oxlint@1.78.0 --deny-warnings: exit 0. pnpm deps:check: no violations. check-brand-assets: pass. pnpm --filter @zero/server run types: exit 0 (also after merging staging).
  • Scoped vitest, 16 files / 360 tests pass, including: deal-write-guard.test.ts (evaluator plus the snapshot SQL against PGlite: newest-row ordering, provider/deal/object scoping, Deal vs Lead collision, never reads dealData.raw, schema-drift check), update-external-crm-workflow.test.ts (driver never called on a block, every skipReason), connection-config.test.ts (storage, wipe guard, key validation and override), updateConversationFieldsTool.test.ts (all four skip/fail combinations, real shouldNotifyToolCallError), the two tRPC partial-failure files and mastra-update-external-crm.test.ts.
  • Not run locally: the full vitest run src/ and the @zero/mail jest suite (repo rule against unscoped vitest runs, and no mail changes); CI runs them.
  • Checked against real data (read-only): all 97,809 snapshot rows for the affected reps have the promoted columns popu
Show production surfaces and changed-file mapping

Production surfaces

SurfaceRequestsErrorsp95UsersVerdict
/api/trpc/crm.listConversations0 → 30 → 0 (0%)not measured → 2105 ms0Insufficient traffic
3 requests, under the 20 Libra needs
/api/trpc/crm.getConversations0 → 30 → 1 (33%)not measured → 22 ms0Insufficient traffic
3 requests, under the 20 Libra needs
POST /api/trpc/crm.listConversations0 → 20 → 0 (0%)not measured → 2105 ms0Insufficient traffic
2 requests, under the 20 Libra needs
POST /api/trpc/crm.getConversations0 → 20 → 1 (50%)not measured → 22 ms0Insufficient traffic
2 requests, under the 20 Libra needs
POST /api/trpc/mastra.chatWarm0 → 00 → 0 (0%)not measured0No traffic
No requests recorded since this deploy.
/api/trpc/mastra.chatStream0 → 00 → 0 (0%)not measured0No traffic
No requests recorded since this deploy.
/api/trpc/crm.searchConversationsMinimal0 → 00 → 0 (0%)not measured0No traffic
No requests recorded since this deploy.
POST /api/trpc/crm.searchConversationsMinimal0 → 00 → 0 (0%)not measured0No traffic
No requests recorded since this deploy.
crm.searchConversationsMinimal0 → 00 → 0 (0%)not measured0No traffic
No requests recorded since this deploy.
trpc.crm.searchConversationsMinimal0 → 00 → 0 (0%)not measured0No traffic
No requests recorded since this deploy.
/api/trpc/mastra.chatWarm0 → 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.

Changed files → surfaces

  • apps/server/src/services/crm/conversations.ts/api/trpc/crm.listConversations/api/trpc/crm.getConversationsPOST /api/trpc/crm.listConversationsPOST /api/trpc/crm.getConversations/api/trpc/crm.searchConversationsMinimalPOST /api/trpc/crm.searchConversationsMinimal+3
  • apps/server/src/trpc/routes/mastra.tsPOST /api/trpc/mastra.chatWarm/api/trpc/mastra.chatStream/api/trpc/mastra.chatWarm
  • apps/server/src/lib/__tests__/edit-distance.test.tsno production surface mapped
  • apps/server/src/lib/edit-distance.tsno production surface mapped
  • apps/server/src/mastra/skills/account-config/tools/applyConfigChangeTool.tsno production surface mapped
  • apps/server/src/mastra/skills/account-config/tools/readConnectionSettingsTool.tsno production surface mapped
  • apps/server/src/mastra/tools/conversation/__tests__/updateConversationFieldsTool.test.tsno production surface mapped
  • apps/server/src/mastra/tools/conversation/updateConversationFieldsTool.tsno production surface mapped
  • apps/server/src/mastra/workflows/sync/__tests__/update-external-crm-workflow.test.tsno production surface mapped
  • apps/server/src/mastra/workflows/sync/update-external-crm-workflow.tsno production surface mapped
  • apps/server/src/services/crm/__tests__/crm-field-sync-summary.test.tsno production surface mapped
  • apps/server/src/services/crm/__tests__/deal-write-guard.test.tsno production surface mapped
  • apps/server/src/services/crm/deal-write-guard.tsno production surface mapped
  • apps/server/src/services/crm/external-crm-events.tsno production surface mapped
  • apps/server/src/services/integrations/__tests__/connection-config.test.tsno production surface mapped
  • apps/server/src/services/integrations/connection-config.tsno production surface mapped
  • apps/server/src/services/kb/helpers.tsno production surface mapped
  • apps/server/src/trpc/routes/__tests__/crm-update-conversation-partial-failure.test.tsno production surface mapped
  • apps/server/src/trpc/routes/__tests__/crm-upsert-working-memory-partial-failure.test.tsno production surface mapped
  • apps/server/src/trpc/routes/__tests__/mastra-update-external-crm.test.tsno production surface mapped
  • apps/server/src/trpc/routes/crm.tsno production surface mapped
  • docs/design/deal-write-guard.mdno production surface mapped