CedarCopilot wants to merge 3 commits into staging from fix/config-agent-audit-fixes
Live on prod, no production signal yet
Libra has verdicts on 0 of 10 tracked behaviors on prod; 10 are still being checked. Libra checks hourly for 3 days after each deploy.
Five fixes from the audit of two weeks of production config-agent sessions. One commit per bug, plus one commit of thermo-review fixes.
337 failures / 104 threads / 14 days , the most common failure in the corpus by a factor of six. Family tools take params in an action-named sub-object; agents send them flat, fail, retry nested, succeed. It recurs within the same thread after being corrected. No user-visible damage, but it doubles latency on every config operation and burns context during exactly the tasks that need the most room.
The nesting is not a deliberate API design , it exists only because the MCP SDK's normalizeObjectSchema() will not convert a top-level union to JSON Schema (typescript-sdk#1643), so accepting the flat form costs nothing semantically.
Layer chosen: the MCP registration boundary, not family-tool-dispatch.ts. Verified against the installed packages rather than assumed , there are two Zod parses in front of every family-tool call, and both strip unknown keys before any of our code can read them:
@modelcontextprotocol/sdk's tools/call handler calls validateToolInput() and only then executeToolHandler(), so even the registerTool callback gets already-stripped args.@mastra/core's createTool wraps the supplied execute with validateToolInput(this.inputSchema, …) and invokes the original body with the parsed data, so family-tool-dispatch.ts , which runs inside that body , never sees a flat key either.So the fix is two halves at registration (mcp/external/flat-args.ts):
allowFlatArgs() widens the registered schema with .passthrough() so the stray keys survive. Deliberately not a z.preprocess() wrapper , that yields a ZodEffects, which has no .shape, which reintroduces the exact {properties:{}} bug above. .passthrough() returns a real ZodObject, and survives the .extend() injectIntentFields applies afterwards.normalizeFlatArgs() re-nests them in the handler, before the scope check , this ordering is a security invariant, not a style choice (see below).Rewriting is refused unless the action names a real params object on the tool's own unwidened schema, and a call sending both forms keeps the nested one.
Read-back was skipped on 14 of 36 playbook writes, with several threads ending in confident "it's set up" claims. The rule lives in an always-loaded SKILL.md and still lost to the agent's desire to answer the user.
writePlaybookXml already compiles the manifest and persists it in the same write , the verification the agent is told to go and fetch was already in scope, just discarded. Now returned as a compact triggerSummary (eventTypes, fieldWatchers, cronSchedules, beforeMeeting, webhooks, refIds), one short line each, threaded through all four boundaries that would otherwise drop it. Built only after verifyPlaybook passes, so it never describes a reverted write. Empty lists are kept , "the cron you asked for is NOT armed" is the failure this makes visible.
Root cause of the largest failure class: 233 user-scope playbook writes vs 5 at org scope, and in 88 threads the agent never once asked which was meant. It wasn't choosing badly , it was blind.
Adds <organization> to the per-user <runtime_context>: member count (excluding archived), the caller's organizationRole, and whether their playbooks inherit from a shared org playbook. Each stated with what it implies , a one-person org is told user scope is trivially correct and not to ask; a multi-person org is told the question is real.
targetUserId is built and invisible8 uses in 1,698 config/document calls, almost all the staff debug tool. Not one session had an admin edit a teammate's
Libra has not measured any production surfaces for this change yet.