11.3.1 Decision — amend the `/api/v1` ADR: cursors over SERVICE-OWNED sort orders, the ranked-collection `totalCount`, the by-id re-presentation carve-out, and the page-projection second read
The four contract questions 11.3's resources raise, settled ONCE in docs/decisions/public-api-conventions.md as Amendment 3 — before eight endpoint cards each answer them a slightly different way. Same shape and same reason as 11.2.1: the questions are contract questions, they were found by verifying the story's "thin adapter" premise against origin/main, and a per-endpoint answer is how an API grows two of everything.
This card writes the amendment. It ships no route.
Q1 — a cursor over a sort order v1 does not own
The conflict. ADR §5 pins "the cursor encodes a position in the sort order, not a page number" and never says WHICH order. lib/api/v1/pagination.ts then hardwired one: PageCursor is { createdAt, id } and paginateKeyset requires Keyed { id: string; createdAt: Date }. That was right for 11.1's and 11.2's collections and is wrong for all four of this story's, none of which sorts on createdAt:
| collection | shipped sort order | shipped cursor |
|---|---|---|
backlog (getBacklog) | backlogRank | the last row's id |
sprint members (getSprintIssues) | backlogRank | the last row's id |
the ready set (listReady) | (type asc, priority desc, key asc) — the DISPATCH rank | base64url([kind, priority, key]) |
a project's sprints (listByProject) | sequence | none — unpaginated |
Two of these DTOs cannot even satisfy Keyed: SprintDto has no createdAt field, and ProjectDTO.createdAt is optional and deliberately not loaded on the list path. And re-sorting the ready set by createdAt would discard the dispatch rank, which is the entire product value of the endpoint.
The recommendation to record: generalize the codec, do not widen the DTOs. The v1 cursor becomes a signed, opaque envelope around a service-owned position — the token the underlying read already speaks — while keeping §5's three properties unchanged: keyset (never an offset or page number), opaque (HMAC-signed, so a client cannot construct one and the sort key never becomes API), and a malformed/foreign cursor is a 422 that never silently resets to page one. Pin two consequences the endpoint cards then inherit rather than re-decide: the signed payload names its collection, so a cursor issued by one endpoint is rejected by another rather than decoded into a meaningless position in a different order; and v1's 100 ceiling holds even where the underlying read allows more (the ready set clamps at 200 — v1 clamps down to its own documented ceiling, it does not raise it).
Record the rejected alternatives: add createdAt to SprintDto / ProjectDTO (a v1 concern reaching into product DTOs to make an unrelated sort work — and for the ready set it would still be the wrong order); re-sort each collection by (createdAt, id) at the route (correct paging over an answer nobody asked for — the backlog would come back in creation order and the ready set would stop being ranked); let each endpoint carry the service's raw cursor through unsigned (drops the opacity property §5 gives its own paragraph, and makes backlogRank and the dispatch tuple public API).
Q2 — totalCount, which the shipped ranked reads already return
RankedIssuePageDto is { items, nextCursor, totalCount }. The v1 list envelope is { items, nextCursor }. So the backlog and sprint-member endpoints either drop a number the read already computed, or the envelope grows a field for two endpoints out of six.
Decide it, either way, and say which — because the OpenAPI assembly emits the SHARED envelope schema and cannot emit two shapes it was never told about. Note the real asymmetry when weighing it: the ranked reads compute the count as a bounded COUNT they already pay for, whereas the ready set and the project list have no equivalent (countReady is a separate traversal, and the ADR §5 promise of a cheap page is what would pay for it). Under §8 a field added later is additive and safe, so "not yet, and here is the shape it would take" is a legitimate answer — but an unstated one is how the two endpoints diverge.
Q3 — a by-id read that no service exposes
GET /api/v1/sprints/{sprintId} needs one sprint as a SprintDto. sprintsService has no by-id read: only sprintRepository.findById (a Prisma row, which ADR §9 forbids a route from touching) and getActiveSprint, which returns toSprintDto(row, 0) — an issueCount hard-coded to 0, so it cannot serve the endpoint at all.
Amendment 1 carved out "a new page ADDRESSING over an unchanged predicate". A by-id re-presentation is the same class — the same tenancy gate, the same mapper, the same fields, no new predicate and no write — but it is not literally on Amendment 1's permitted list, and the permitted/forbidden table is what a reviewer reads. Extend the table explicitly rather than arguing by analogy at review time: a by-id read that re-presents an already-shipped repository read through the already-shipped mapper, adding no field, no gate and no filter axis, is permitted; anything that would change what the row CONTAINS is not.
Q4 — the ready page's dependency block is a SECOND service read
ADR §9: a v1 route "calls ONE service method, and returns." The ready endpoint calls workItemsService.listReady for the page and workItemsService.getDependencyEdgesForItems for the page's edges — which is exactly what the shipped MCP transport does (lib/mcp/tools/listReady.ts), and deliberately so: the edge block is attached at the transport and NOT on ReadyItemDto, because widening the DTO would ship an edge payload to the /ready page that does not consume it.
Note that §9's rule is already read as "no business logic in a route" rather than a literal call count — every shipped project-scoped route resolves the project key and then reads, two calls. Pin the distinction so it is a rule and not a habit: a route may make a bounded, constant number of service calls that RESOLVE or PROJECT the same response — key resolution, and a batched per-page projection over the ids the first call returned — and may not make one whose result it BRANCHES on, loops over, or combines into a derived answer. State the ceiling: constant, never per-row.
Acceptance criteria
docs/decisions/public-api-conventions.mdcarries Amendment 3, in the shipped amendment format (context → the conflict → the decision → rejected alternatives), and the affected clauses (§5, §9, and Amendment 1's permitted/forbidden table) each carry the pointer to it, exactly as §5 and §9 point at Amendments 1 and 2 today.- Q1 is decided with the cursor's THREE properties restated as still-holding, and with both consequences pinned: the cursor is collection-scoped, and v1's 100 ceiling is not raised by an underlying read that allows more.
- Q2 is decided one way or the other, and the amendment WRITES DOWN the resulting envelope shape — a single envelope, or a documented ranked variant — so the later OpenAPI assembly has one shape to emit rather than a question to re-open. (Nothing here waits on that story: this card states the shape, it does not consume anything the spec work produces.)
- Q3 extends Amendment 1's permitted/forbidden table with a row for by-id re-presentation, rather than leaving it to be argued from analogy.
- Q4 states the bounded-call rule with its ceiling (constant, never per-row) and the branching test that distinguishes a projection from business logic.
- Every decision records its rung-1 (mirror: Plane / GitLab keyset cursors) or rung-2 (the shipped code, cited by path) evidence — a cited alternative with no evidence is not a recorded decision.
- ONE PR against
motir-core, docs only: no route, no service, no schema module changes in this card.
Context refs
docs/decisions/public-api-conventions.md— §5 (pagination), §7 (naming), §8 (stability), §9 (thin adapter), Amendment 1 (the read-addressing carve-out + its permitted/forbidden table), Amendment 2 (schemas live with the resource).lib/api/v1/pagination.ts—PageCursor,Keyed,paginateKeyset,parsePageRequest,MAX_PAGE_LIMIT = 100.lib/workItems/readyFilter.ts— the ready set's(type asc, priority desc, key asc)order,encodeReadyCursor/decodeReadyCursor,READY_MAX_LIMIT = 200.lib/dto/backlog.ts—RankedIssuePageDtoand itstotalCount.lib/dto/sprints.ts·lib/dto/projects.ts— the two DTOs that cannot satisfyKeyed.lib/services/sprintsService.ts—getActiveSprint'stoSprintDto(row, 0), and the absence of a by-id read.lib/mcp/tools/listReady.ts·lib/mcp/dependencyEdges.ts— the two-read page projection, and the recorded reason the edge block lives at the transport.- Precedent: 11.2.1, the amendment this one follows. Downstream consumer of the shapes decided here (no edge — a subtask never blocks on a story, and the story-level edge already exists): 11.4. Parent story: 11.3.