MotirBuilding in public
MOTIR · moooon
onMotir
You’re viewing a public project. Anyone can view it — no account needed. Sign in to submit, upvote, or comment on requests.View-only — you can’t edit work items
MOTIR-3654

A plan proposal's `type` is a bare `z.string()` while every enum beside it is a `z.enum` — an out-of-enum value is stored, passes validate, and 500s at approve

Done
Description

Repo · motir-core. Root cause found — this is a one-line defect at three sites, not missing validation.

The defect

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:

lineschemadoor
~214proposedFieldsSchemaadd_plan_items, an add
~272patchSchemaadd_plan_items, a modify
~449the update schemaupdate_plan_proposal

The fix already exists in the repository

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 WorkItemType Prisma 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.

Reproduction — observed, not read

Plan cmtb6zjgt003whvn8zbgc6e9h, authored over MCP on 2026-08-27, one add carrying type: "migration":

  1. add_plan_items — accepted, stored, returned the item id.
  2. add_plan_items({ proposals: [], final: true }) — closed to planned.
  3. validate_plan{"valid": true, "blockers": [], "rejections": []}
  4. POST /api/plans/<id>/approve500, 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.

The two secondary defects, both downstream of the first

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.

Acceptance criteria

  1. All three 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.
  2. 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.
  3. 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.
  4. approve maps PrismaClientValidationError to a typed 4xx naming the proposal and the field; never a bare 500.
  5. A regression fixture reproduces the plan above end to end: append 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.
  6. The clean-rollback property is asserted rather than assumed: a failed approve leaves no work item and does not advance the key counter.

Why the sibling fields were right and this one was not

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.

Context refs

  • lib/mcp/tools/authorPlan.ts — the three schemas, lines ~214 / ~272 / ~449.
  • lib/issues/executorDefaults.tsWORK_ITEM_TYPES, already exported for this.
  • lib/plans/validateProposals.tsProposalNode, issueKindOf, and the defense-in-depth header.
  • lib/services/plansService.tsapprovePlan and the MOTIR-3396 P2028 → 503 mapping this follows.
  • prisma/schema.prismaenum WorkItemType.

Seen on

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.