Libra CodeHub

CedarCopilot/cedar-mail

Branch: staging

feat: add create-aop and update-selection-procedure config-write actions

merged#2849CedarCopilot

CedarCopilot wants to merge 4 commits into staging from feat/create-aop-and-update-selection-procedure

Live on prod, watching, 2 days leftTimeline and evidence
  1. Opened
    Sep 20, 2026, 1:43 PM
  2. Sep 20, 2026, 6:00 PM
  3. Merged
    Sep 20, 2026, 6:04 PM
  4. Live on prod
    Sep 20, 2026, 6:04 PM
  5. Observed 6 hours
    Sep 20, 2026, 6:04 PM
  6. Watching

    Live on prod, watching, 2 days left

    Sep 20, 2026, 6:04 PM
  7. Pipelines steady after this deploy
    Sep 20, 2026, 6:04 PM

Behaviors Libra is checking

`create-aop` can create a new playbook for a user by cloning an existing user-owned AOP, copying its live configuration and document tree while applying the requested name, routing text, and optional conversation scope.Not checked
prod
Cloned playbooks receive fresh custom-field IDs and fresh subagent agent IDs, with owner references and document `<ref>` links rewritten to the newly created identities.Not checked
prod
`create-aop` rejects duplicate names, source AOPs owned by another user, and sources without a playbook tree, leaving no partially created AOP rows or documents after a failed clone.Not checked
prod
A caller cannot combine an organization-scoped AOP update with `targetUserId`, because that target is meaningless for an organization-owned resource.Not checked
prod
Selection-procedure updates only affect the AOP owned by the resolved user or organization, while wrong-owner and nonexistent targets are rejected with the same not-found behavior.Not checked
prod
`update-selection-procedure` replaces an AOP's routing text in full for either a user-scoped AOP or an organization-scoped AOP.Not checked
prod

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

Summary

Two new MCP write capabilities, codifying a real, previously hand-done operation: giving a customer a second playbook cloned from an existing one, then routing future conversations to it by a new rule.

  • create-aop , clone an existing user AOP into a brand-new AOP: conversationFieldDefinitions, customFieldDefinitions, displayConfig, scope/icon/color, plus the entire user/playbooks/{aopId}/ document tree (PLAYBOOK.md, resources/, subagents/). Always clones from the SOURCE's user-level row (never its linked org_aops row , the two can diverge, and the user-level row is what's actually live). cloneFromAopId is required; there is no from-scratch seeding path (that's a bigger, separate feature account-setup already owns differently).

  • update-selection-procedure , a plain full-replace write to an AOP's selection_procedure column (the routing text an LLM classifier reads to decide which AOP a new conversation belongs to), at scope: 'user' | 'org'. Already readable via config-read's read-playbook (mode: "list") but had no write path anywhere.

Ref-remap , the hard part

The source PLAYBOOK.md and subagent docs reference each other by document id via <ref id="..."> tags. Those ids don't exist until the clone writes mint them, so cloneAopDocuments (apps/server/src/services/aop/create-aop.ts) runs in two passes:

  1. Clone every document verbatim to user/playbooks/{newAopId}/... via writeDocument (never raw SQL, so content_yjs never goes stale for a doc the live editor can open), minting new document ids and building an old→new id map keyed by relative path.
  2. Re-scan every newly-written document and patch every <ref id="OLD"> to the new id.

findDanglingOldIds is the automated form of the manual "grep the whole tree for the old ids, expect zero matches" check this was verified against by hand in the real case , it runs at the end of every clone and throws (triggering a best-effort rollback of the two AOP rows + the whole new document subtree) rather than leaving a half-remapped playbook live. Covered by a full PGlite-backed integration test (create-aop.test.ts) with a real PLAYBOOK.md referencing two subagents and a subagent doc that refs another subagent , plus direct unit tests for remapRefIds/findDanglingOldIds in isolation.

Two identity bugs a byte-for-byte clone would have reintroduced, closed along the way (not explicitly called out in the original manual clone, but real correctness bugs against the current schema):

  • Custom field ids , crm_conversation_field_values.field_id is a global column with no aopId of its own; countFieldValues/deleteFieldValues query it by fieldId alone. Reusing the source's field ids would mean deleting a field on either AOP cascade-deletes values written under the other. Every cloned field gets a freshly minted id.
  • Subagent agent_id , a subagent's identity is metadata.agent_id (cron dispatch, findSubagentByAgentId, field-ownership resolution). Cloning a subagent doc's frontmatter verbatim with no override would make reconcileSubagentWrite adopt the SOURCE's live agent_id on the new doc , two rows resolving to one identity. Every cloned subagent doc mints a fresh agent_id, and any ownerAgentId claim in the cloned field/display config pointing at the old subagent is repointed at its new one (remapOwnerAgentIds).

Schema-size decision (measured, not assumed)

Ran the actual budget test (mcp/external/__tests__/tool-inputschema-size.test.ts, ceiling 18,000 bytes):

  • config-write today (its existing 10 actions, unmodified): 14,445 bytes
  • These two new actions together, as their own tool: 2,947 bytes

Summed that's 17,392 bytes , technically under the ceiling, but leaving only ~600 bytes of headroom on what would become a 12-action tool, the exact fragility that forced connection-write off config-write previously (past the ceiling, the connector silen

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/mcp/external/scope-map.tsno production surface mapped
  • apps/server/src/mastra/mcp/external/server.tsno production surface mapped
  • apps/server/src/mastra/tools/config/aop-write-tool.tsno production surface mapped
  • apps/server/src/mastra/tools/config/config-tool-shared.tsno production surface mapped
  • apps/server/src/mastra/tools/config/config-write-tool.tsno production surface mapped
  • apps/server/src/services/aop/__tests__/create-aop.test.tsno production surface mapped
  • apps/server/src/services/aop/__tests__/update-selection-procedure.test.tsno production surface mapped
  • apps/server/src/services/aop/create-aop.tsno production surface mapped
  • apps/server/src/services/aop/update-selection-procedure.tsno production surface mapped
  • apps/server/src/services/aop/user-aops.tsno production surface mapped
  • apps/server/src/services/auth/__tests__/target-user-id-reaches-the-authority.test.tsno production surface mapped