11.2.9 `GET` + `POST` + `DELETE /api/v1/work-items/{key}/links` — the dependency and relationship edges, `blocked_by` included
The edges are what make Motir's data a plan rather than a list — blocked_by is the edge the ready set reads, so an integration that cannot write one cannot express a dependency at all. app/api/v1/work-items/[key]/links/route.ts carries GET (scope: 'read'), POST and DELETE (both scope: 'work_items:write').
What to build
GET— this item's edges in one shape:blockedBy·blocks·relatesTo·duplicates·clones, each entry{ key, title, status, relationship }. The groups come from the aggregateworkItemsService.getIssueDetailalready resolves (one service call; do not assemble them from several reads), presented through the schema module so the group shape here and inside the detail resource are literally the same declaration — two shapes for one concept is how they drift.POST— body{ toKey, relationship }whererelationshipis the shipped set (blocked_by·blocks·relates_to·duplicates·clones), applied viaworkItemsService.linkWorkItems. The FROM side is the path key; edges may cross projects inside the workspace, never across workspaces. 201 with the created edge.DELETE— addressed by endpoints, not by link id:?toKey=&relationship=, applied viaworkItemsService.unlinkWorkItemsByEndpoints, which is idempotent and returns whether a row was actually removed. So DELETE returns 204 whether or not an edge was there — the correct HTTP reading of an idempotent delete, and it means a retried teardown is safe. No internallinkIdappears on the wire in either direction (ADR §7).- Domain rows for
DOMAIN_ERROR_STATUS, each proven by a test:SELF_LINKandWORK_ITEM_LINK_CYCLE→ 422 (the caller can fix the request);DUPLICATE_LINK→ 409 (a conflict with existing state, not a malformed request — the shipped service deliberately throws rather than silently succeeding on a manual link);CROSS_WORKSPACE_LINK/WORKSPACE_MISMATCH_LINK→ 404 on the target key, because confirming the other item exists in another tenant is the existence oracle ADR §4 forbids;WORK_ITEM_LINK_NOT_FOUND→ 404 for a read that names a missing edge. 409 is a status the ADR §4 table does not list — append the row with its condition, as a new condition rather than a changed one (additive under §8).
Scope BOUNDARY
Ends at this item's edges. It does not expose link CANDIDATE search (listLinkCandidates serves a picker UI, not an API contract), does not touch parent/child structure (re-filing is parentKey on 11.2.6's PATCH — a parent is not a link and must not be settable through both), and does not compute or expose readiness beyond what the detail resource already carries. It changes no link service or repository, and adds no relationship kind.
Acceptance criteria
GET …/linksreturns all five groups (empty arrays when a group has no edges, never omitted keys — an absent key and an empty group are different things to a typed client).- The group shape is the SAME schema declaration the detail resource uses — asserted by parsing both bodies against it.
POSTwithrelationship: 'blocked_by'creates the edge, and the target immediately shows the reciprocal direction on ITSGET …/links— the round trip a dependency-writing integration depends on.- A
relates_toedge shows on both items (the shipped reciprocal), while ablocked_byedge shows asblockedByon one andblockson the other. DELETEis idempotent: the second call returns 204 exactly like the first, and aDELETEnaming an edge that never existed is also 204.- Re-creating an existing link returns 409 with
code: 'DUPLICATE_LINK'; a self-link and a cycle each return 422 with their own codes. - A
toKeyin another workspace returns 404 — indistinguishable from a key that does not exist. - A
read-only token gets 200 onGETand 403 on both writes. - The ADR §4 status table carries the 409 row with its condition.
- Every export composes
withV1Routewith its own scope; no Prisma, no transaction in the route. - The per-file coverage floor (≥90%) holds on every new file.
Context refs
lib/services/workItemsService.ts—linkWorkItems,unlinkWorkItemsByEndpoints(and its idempotent contract),getIssueDetail's link groups.lib/workItems/linkErrors.ts—SELF_LINK·WORK_ITEM_LINK_CYCLE·DUPLICATE_LINK·CROSS_WORKSPACE_LINK·WORKSPACE_MISMATCH_LINK·WORK_ITEM_LINK_NOT_FOUND.lib/dto/workItemLinks.ts—WorkItemLinkDto, the shape mapped from.lib/mcp/tools/linkWorkItems.ts— the shipped tool proving both service paths and the endpoint-addressed unlink.lib/mcp/dependencyEdges.ts— how the MCP surface already projects edges, for shape consistency.- Producer: 11.2.2 (the schema module). Sibling that owns re-parenting: 11.2.6. Parent story: 11.2.