Libra CodeHub

CedarCopilot/cedar-mail

Branch: staging

fix(chat): let authorized humans edit agent-owned deal fields; make fetch and write verdicts truthful

merged#2918CedarCopilot

CedarCopilot wants to merge 4 commits into staging from worktree-send-it-chat-field-write-permissions

What Libra verified

  • CRM synchronization preserves definition-based field writes

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

    Technical evidence
    • external CRM synchronization is identified without granting a human ownership override
    • ordinary non-sync writes retain the distinct human actor needed for ownership checks

    Changed code: updateConversationFieldsTool.ts.

  • Fetch and write expose the same per-field write verdict

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

    Technical evidence
    • a genuine in-app chat context carries the human identity needed for its writable field verdict
    • automation and chat-descended subagents remain blocked by the same field restrictions instead of inheriting human write authority

    Changed code: fetchConversationTool.ts.

  • Human field-write authority is not inherited by delegated runs

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

    Technical evidence
    • the chat agent's own run carries actingHumanUserId while preserving the attributed userId
    • a run marked withoutHumanAuthority keeps user attribution but cannot carry the human field-write override
    • automation and external MCP runs retain user attribution but cannot carry human field-write authority

    Changed code: surface-context.ts.

Live on prod, 1 of 4 surfaces working, 2 days leftTimeline and evidence
  1. Opened
    Sep 23, 2026, 12:21 PM
  2. Sep 23, 2026, 1:34 PM
  3. Merged
    Sep 23, 2026, 1:43 PM
  4. Live on prod
    Sep 23, 2026, 1:43 PM
  5. Observed 1 hour, 4 surfaces, 290 requests
    Sep 23, 2026, 1:43 PM
  6. Watching

    Live on prod, 1 of 4 surfaces working, 2 days left

    Sep 23, 2026, 1:43 PM
  7. Pipelines steady after this deploy
    Sep 23, 2026, 1:43 PM

Behaviors Libra is checking

The chat agent is instructed to use writable rather than agentEditsForbidden and to tell users exactly which requested fields were not written and why without inventing causes.Not checked
prod
External CRM synchronization writes bypass human ownership and policy lookups so an ownership or authority lookup failure cannot abort the CRM sync, while fetched non-taxonomy fields are treated as writable for that sync actor.Not checked
prod
fetch-conversation annotates each returned field with whether the current caller may write it and, when not, the same blockedBy, ownerAgentName, and blockedReason that the write guard will enforce.Not checked
prod
agentEditsForbidden remains a caller-independent definition flag in fetched and cached conversation data, while caller-specific write permission is represented only by writable and its verdict fields.Not checked
prod
In-app and headless chat runs carry a server-set acting human identity for field-write authorization, while automation, external MCP, and chat-spawned subagent runs do not inherit that authority.Not checked
prod
A policy-authorized human acting through chat can update fields owned by another agent, with editors able to override ownership and organization admins additionally able to override agent-edit locks.Not checked
prod

Libra measured 1 of 4 production surfaces on prod. 3 surfaces had no requests at all. Libra checks hourly for 3 days after each deploy.

Summary

  • Chat could not edit nextSteps / statusOverview on a deal, and the agent looped about why. Those core fields are stamped as owned by the next-steps and crm-updater agents (stampCoreFieldOwnership). The write guard forbade any owned field to a caller that is not the owner, and the chat agent is never the owner. The error then blamed a "human-only" flag that was never set, fetch-conversation reported the fields as writable, and a partial write returned success: true with the blocked field silently dropped.
  • Authorized humans in chat can now write owned fields. The override is carried by a new server-set actingHumanUserId context key, set only at genuine human chat entry points (in-app chat, headless chat, proposal apply). Subagents the chat spawns (orchestrator, dispatch, SDK Task-delegates) do not get it and are classified like any automation. Authority: read-only seats never; deal owner or editor member via canWriteConversation; org admin via authorize() (write on conversation). agentEditsForbidden locks stay respected except for an admin of the conversation's own org. Any lookup failure falls back to no override.
  • Fetch and write now share one classifier (loadFieldWriteFacts + classifyFieldWrites). Fetch reports per-field writable / blockedBy (owner, locked, system, background) / ownerAgentName / blockedReason. agentEditsForbidden stays definition-only so automation consumers (next-steps, crm-updater) are unaffected. Writes return blockedFields with reasons on every result, including partial success, and the all-blocked error states the real reason and the way out. The chat error-handling prompt now says never to guess a cause for an unwritten field.

Behavior changes to be aware of:

  • The write guard now refuses scope (it was already shown as read-only in fetch; nothing in skills, prompts or playbook defaults writes it through this tool).
  • A failed definitions or ownership load now aborts a non-sync write with the existing retry error instead of silently dropping custom fields or turning owner protection off. CRM-sync writes load only definitions.
  • Background and json field ids now come from the effective (org-preferred) definitions, unioned with the user AOP's, instead of the user AOP only.
  • There is no separate "deal editor" role in authorize(); deal membership lives in crm_conversation_users and is checked via the existing canWriteConversation predicate (also used by move-event). Folding membership into authorize() is a follow-up.

