11.2.2 The v1 work-item RESPONSE schema module + `GET /api/v1/work-items/{key}` — the resource representation every other endpoint returns
The first endpoint of the story and the one that PINS the work-item resource on the wire. Every sibling endpoint — list, create, update, transition, archive — returns a shape defined here, so this card ships the schema module before it ships the route.
Per 11.2.1: the response is a zod schema's output, never a service DTO passed through. IssueDetailDto is the web app's internal shape and changes whenever a page needs it to; the public contract's additive-only promise (ADR §8) cannot ride something nobody promised to keep still.
What to build
1. lib/api/v1/workItems/schema.ts — the resource, declared once:
workItemSummarySchema— the row every COLLECTION returns (key, kind, title, status, priority, type, executor, storyPoints, estimateMinutes, assignee/reporter as{ id, name }, parentKey, sprintId, targetRepo, createdAt/updatedAt as ISO-8601 strings).workItemDetailSchema— the summary plus what a single read adds:descriptionMd, lineage (ancestors + parent), children, the dependency-link groups (blockedBy/blocks/relatesTo/duplicates/clones), the readiness verdict, labels, components,commentCount.presentWorkItemSummary(dto)/presentWorkItemDetail(dto)— the mappers from the service DTOs, shaping explicitly, field by field. Never a spread:GET /api/v1/mealready refuses to spread a Prisma row for exactly this reason, and the same hazard applies one layer up — a column a later migration adds must not become public API by accident.- Identifiers on the wire are the
MOTIR-<n>key (ADR §7). An internal cuid appears nowhere a client can depend on it: not asid, not in a link group, not as a parent pointer. A cuid in a response body freezes the primary key as contract exactly as it would in a path. - Datetimes are ISO-8601 strings; the schema's
parsein a unit test is what proves the mapper cannot emit aDateor anullwhere the contract says otherwise.
2. lib/api/v1/workItems/resolveKey.ts — resolve a MOTIR-<n> path segment to { projectId, identifier } for the read. lib/mcp/tools/workItemRef.ts already implements this (normalizeIdentifier / projectKeyOf / resolveWorkItemByKey) — read it, and re-implement in the v1 layer rather than importing it. A public route importing the MCP tool layer couples two contracts that 11.6 exists to align through SCHEMAS rather than through imports, and it is the direction the epic explicitly rejects (MCP tools are not re-pointed at HTTP, and HTTP does not reach into MCP). A malformed key (no -, no numeric suffix) is a 422 before any read.
3. app/api/v1/work-items/[key]/route.ts — GET, scope: 'read', composing withV1Route. Resolve the key → projectsService.getByKey → workItemsService.getIssueDetail(projectId, identifier, ctx) → presentWorkItemDetail. No db.*, no $transaction (the shipped guard in tests/api/v1/story-gate.test.ts audits this over the whole tree).
4. The response carries an ETag — an opaque validator derived from the item's updatedAt. It exists because 11.2.6's PATCH accepts If-Match and passes it to updateWorkItem's shipped expectedUpdatedAt precondition, so this read is where a client GETS the value it later sends back. Derive it here (in the schema module, alongside the presenter, so one function owns both directions) rather than letting the write card invent its own encoding — a validator produced by one card and parsed by another is a contract, and it belongs with the resource.
5. The domain→status rows this endpoint can raise, added to DOMAIN_ERROR_STATUS in lib/api/v1/errors.ts. The map is seeded with NOT_A_MEMBER only and an unlisted code falls through to a bare 500 — that is deliberate (an error reaching a client is public contract), so each row is added knowingly: WORK_ITEM_NOT_FOUND → 404, PROJECT_NOT_FOUND → 404, and PROJECT_ACCESS_DENIED → 404, not 403 (a 403 on a project the caller cannot browse confirms it exists — the same existence-oracle argument ADR §4 makes for cross-tenant, applied within a tenant).
⚠️ The dynamic segment is [key] and every sibling v1 route under work-items must use the same slug name — Next.js refuses to build when two sibling dynamic segments differ ([key] vs [id]), and that failure surfaces as a boot error, not a type error. 11.3's item-scoped routes inherit the same name.
Scope BOUNDARY
Ends at the single-item READ and the schema module it establishes. It does not ship the list endpoint or any keyset read (11.2.4 / 11.2.3), no write of any kind, and no OpenAPI emission — 11.4 assembles the spec FROM this schema and must not be pre-empted here. It changes no service, repository or DTO.
Acceptance criteria
GET /api/v1/work-items/{key}returns the detail resource for a validreadtoken, and its bodyparses againstworkItemDetailSchema— the schema is the assertion, not a hand-written expectation.- Every identifier in the body is a
MOTIR-<n>key; a test asserts no cuid appears anywhere in the serialized response (a regex over the JSON), so a mapper that forwards an internal id fails rather than silently publishing it. - The mappers shape field-by-field: a test adds an unexpected property to the source DTO and asserts it does NOT appear in the output.
- The response carries an
ETag, it changes when the item is updated, and it is opaque — a client cannot readupdatedAtback out of it. - An unknown key, a key in another workspace, and a key in a project the caller cannot browse are indistinguishable: all three return 404 with
{ code, error }. - A malformed key returns 422 before any service call — asserted with a service spy that must not have been called.
- The route declares
scope: 'read', composeswithV1Route, and calls no Prisma and no transaction — it passes the shipped architecture guard unchanged. - Every
DOMAIN_ERROR_STATUSrow this card adds is exercised by a test that drives the real error through the wrapper, so no added row is unproven and no unmapped error silently becomes a 500. - The per-file coverage floor (≥90% branch/fn/line) holds on every new file.
Context refs
lib/api/v1/route.ts—withV1Route,V1RouteContextand what a handler is given.lib/api/v1/errors.ts—DOMAIN_ERROR_STATUS, and the comment explaining why an unlisted code is a 500.lib/services/workItemsService.ts—getIssueDetail(projectId, identifier, ctx), its link groups and thereadinessverdict;updateWorkItem'sexpectedUpdatedAt, the precondition the ETag serves.lib/dto/workItems.ts—IssueDetailDto/WorkItemListItemDto, the internal shapes the mappers read FROM.lib/mcp/tools/workItemRef.ts— the key-resolution logic to READ and re-implement (not import);lib/mcp/tools/getWorkItem.tsproves the service path.app/api/v1/me/route.ts— the explicit-shaping precedent, including why a Prisma row is never spread.tests/api/v1/story-gate.test.ts— the architecture guard this route must pass.- Decision it implements: 11.2.1. Consumer of the ETag: 11.2.6. Spec consumer: 11.4. Parent story: 11.2.