11.3.8 The two RANKED work-item collections — `GET /api/v1/projects/{projectKey}/backlog` and `GET /api/v1/sprints/{sprintId}/work-items`, paged in `backlogRank` order
The two collections that answer "what is queued, and what did we commit to?" — and the only reads in 11.3 that are genuinely unbounded (this project's sprints have run to 117 issues and its backlog is far larger).
They ship as one card because they are one shape: backlogService.getBacklog and getSprintIssues both return RankedIssuePageDto, both window in the DATABASE, both order by backlogRank, both take the same optional filterAst, and both raise the same totalCount question. Two cards would mean writing the same cursor translation and the same envelope decision twice and letting them drift.
Both are already real database-level keyset reads — this is a true thin adapter
Unlike 11.2's work-item list (which had to add a keyset read), these two already do the right thing: findBacklogPage / findSprintIssues take { take, cursor, filter } and window in SQL, with a bounded COUNT alongside. Nothing new is needed at the service or repository layer. What the route owes is the translation between v1's signed, collection-scoped cursor (11.3.2) and the service's own cursor — the last row's id, in backlogRank order — and the RankedIssuePageDto → v1 envelope mapping.
Two shipped behaviours are contract and must be preserved rather than re-derived:
- The backlog EXCLUDES done-category issues (
backlogExcludedStatusKeys) — the backlog is the to-be-planned pile, matching the mirror. A sprint's members are NOT filtered that way: a done issue stays part of its sprint's scope. Same-shaped endpoints, deliberately different predicates. Say so in the module; a reader who assumes symmetry will "fix" one of them. totalCount. Both reads compute it. Whether the v1 envelope carries it is 11.3.1 Q2's decision — implement whichever it records, and do not invent a third shape here.
Narrowing is the versioned FilterAST, decoded by the same codec /items and search_work_items ride and the shipped work-item collection route already uses — never an ad-hoc ?status=&assignee= axis. One query grammar; a second is the first place the API and the product start disagreeing about what a filter means.
Rows are the work-item summary shape 11.2.2 already pins (WorkItemSummaryDto is what both reads return) — this card adds no new work-item representation.
Acceptance criteria
- Both endpoints exist, declare
scope: 'read', and return the declared schema's output over the v1 list envelope. - A paged scan of a collection being written concurrently never skips or duplicates a row — the keyset property, driven with a real concurrent insert the way 11.1's pagination test does, on at least one of the two.
- Paging preserves
backlogRankorder across page boundaries — asserted over a collection larger than one page, comparing the concatenated pages against the service's own order. limitdefaults to 50 and clamps to 100; a cursor issued by one of these two collections is refused (422) by the other.- The backlog omits done-category issues and the sprint read keeps them — asserted with the same fixture item in both, since this is the asymmetry a later reader is most likely to "correct".
- The
?filter=parameter accepts the versioned FilterAST and produces the same narrowing the/itemsview does for an identical filter; an invalid filter is a 422 with the shipped code (INVALID_FILTER/UNSUPPORTED_FILTER_VERSION/FILTER_TOO_LARGE), not a 500. totalCountis handled exactly as Amendment 3 Q2 records — carried, or documented as deliberately absent. Not both, and not silently dropped.- An empty backlog or an empty sprint is 200 with empty
items, never a 404; an unknownprojectKey/ cross-workspacesprintIdis a 404, not a 403. - No route calls Prisma or opens a transaction; each is key resolution plus one service call.
- Unit tests ship with the routes; every new file holds the ≥90% per-file coverage floor.
- ONE PR against
motir-core.
Context refs
lib/services/backlogService.ts—getBacklog,getSprintIssues,backlogExcludedStatusKeys,resolveBacklogFilter,clampLimit,buildPage.lib/repositories/workItemRepository.ts—findBacklogPage/findSprintIssues/countBacklog/countSprintIssues, the SQL-level windowing this adapts.lib/dto/backlog.ts—RankedIssuePageDto(items/nextCursor/totalCount).lib/filters/ast.ts+lib/filters/registry.ts— the one query grammar and its field set.app/api/v1/projects/[projectKey]/work-items/route.ts— the shipped route that already does this translation for a DB-windowed read, includingparseFilterParamand its error codes.lib/api/v1/workItems/schema.ts—presentWorkItemSummary, the row shape both collections return.- Blockers: 11.3.1 (the
totalCount+ cursor contract), 11.3.2 (the cursor primitive), 11.3.4 (the/sprints/{sprintId}resource + its tenancy resolution). Parent story: 11.3.