11.2.6 `POST /api/v1/projects/{projectKey}/work-items` + `PATCH /api/v1/work-items/{key}` — the write pair, with `If-Match` optimistic concurrency
The two mutations that make /api/v1 a write surface rather than a read-only mirror. Both are scope: 'work_items:write' (ADR §3), both are thin adapters over ONE shipped service method each, and both return the detail resource 11.2.2 pins.
POST is a second export in the collection route module 11.2.4 creates; PATCH is a second export in the item route module 11.2.2 creates. Both reuse those cards' project / key resolution rather than re-deriving it — and the shipped guard already catches "a POST that bypasses the wrapper even when a sibling GET does not", so each export declares its own scope.
What to build
Request schemas, in the same lib/api/v1/workItems/schema.ts the responses live in — createWorkItemBodySchema and updateWorkItemBodySchema. Validation failure is a 422 in the { code, error } envelope; the schemas are what 11.4 later emits as the operations' request bodies.
POST …/work-items → workItemsService.createWorkItem. The body carries kind, title, and the leaf-authoring fields the shipped CreateWorkItemInput accepts (descriptionMd, priority, type, executor, storyPoints, estimateMinutes, targetRepo, assigneeId, dueDate) plus parentKey — a MOTIR-<n> key the route resolves to the internal parent id, never a cuid on the wire (ADR §7). It stamps planningSource: 'api' (11.2.5), exactly as the MCP tool stamps mcp. Responds 201 with the created resource and a Location header pointing at its item URL.
PATCH /work-items/{key} → workItemsService.updateWorkItem. The editable set is the shipped UpdateWorkItemInput PATCH keys — title, descriptionMd, explanationMd, priority, type, executor, storyPoints, estimateMinutes, targetRepo, assigneeId, dueDate, plus parentKey (re-file) and kind (re-classify), both of which that ONE service method already validates against the kind-parent matrix. An omitted field is untouched; an explicit null clears a nullable one — say which is which in the schema, because "absent" and "null" meaning the same thing is the commonest PATCH defect.
Optimistic concurrency — If-Match, honouring the guard the service already has. updateWorkItem accepts { expectedUpdatedAt } and raises StaleWorkItemError when the row moved underneath the caller. Expose it the HTTP-native way rather than inventing a body field: the item read (11.2.2) returns an opaque ETag derived from updatedAt; PATCH accepts If-Match, decodes it with that card's function (do not re-derive the encoding here) and passes it through as the precondition; a mismatch is 412 with a stable code. Omitting If-Match is legal and means last-write-wins — unchanged from today's behaviour, so this adds a guarantee without making one mandatory. Two agents patching the same item is the epic's own stated normal, which is why the guard is exposed rather than left internal.
The DOMAIN_ERROR_STATUS rows these two can raise, each added deliberately and each proven by a test that drives the real error: ILLEGAL_PARENT_TYPE, CROSS_PROJECT_PARENT, PARENT_CYCLE, DEPTH_LIMIT_EXCEEDED, TYPE_NOT_ALLOWED_ON_KIND, ASSIGNEE_NOT_IN_WORKSPACE, REPORTER_NOT_IN_WORKSPACE, UNKNOWN_TARGET_REPO, ARCHIVED_TARGET_REPO, INVALID_ESTIMATE → 422; WORK_ITEM_NOT_FOUND / PROJECT_NOT_FOUND → 404; STALE_WORK_ITEM → 412. 412 is a status the ADR's §4 table does not yet list, so this card appends the row (a new CONDITION getting a status, not an existing condition changing one — additive under §8) rather than silently emitting an undocumented status.
Scope BOUNDARY
Ends at create and update of the work-item resource itself. It ships no transition (a status move is 11.2.7 — updateStatus is its own service method with its own legality rules), no comment, no link, no archive, and no sprint assignment (moving an item into or out of a sprint is sprints:write and belongs to 11.3). It exposes no delete. It changes no service, repository or migration.
Acceptance criteria
POSTcreates the item, returns 201 with the detail resource and aLocationheader, and the created row carriesplanningSource: 'api'— asserted by reading the row back.PATCHapplies a partial update, leaves omitted fields untouched, and clears a nullable field on an explicitnull; both cases asserted per field class.- Both responses
parseagainstworkItemDetailSchema; no cuid appears anywhere in either body,parentKeyincluded. parentKeyresolves to the parent, and an unknown or cross-project parent key produces the mapped domain error, not a 500.- A
read-only token is refused 403 on both, and the refusal is not a 200 with an empty body. If-Matchcarrying a stale ETag returns 412; the same request withoutIf-Matchsucceeds; the ETag from a fresh read succeeds — all three asserted against a real concurrent update.- Every mapped domain error above is exercised through the wrapper, so no row is unproven and no unmapped error becomes a silent 500.
- Cross-tenant and unbrowsable-project targets return 404, never 403 (the existence-oracle rule).
- The ADR §4 status table carries the 412 row with its condition.
- Both exports compose
withV1Routewith their own declared scope and call no Prisma and no transaction. - The per-file coverage floor (≥90%) holds on every new and changed file.
Context refs
lib/services/workItemsService.ts—createWorkItem,updateWorkItem(id, patch, ctx, { expectedUpdatedAt }), itsPATCH_KEYSset and theStaleWorkItemErrorpath.lib/dto/workItems.ts—CreateWorkItemInput/UpdateWorkItemInput, the field sets the schemas mirror.lib/workItems/errors.ts— the typed error classes and theircodevalues.lib/mcp/tools/createWorkItem.ts·lib/mcp/tools/updateWorkItem.ts— the shipped tools that prove both service paths and their argument shapes.lib/api/v1/errors.ts—DOMAIN_ERROR_STATUS, extended here.docs/decisions/public-api-conventions.md— §3 (the scope map), §4 (the status table this card appends to), §8 (why adding a status for a new condition is additive).- Producers: 11.2.2 (schema + item module + the ETag) · 11.2.4 (collection module) · 11.2.5 (the
apisource). Parent story: 11.2.