Repo · motir-core. Root cause found — this is a one-line defect at three sites, not missing validation.
lib/mcp/tools/authorPlan.ts, proposedFieldsSchema (~line 205). Read the four fields together:
kind: z.enum(['epic', 'story', 'task', 'bug', 'subtask']) // closed ✅
type: z.string() // OPEN ❌
priority: z.enum(['lowest', 'low', 'medium', 'high', 'highest']) // closed ✅
executor: z.enum(['coding_agent', 'human']) // closed ✅
Every enum-valued sibling is a z.enum. type alone is a bare z.string(), and the fourteen legal members are demoted into a prose .describe() that ends in an ellipsis:
'Leaf work type (code / design / test / decision / manual / …).'
So the schema advertises five of fourteen members and then invites the reader to complete the pattern. That is exactly what happened: type: "migration" was written, and nothing between the tool boundary and prisma.workItem.create() had an opinion about it.
The same bare z.string() appears at three sites in that one file — all three doors that can put a type on a proposal:
| line | schema | door |
|---|---|---|
| ~214 | proposedFieldsSchema | add_plan_items, an add |
| ~272 | patchSchema | add_plan_items, a modify |
| ~449 | the update schema | update_plan_proposal |
lib/issues/executorDefaults.ts exports the list, as const, and its own doc comment says what it is for:
export const WORK_ITEM_TYPES = [
'code', 'design', 'test', 'content', 'copy', 'translate', 'research',
'review', 'verification', 'decision', 'deploy', 'manual', 'legal', 'chore',
] as const satisfies readonly WorkItemTypeDto[];
"Mirrors the
WorkItemTypePrisma enum 1:1. Exported so pickers / filter facets / the loader's validate-against-the-enum check read one list instead of re-declaring it."
create_work_item's own MCP schema already uses the full enum — so the constraint is enforced on the direct-create door and absent on the plan door. z.enum(WORK_ITEM_TYPES) at the three sites is the whole fix, and it makes the fourteen members visible in the tool schema an author actually reads, which is the half that prevents recurrence.
Plan cmtb6zjgt003whvn8zbgc6e9h, authored over MCP on 2026-08-27, one add carrying type: "migration":
add_plan_items — accepted, stored, returned the item id.add_plan_items({ proposals: [], final: true }) — closed to planned.validate_plan → {"valid": true, "blockers": [], "rejections": []}POST /api/plans/<id>/approve → 500, empty body.fly logs -a motir-core:
⨯ Error [PrismaClientValidationError]:
Invalid `prisma.workItem.create()` invocation:
{ data: { … type: "migration",
~~~~~~~~~~~ … } }
Invalid value for argument `type`. Expected WorkItemType.
The rollback is clean. Both attempts proposed the same MOTIR-3656 for the failing child, so the project key counter never advanced, and MOTIR-3655 — the story that materialized first — does not exist.
validateProposals.ts types the proposal as proposedFields: { kind?: string | null } | null — it reads kind and nothing else. That is a deliberate narrowing, but the file's own header argues against itself here:
"the approved proposal set can be edited between generation and approve (
updateProposal), so the proposal is NOT trusted here. Core re-checks — defense in depth."
It validates kind against isIssueType for exactly that reason. type deserves the same check by the same argument, and it is what makes validate_plan honest rather than merely quiet.
approve maps a PrismaClientValidationError to a bare 500 with an empty body. MOTIR-3396 set the precedent one failure over — P2028 → a typed 503 carrying planId and itemCount. A validation error should get a 422 naming the proposal, the field and the value. An empty body is why the user pressed Approve twice.
z.string() sites become z.enum(WORK_ITEM_TYPES), importing the existing constant — no fourth copy of the list. git grep proves no bare z.string() type remains in authorPlan.ts.add_plan_items refuses type: "migration" at append with a message naming the legal members; asserted for an add AND for a modify patch, and for update_plan_proposal.validateProposals.ts widens ProposalNode.proposedFields to read type and rejects an out-of-enum value with a PlanGrammarError, the way issueKindOf rejects an unknown kind — so a proposal edited between generation and approve is still caught.approve maps PrismaClientValidationError to a typed 4xx naming the proposal and the field; never a bare 500.type: "migration" → refused at the door; force one into the row → validate_plan reports valid: false; force it past validate → approve answers the typed error, not a 500.Worth stating, because it is the generalisable half. kind, priority and executor are all closed sets and all written as z.enum, so a wrong value is refused at the boundary with the legal members in the error. type is the same kind of field and was written as free text with the members in prose. A closed set described in a .describe() string is not a constraint — it is a hint, and an ellipsis at the end of a hint reads as an invitation to extrapolate. Every enum-valued argument on every MCP tool should be a z.enum over the exported constant; a sweep for the pattern is cheap and probably finds more than this one.
lib/mcp/tools/authorPlan.ts — the three schemas, lines ~214 / ~272 / ~449.lib/issues/executorDefaults.ts — WORK_ITEM_TYPES, already exported for this.lib/plans/validateProposals.ts — ProposalNode, issueKindOf, and the defense-in-depth header.lib/services/plansService.ts — approvePlan and the MOTIR-3396 P2028 → 503 mapping this follows.prisma/schema.prisma — enum WorkItemType.Plan cmtb6zjgt003whvn8zbgc6e9h, project cmqfb4d8q000e2d0i6n62otyc, machine 7817663f103648, 2026-08-27T07:36:29Z and 07:36:52Z. Read against origin/main at d4072154c. Corrected in place with update_plan_proposal, which is legal while a plan is planned.