11.3.2 The ORDER-AGNOSTIC v1 page cursor — a signed, collection-scoped envelope around a service-owned position
The shared pagination primitive every other read in 11.3 composes. Extends lib/api/v1/pagination.ts so a v1 cursor can name a position in a sort order the service owns, instead of only the (createdAt, id) order 11.1 hardwired.
Implements what 11.3.1 decides in Q1; do not re-open the decision here, and do not proceed if the amendment's Q1 differs from the sketch below — the amendment is the contract, this card is its implementation.
Why the shipped codec cannot serve this story
PageCursor is { createdAt: string; id: string } and paginateKeyset constrains its rows to Keyed { id: string; createdAt: Date }. Four of this story's collections sort on something else — backlogRank (backlog, sprint members), the (type asc, priority desc, key asc) dispatch rank (the ready set), sequence (sprints) — and two of the DTOs involved cannot satisfy Keyed at all (SprintDto has no createdAt; ProjectDTO.createdAt is optional and unloaded on the list path). Paging any of them through today's codec would silently return the collection in a different order than the product's own surfaces show.
The change
Add, do not replace. encodePageCursor / decodePageCursor / parsePageRequest / paginateKeyset stay exactly as they are — 11.2's six shipped endpoints and GET /api/v1/workspaces depend on them and must not change behaviour. The new surface sits beside them:
- A signed, opaque envelope over an arbitrary position payload, reusing the SAME HMAC construction and the same
BETTER_AUTH_SECRET-derived signing key — so a cursor issued by one deployment stays invalid against another, and the existingtimingSafeEqualcomparison is not re-implemented. - Collection-scoped. The signed payload names the collection that issued it, and decoding under a different collection is the same 422 as a tampered token — not a silently decoded position in an order where it means nothing. This is the property that keeps a backlog cursor from paging the ready set.
- A parse helper that reads
?cursor=&limit=for a service-positioned collection, applying v1's OWN ceiling:limitdefaults to 50 and clamps to 100, even where the underlying read permits more (READY_MAX_LIMITis 200 — v1 clamps down, it does not raise its documented cap). Thelimitvalidation rules are unchanged:0, negative, fractional and non-numeric are 422, over-cap is clamped. - The existing
{ items, nextCursor }envelope type is reused; if 11.3.1 Q2 admits a ranked variant carryingtotalCount, this module is where its type lives, so 11.4 has one place to emit from.
No route, no service, no schema module in this card — those are the endpoint cards. This ships the primitive plus its unit tests.
Acceptance criteria
- A v1 cursor can be issued and re-read for a position that is NOT
(createdAt, id)— covered by a test for each of the three position shapes this story actually needs: an opaque row id (backlogRankorder), the ready set's(kind, priority, key)tuple, and a sprintsequence. - A cursor issued for collection A is refused with 422 when presented to collection B, with the same
INVALID_CURSORcode as a tampered token — asserted directly, since this is the property that makes the generalization safe rather than merely convenient. - A tampered, truncated, foreign-deployment or non-JSON cursor is a 422 and NEVER a silent reset to page one — the existing property, re-asserted on the new surface (a cursor whose signature verifies but whose payload is not a valid position is included, the case 11.1's own gate found).
limitclamps to 100 on a collection whose service allows more, asserted against the ready set's 200 specifically;0/-1/1.5/abcare each 422.- The shipped
encodePageCursor/decodePageCursor/paginateKeysetbehaviour is UNCHANGED — the existingtests/api/v1/pagination.test.tspasses untouched, and a cursor encoded before this change still decodes after it. - Unit tests ship with the module (the per-subtask floor) and the new file holds the ≥90% per-file coverage gate.
- ONE PR against
motir-core.
Context refs
lib/api/v1/pagination.ts— the module this extends:PageCursor,Keyed,signingKey,sign,encodePageCursor,decodePageCursor,parsePageRequest,paginateKeyset,DEFAULT_PAGE_LIMIT,MAX_PAGE_LIMIT.lib/api/v1/errors.ts—InvalidRequestErrorand theINVALID_CURSORcode the 422 carries.lib/workItems/readyFilter.ts— the ready position (ReadyCursor,encodeReadyCursor,decodeReadyCursor) this must be able to carry, andREADY_MAX_LIMIT = 200.lib/services/backlogService.ts—getBacklog/getSprintIssues, whose cursor is the last row's id.tests/api/v1/pagination.test.ts— the shipped suite that must keep passing unchanged.docs/decisions/public-api-conventions.md§5 + Amendment 3 (written by 11.3.1) — the contract this implements.- Parent story: 11.3.