11.2.8 `GET` + `POST /api/v1/work-items/{key}/comments` — the discussion sub-resource, paged in the v1 cursor envelope
Comments are the half of a work item that is conversation rather than fields, and until MOTIR-1999 shipped get_work_item_activity no agent surface could read them at all. app/api/v1/work-items/[key]/comments/route.ts carries GET (scope: 'read') and POST (scope: 'work_items:write'), both thin adapters over commentsService.
What to build
GET — the comment page, re-wrapped in the v1 cursor envelope. commentsService.listComments(workItemId, { cursor, order }, ctx) already returns a cursor-paged CommentsPageDTO (threaded: root comments with their replies), so the data path is shipped. Two seams need care, and neither is optional:
- The service's cursor must NOT be passed through raw. It is a bare root-comment id — not opaque, not signed, and therefore forgeable, which is exactly what
lib/api/v1/pagination.tsrefuses ("a client that can hand-craft a cursor has made the underlying sort key public API"). Wrap it:encodePageCursorthe last root comment's{ createdAt, id }, and feed the decodedidback to the service on the next call. The v1 cursor stays signed and opaque; a foreign or tampered one is the same 422 every other collection returns. limitmust mean something.ListCommentsOptionsis{ cursor, order }— the page size is the fixed, Jira-faithfulCOMMENT_PAGE_SIZE = 20— so a v1?limit=would silently do nothing, which is worse than not offering it. Add an optionallimittolistComments(and the repository read beneath it), clamped to the v1 ceiling, defaulting to the shipped 20 so every existing caller is unaffected. This is the same bounded read-addressing carve-out 11.2.1 records — a page-size parameter over an unchanged predicate, no new filter, gate or field.
?order=asc|desc passes through to the service's own option (oldest-first default, the Jira sort).
POST → commentsService.addComment(workItemId, { bodyMd, parentCommentId }, ctx), returning 201 with the created comment. parentCommentId makes it a reply; the service owns depth and parent validation.
Identifiers: a comment has no MOTIR-<n> key, so its cuid IS its identifier and appears on the wire — the deliberate exception to the key-only rule, which governs WORK-ITEM references. Note it in the schema so the exception is read as a decision rather than a leak.
Domain rows added to DOMAIN_ERROR_STATUS: COMMENT_NOT_FOUND → 404, EMPTY_COMMENT_BODY / INVALID_PARENT_COMMENT / REPLY_DEPTH_EXCEEDED → 422, COMMENT_FORBIDDEN → 403 (this one is genuinely a "you may not do this KIND of thing" refusal on an item the caller can already see, so the existence-oracle argument does not apply — the item's own visibility is settled before the comment gate runs).
Scope BOUNDARY
Ends at reading and adding comments. It ships no edit and no delete of a comment (editComment / deleteComment are moderation-gated operations whose scope mapping the ADR's §3 table does not cover — exposing them is additive under §8 and belongs with that mapping, not smuggled in here), no reactions, no attachments (the upload path is presigned and session-bound — a separate, later card), and not the full ACTIVITY stream (activityService.listAll mixes comments with history; the v1 activity resource is not in this story's endpoint set). It changes no comment behaviour, no mention handling and no notification.
Acceptance criteria
GET …/commentsreturns the threaded page for areadtoken, each rowparseing against the comment schema, with replies nested as the service returns them.- The
nextCursoris a v1-issued, signed cursor: a hand-constructed or tampered cursor is 422, and a raw service cursor value is not accepted. - Walking the pages to exhaustion returns every comment exactly once for a work item seeded past one page — including the short-page case, where a page may be shorter than
limitwhile more remains. ?limit=is honoured up to the v1 ceiling and defaults to the shipped 20 when omitted; a test proves an existing caller oflistCommentsthat passes no limit still gets 20.?order=descreverses the walk and still pages correctly.POSTcreates a comment (201) and a reply whenparentCommentIdis given; both are visible on the nextGET.- A
read-only token is 403 onPOST; a comment on an item outside the token's workspace is 404. - Each mapped domain error is exercised through the wrapper, including the 403 on the comment gate.
- Routes pass the shipped architecture guard; the service change carries its own unit + integration tests against real Postgres.
- The per-file coverage floor (≥90%) holds on every new and changed file.
Context refs
lib/services/commentsService.ts—listComments,addComment,ListCommentsOptions,COMMENT_PAGE_SIZE.lib/dto/comments.ts—CommentDTO/CommentsPageDTO, the shapes the v1 schema maps from.lib/comments/errors.ts— the typed codes to map.lib/mcp/tools/getWorkItemActivity.ts— the shipped cursor pass-through, and its note that a short page with a non-null cursor is normal.lib/mcp/tools/addComment.ts— the shipped write path.lib/api/v1/pagination.ts—encodePageCursor/decodePageCursorand why an unsigned cursor is refused.- Carve-out that authorises the
limitoption: 11.2.1. Producer: 11.2.2. Parent story: 11.2.