11.2.4 `GET /api/v1/projects/{projectKey}/work-items` — the FilterAST-narrowed, cursor-paged collection
The flagship read: the endpoint an integration actually starts from. app/api/v1/projects/[projectKey]/work-items/route.ts, GET, scope: 'read', composing withV1Route — a thin adapter over the keyset read 11.2.3 ships, returning the summary shape 11.2.2 pins.
What to build
Query narrowing — ?filter=, in the versioned FilterAST, decoded by the SAME codec the web app uses. decodeFilterParam (lib/filters/ast.ts) parses the v1:<base64url> param the /items URL already carries, and search_work_items rides the same AST through its own carrier. ONE query grammar, never a parallel one — no ad-hoc ?status=&assignee= axes bolted onto v1, because a second grammar is a second thing to keep in sync with the registry and the first place the API and the product start disagreeing about what a filter means. A decode failure ({ ok: false, reason }) is a 422 carrying a stable code; a FilterValidationError raised deeper (unknown field / operator / bad value / too large) is likewise 422, each code added to DOMAIN_ERROR_STATUS deliberately.
Paging — ?cursor=&limit=. parsePageRequest validates both (default 50, clamped at 100, a bad cursor or limit → 422 before any read). Feed its cursor into the service's after, then encode the next cursor with encodePageCursor from the LAST returned row's { createdAt, id }, or null when no more remain.
⚠️ Do NOT use paginateKeyset here. It sorts and slices a fully-read array — correct for GET /api/v1/workspaces (a user's own memberships), wrong for a collection of 1800+ rows, and the reason 11.2.3 exists. The route composes parsePageRequest + encodePageCursor and lets the database do the windowing.
The response is the shared list envelope — { items, nextCursor } — with each row through presentWorkItemSummary. Same envelope as every other v1 collection; nothing endpoint-specific about its shape.
Project resolution is projectsService.getByKey(projectKey, ctx); a key outside the token's workspace is a 404, indistinguishable from a key that never existed.
Scope BOUNDARY
Ends at this one GET. It adds no filter axis (the registry's field set is what it is — note lib/filters/registry.ts has no parent facet, so a "children of X" query is not offered here; adding one is a registry card in the work-items epic, never an inline query). It ships no write, no sub-resource, and no OpenAPI operation (11.4 assembles the spec from the schema module). It does not change the /items view or search_work_items.
Acceptance criteria
GET /api/v1/projects/{projectKey}/work-itemsreturns the list envelope for areadtoken; every rowparses againstworkItemSummarySchema.- The one-grammar contract, asserted end to end: for an identical FilterAST, the endpoint walked to exhaustion returns the SAME key set as the
/itemsview's own read for that filter — so the API can never disagree with the web app about what a filter means. - Paging a project larger than one page yields every row exactly once across the walk, and the last page carries
nextCursor: null. - No unbounded read: a test asserts the endpoint issues a bounded query — the service is called with a
limit, and a project seeded well past one page never has more thanlimit + 1rows read for a page. limit=100returns up to 100 rows (not 50);limit=0, a negative, a fractional and a non-numeric value are each 422, and an oversizedlimitclamps rather than erroring.- A malformed, truncated, foreign or tampered cursor is 422 with a stable code — never a silent reset to the first page.
- A malformed
?filter=, an unknown field id, an unknown operator, a bad value and an over-cap row count are each 422 with their own code, all in the{ code, error }envelope. - An unknown or cross-workspace
projectKeyis 404; a project the caller cannot browse is the same 404. - An empty result is 200 with
items: []andnextCursor: null, never a 404. - The route passes the shipped architecture guard: wrapper-composed, scope declared, no Prisma, no transaction.
- The per-file coverage floor (≥90%) holds on every new file.
Context refs
lib/filters/ast.ts—decodeFilterParam,FILTER_PARAM,FilterDecodeResult,FILTER_ROW_CAP.lib/filters/registry.ts— the field set the AST validates against, and its absentparentfacet.lib/filters/errors.ts—UnknownFilterFieldError/UnknownFilterOperatorError/InvalidFilterValueError/FilterTooLargeError/MalformedFilterError, the 422 codes to map.lib/api/v1/pagination.ts—parsePageRequest,encodePageCursor,ListEnvelope,MAX_PAGE_LIMIT; and whypaginateKeysetis not used here.lib/mcp/tools/searchWorkItems.ts— the shipped sibling that proves the AST → service path and the one-grammar discipline.app/api/v1/workspaces/route.ts— the shipped list-envelope precedent.- Producers: 11.2.3 (the read) · 11.2.2 (the summary schema). Parent story: 11.2.