11.1.3 Opaque-cursor pagination + the shared list envelope, proven by `GET /api/v1/workspaces`
Repo: motir-core. One PR. The collection half of the /api/v1 envelope: ONE pagination shape every list endpoint in 11.2 / 11.3 reuses, plus the first real paginated endpoint.
blocked_by 11.1.2 — it composes into that wrapper rather than wrapping it again.
What to build
1. The cursor + list envelope primitive in lib/api/v1/: parse and validate ?cursor=&limit=, clamp limit (default 50, hard max 100), encode/decode the OPAQUE cursor, and shape the response as one list envelope (items + the next cursor) every collection returns.
Reuse the encoding idiom already shipped in lib/mcp/searchCursor.ts — the CLI's search_work_items paging already rides it, so the shape is proven. The cursor stays opaque: a client must not be able to hand-craft one, because that would freeze the underlying keyset as public API and make a future index change a breaking change.
2. GET /api/v1/workspaces — the token owner's workspaces, paginated. Chosen as the proving endpoint because it is a genuinely unbounded collection whose access rules are already enforced by the service, so the card tests PAGINATION rather than resource modelling.
Why cursor and not offset — the property to actually test
The ADR rejects offset pagination because Motir's collections mutate while a client pages them, and offset silently skips and duplicates rows when a row is inserted or removed mid-scan. That is not a theoretical concern for a PM tool where an agent loop writes while another reads. So this card does not merely implement a cursor — it proves the property: a test inserts a row between two page fetches and asserts no row is skipped and none is returned twice. A pagination card whose tests only walk a static fixture has not tested pagination.
Completeness
Real-product shape, not the green path: an empty collection returns an empty items with no next cursor (not a 404); the final page reports no next cursor rather than an empty-page round trip; a malformed or foreign cursor is a 422 with a code, never a 500 and never a silent reset to page one — a silent reset is the failure mode that makes a client loop forever.
Scope BOUNDARY
Ends at the pagination primitive + /api/v1/workspaces. It does NOT add filtering or sorting (the FilterAST arrives with the work-item list in 11.2); does NOT add rate limiting (11.1.4); does NOT add any other resource endpoint; does NOT change lib/mcp/searchCursor.ts or any MCP tool (reuse the idiom; if the code is genuinely shareable, extract it WITHOUT changing MCP behaviour, and if that is not clean, duplicate the ~20 lines rather than refactoring a shipped surface inside this card); does NOT change any service, repository or migration; writes no OpenAPI spec (11.4).
Acceptance criteria
GET /api/v1/workspacesreturns the token owner's workspaces in the shared list envelope, gated onread.limitdefaults to 50 and is CLAMPED at 100;limit=0, a negative and a non-numeric value are each rejected as 422 with acode, not silently coerced.- Paging the whole collection with the returned cursor visits every row exactly once, and the last page reports no next cursor.
- The mutation case: inserting a row between two page fetches skips no row and duplicates none — the property offset pagination cannot provide, asserted directly.
- An empty collection returns an empty
itemsand no next cursor, with a 200. - A malformed, truncated or foreign cursor returns 422 with a
code— never a 500, and never a silent reset to the first page. - The cursor is opaque: the test asserts a client cannot construct a valid one from row data, so the keyset is not accidentally public API.
- The envelope is reusable — a second fixture collection adopts it with no copied parsing logic.
- Cross-tenant isolation holds: only the token's own workspaces appear.
- The route contains no
db.*and no$transaction; one service call. - Unit + integration tests ship in the same PR against real Postgres; the per-file coverage floor (≥90%) holds on every new file.
Context refs
lib/mcp/searchCursor.ts—encodeSearchCursor/decodeSearchCursor, the shipped opaque-cursor idiom to reuse.lib/mcp/tools/searchWorkItems.ts— how the shipped tool wraps a service page in a cursor (FILTER_ROW_CAP, the 50-row server cap).lib/services/workspacesService.ts— the service the endpoint adapts.lib/api/v1/— the wrapper from 11.1.2 this composes into.- Decision: 11.1.1 (the pagination axis). Parent story: 11.1.