Files
ersatztv/docs/decisions/records/api/paging-zero-based.md
T
timothyandClaude Opus 5 5648f8e92e docs(616): scope the pageSize cap per endpoint — it is not one number
Self-review of the previous commit caught an overclaim I introduced. I wrote the
paging docs as if the pageSize cap were uniformly 100. It is not:

  most reads (collections, library browse, logs, playouts)  Math.Clamp(.., 1, 100)
  GetAutoTuneChannelMembers                                 pageSize <= 0 ? 100 : Min(.., 200)
  GET /api/v1/search/all-items                              default 500, cap 1000

That made a concrete example in docs/mcp.md simply false. It claimed
`pageSize=500&pageNum=1` returns items 101-200 "not 501-1000" — but
ersatztv_search_all_items also takes Page(), and 500 is UNDER its cap, so there
page 1 really is items 501-1000. A caller following that example on the one tool
most likely to be paged hard would have mis-derived its offsets, which is the
same class of silent-short-set error this issue is about.

The invariant that actually holds everywhere is the derivation, not any single
cap: the offset comes from the EFFECTIVE (bounded) page size, never the
requested one. Reworded to say that, in docs/mcp.md, the api.paging-zero-based
record (rule + mechanics + body), and the ToolCatalog Page() comment. Catalog
regenerated. The record's mechanics line no longer claims every controller uses
Math.Clamp — ChannelController does not.

No behaviour change; MCP suite still 59/59.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 23:24:40 +02:00

4.0 KiB
Raw Blame History

key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
key title status since supersedes superseded-by rule signals mechanics
api.paging-zero-based 2026-07-25 — Paging is 0-based everywhere, and every wrapper says so (#616) active 2026-07-25 none none `pageNum` is 0-based across the entire `/api/v1` surface and every wrapper of it (MCP tool catalog, SPA hooks, docs); the page offset is always derived from the EFFECTIVE (bounded) `pageSize`, never the requested one, so a `pageSize` above an endpoint's cap narrows the page without widening the offset. The cap itself is per-endpoint (100 typical, 200 auto-tune members, 1000 search/all-items) and must not be documented as one number. A paging parameter description that omits or contradicts "0-based" is a defect. `pageNum`, `pageSize`, `Math.Clamp(pageSize`, `Skip(pageNum * pageSize)`, "1-based", off-by-one paging, short result set, MCP `Page()` · paths: `ErsatzTV.Mcp/ToolCatalog.cs`, `ErsatzTV/Controllers/Api/*Controller.cs` · issues: #616, #487, #58 `Math.Max(0, pageNum)` + a per-endpoint upper bound on `pageSize` (`Math.Clamp(pageSize, 1, MaxPageSize)` in most controllers) then `Skip(PageNum * PageSize)` in the handler

Every paged controller on /api/v1 defaults pageNum to 0, floors it with Math.Max(0, pageNum), bounds pageSize above by a per-endpoint maximum, and passes both bounded values to a handler that skips PageNum * PageSize. That makes paging uniformly 0-based, and makes the offset a function of the effective size rather than the requested one.

The maximum is deliberately not uniform and must not be documented as if it were: most reads clamp Math.Clamp(pageSize, 1, MaxPageSize) with MaxPageSize = 100, GetAutoTuneChannelMembers uses pageSize <= 0 ? 100 : Math.Min(pageSize, 200), and GET /api/v1/search/all-items defaults to 500 and caps at 1000. So pageSize=500 is narrowed to 100 on a collection listing and honored verbatim on all-items. The invariant that holds everywhere is the derivation (offset from the effective size), not any single cap.

The convention was correct in code and unwritten everywhere else, which is how it produced a bug report. The MCP tool catalog described pageNum as "1-based page number", so a caller that started at pageNum=1 skipped the first page: a 15-item collection returned 0 items and a 204-item collection returned 104. Nothing errored — the caller just got a short set, which reads as data loss, not as an off-by-one, and cost a verification pass being chased as one (#487).

The same report's second claim — that pageSize is capped for the returned page while the offset still honours the requested value — was not reproducible and is not true of any endpoint. The observation behind it (pageSize=500&pageNum=2 on a 204-item collection returning 4 items) is exactly correct 0-based behaviour at the clamped width of 100: page 2 is items 201204. Both behaviours are now pinned by mutation-verified tests in GetCollectionItemsHandlerTests, so the next reader does not have to re-derive which half was real.

Direction of the fix. The alternative was to make the MCP layer 1-based and translate. Rejected: /api/v1 is additive-only post-freeze (api.versioning-v1), 0-based is already load-bearing in a dozen controllers and the SPA, and a 1-based wrapper over a 0-based API would make the same parameter name mean different things on two surfaces a reader routinely reads together — trading a documented off-by-one for an undocumented one. Accuracy in the description is the cheaper contract.

Corollary — ids in paged rows. A row that names a related entity must expose that entity's id, not only its display fields. reset_channel_playout takes a channel id while playout rows exposed only the playout id plus channel name/number; the id spaces overlap numerically, so passing the row's id silently reset a different channel and returned a plausible 202. List rows gained channelId in #297; #616 added it to PlayoutResponseModel (the detail response) and named the trap in the MCP argument description.