Skip to content

moooon

Motir

Vibe your whole project. Bring an idea — Motir's three AI layers plan it, track it, and ship it, end to end. You're looking at Motir, built in Motir.

  • Vibe Project
  • Open Source
  • AI Agent
  • AI Loop
1
requests
0
upvotes
145
planned
1,361
shipped

Motir · Work items

MOTIR-2058Done

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:

collectionshipped sort ordershipped cursor
backlog (getBacklog)backlogRankthe last row's id
sprint members (getSprintIssues)backlogRankthe last row's id
the ready set (listReady)(type asc, priority desc, key asc) — the DISPATCH rankbase64url([kind, priority, key])
a project's sprints (listByProject)sequencenone — 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.md carries 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.tsPageCursor, 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.tsRankedIssuePageDto and its totalCount.
  • lib/dto/sprints.ts · lib/dto/projects.ts — the two DTOs that cannot satisfy Keyed.
  • lib/services/sprintsService.tsgetActiveSprint's toSprintDto(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.