11.2.7 `GET` + `POST /api/v1/work-items/{key}/transitions` — the legal-move list and the status move that teaches on refusal
Moving an item through its workflow is the operation an integration reaches for second (after reading), and Motir's shipped behaviour on refusal — an illegal move returns the allowed targets — is what the story calls the thing that makes the API self-teaching. This card ships it as a sub-resource, in the shape the mirror uses.
Rung 1 — the shape is Jira's, not invented: Jira exposes GET /issue/{key}/transitions (what can I do from here?) and POST /issue/{key}/transitions (do it). A client that can ASK is not reduced to attempting a move and parsing the failure, and a UI built on this API can render exactly the buttons the workflow allows. app/api/v1/work-items/[key]/transitions/route.ts carries both exports.
What to build
GET,scope: 'read'— the transitions legal FROM the item's current status, each{ key, label, category }, read throughworkflowsService.getWorkflowfor the item's project. Not the whole workflow graph: the question is "what can this item do now", and the full graph is a project resource (11.3's territory if it is ever wanted).POST,scope: 'work_items:write'— body{ status }, applied viaworkItemsService.updateStatus(id, toKey, ctx). The service owns legality (workflowsService.canTransition) — no legality logic in the route, which is why the same rules govern the board, the MCP tool and this endpoint. On success return the updated detail resource, so a client sees the new status without a second read.- The refusal carries the allowed targets as DATA, not prose.
transition_statusappends them to the human message because an agent reads English; a machine client must not parse a sentence. SoILLEGAL_TRANSITIONreturns 422 with the pinned{ code, error }plus an additiveallowedTransitionsarray — a new field on a response object, explicitly allowed under ADR §8, and the schema module is where its shape is declared.UNKNOWN_STATUS(a status key the project's workflow does not define at all) is a distinct 422 code fromILLEGAL_TRANSITION(a real status that is not reachable from here) — collapsing the two would make a typo and a workflow rule indistinguishable. - The domain rows added to
DOMAIN_ERROR_STATUS:ILLEGAL_TRANSITIONandUNKNOWN_STATUS→ 422;WORK_ITEM_NOT_FOUND→ 404 (already added by 11.2.2 — do not duplicate the row).
Scope BOUNDARY
Ends at this item's status moves. It does not expose workflow ADMINISTRATION (defining statuses or transitions is an admin surface, not this), does not touch markIntegrated / completeSession (those are integration-scoped external-agent writes and are not in this story's endpoint set), and does not change updateStatus, applyStatusTransition or the default workflow. It emits no notification and no side effect of its own — the service already owns what a transition triggers.
Acceptance criteria
GET …/transitionsreturns exactly the targets legal from the item's current status for areadtoken, matchingworkflowsService.canTransitionfor each — asserted by driving every returned target successfully and at least one omitted target unsuccessfully.- A legal
POST …/transitionspersists the move and returns the updated resource; a subsequentGET /api/v1/work-items/{key}shows the new status. - An illegal move returns 422 with
code: 'ILLEGAL_TRANSITION'and anallowedTransitionsarray whose contents equal whatGET …/transitionsreturns for that item — the two surfaces cannot disagree. - An unknown status key returns 422 with
code: 'UNKNOWN_STATUS', distinct from the illegal-move code. - A
read-only token gets 200 onGETand 403 onPOST— the scope split asserted on one endpoint pair. - Cross-tenant and unbrowsable targets return 404 on both methods.
- The route calls no Prisma and opens no transaction, and each export declares its own scope (the shipped guard).
- The per-file coverage floor (≥90%) holds on every new file.
Context refs
lib/services/workItemsService.ts—updateStatus, andapplyStatusTransitionbeneath it.lib/services/workflowsService.ts—getWorkflow/canTransition, the single source of "legal".lib/workflows/defaultWorkflow.ts— the shipped status set and its transition edges (note there is no directin_progress → doneedge).lib/mcp/tools/transitionStatus.ts— the shipped tool that proves the service path and the allowed-targets-on-refusal behaviour this card turns into data.lib/api/v1/errors.ts—DOMAIN_ERROR_STATUS, extended with the two transition codes.- Producer: 11.2.2 (the schema module + key resolution). Parent story: 11.2.