For the reviewer to decide:

  • Headless chat surfaces (Slack/iMessage) apply without an approval card and get the same override as in-app chat.
  • Admin-view/staff chat: actingHumanUserId is the viewed user, so the audit log shows the viewed user, not the staff member.
  • Thermo review hit the 3-round cap; the third round's findings were fixed but not re-reviewed.

Test plan

  • pnpm run autofix:local: everything passes except one apps/mail Jest test, tests/modules/chat-store/chatHistoryList.test.ts ("Yesterday" bucket, date-dependent, no apps/mail changes in this PR). The chain stops there, so the rest was run separately:
  • pnpm --filter @zero/server exec vitest run src/: 1153 files, 14299 tests passed.
  • pnpm --filter @zero/server run types and pnpm deps:check: clean.
  • New/extended tests: resolver and policy (field-write-access.test.ts, field-write-facts.test.ts), fetch verdicts, field-write-actor, surface and harness contexts, and updateConversationFieldsTool.test.ts including a 20+ case matrix asserting fetch and the write guard return the same verdict for (owned, locked, system, plain) x (chat admin, chat editor, unauthorized, agent owner, agent non-owner, chat-spawned subagent).
  • Agent-feature note: the only prompt change is one added instruction in mastra/prompts/error-handling.ts; not fire-rate evaluated, since it only governs how an unwritten field is reported.

Verify in p

Show production surfaces and changed-file mapping

Production surfaces

SurfaceRequestsErrorsp95UsersVerdict
db.getConnectionRecord253 → 2906 → 1 (0.3%)31 ms → 30 ms0Working
290 requests since the deploy with 1 errors (6 errors in the 253-request baseline before it). p95 30 ms, was 31 ms.
aws-primary.syncThread1 → 00 → 0 (0%)246 ms → not measured0No traffic
No requests recorded since this deploy.
cedar.analytics.draft.create1 → 00 → 0 (0%)128 ms → not measured0No traffic
No requests recorded since this deploy.
execute_tool write-document0 → 00 → 0 (0%)not measured0No traffic
No requests recorded since this deploy.

Changed files → surfaces

  • apps/server/src/mastra/context/values.tscedar.analytics.draft.create
  • apps/server/src/services/auth/authorize.tsdb.getConnectionRecordaws-primary.syncThreadexecute_tool write-document
  • apps/server/src/mastra/context/__tests__/surface-context.test.tsno production surface mapped
  • apps/server/src/mastra/context/surface-context.tsno production surface mapped
  • apps/server/src/mastra/prompts/error-handling.tsno production surface mapped
  • apps/server/src/mastra/tools/conversation/__tests__/fetchConversationTool.field-verdicts.test.tsno production surface mapped
  • apps/server/src/mastra/tools/conversation/__tests__/field-write-actor.test.tsno production surface mapped
  • apps/server/src/mastra/tools/conversation/__tests__/updateConversationFieldsTool.test.tsno production surface mapped
  • apps/server/src/mastra/tools/conversation/__tests__/updateConversationFieldsTool.unchanged-approval.test.tsno production surface mapped
  • apps/server/src/mastra/tools/conversation/conversationTool.schemas.tsno production surface mapped
  • apps/server/src/mastra/tools/conversation/fetchConversationTool.tsno production surface mapped
  • apps/server/src/mastra/tools/conversation/field-write-actor.tsno production surface mapped
  • apps/server/src/mastra/tools/conversation/updateConversationFieldsTool.tsno production surface mapped
  • apps/server/src/mastra/utils/__tests__/live-editable-field.test.tsno production surface mapped
  • apps/server/src/mastra/utils/context-formatting/__tests__/field-write-verdicts.test.tsno production surface mapped
  • apps/server/src/mastra/utils/context-formatting/conversation-context.tsno production surface mapped
  • apps/server/src/mastra/utils/context-formatting/core-fields.tsno production surface mapped
  • apps/server/src/mastra/utils/context-formatting/field-write-verdicts.tsno production surface mapped
  • apps/server/src/mastra/workflows/chat/chat-workflow.tsno production surface mapped
  • apps/server/src/mastra/workflows/chat/harness/__tests__/request-context.test.tsno production surface mapped
  • apps/server/src/mastra/workflows/chat/harness/request-context.tsno production surface mapped
  • apps/server/src/mastra/workflows/chat/run-chat-agent-sdk.tsno production surface mapped
  • apps/server/src/mastra/workflows/event-execution/pre-execution-setup.tsno production surface mapped
  • apps/server/src/services/aop/field-ownership.tsno production surface mapped
  • apps/server/src/services/conversations/__tests__/field-write-access.test.tsno production surface mapped
  • apps/server/src/services/conversations/__tests__/field-write-facts.test.tsno production surface mapped
  • apps/server/src/services/conversations/__tests__/helpers/field-write-fakes.tsno production surface mapped
  • apps/server/src/services/conversations/core-field-names.tsno production surface mapped
  • apps/server/src/services/conversations/field-write-access.tsno production surface mapped
  • apps/server/src/services/conversations/field-write-facts.tsno production surface mapped