--- key: api.paging-zero-based title: '2026-07-25 — Paging is 0-based everywhere; every wrapper must say so (#616, #633)' status: active since: '2026-07-25' supersedes: none superseded-by: none rule: '`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.' signals: '`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' mechanics: '`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 at 0 (`Math.Max(0, pageNum)` — `search/all-items` uses `Math.Clamp(pageNum, 0, 2_000_000)` because it additionally needs an upper bound to keep `pageNum * pageSize` inside `int`), 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 201–204. 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. **Where "0-based" is stated.** The MCP tool catalog, these docs, and — since #633 — the generated OpenAPI document all say it explicitly. All 24 paging parameters across the 12 paged operations carry a `[Description]` (`System.ComponentModel`, on the `[FromQuery]` parameter, the same mechanism `parentId` already used), so a REST consumer reading only `v1.json` no longer has to infer the base from `default: 0` — which is the inference that cost #487 a verification pass on the MCP side, where the description was present but wrong. `pageSize` descriptions state the endpoint's own cap and that the offset derives from the effective size, never a single global number. That sweep is pinned by `OpenApiPagingContractTests` against the in-process generated document. The test names the expected set of 12 operations rather than only filtering for parameters called `pageNum`: a filter cannot see an endpoint that *should* page and doesn't, so set-equality is asserted in both directions — a new paged endpoint fails until it is added with descriptions, and an endpoint that quietly drops paging fails too. Both directions are mutation-verified. The residual gap this cannot close is a brand-new endpoint that returns a page while declaring no paging parameters at all under any name; nothing in the document distinguishes that from an unpaged endpoint, so it stays a review concern. **Corollary — ids in paged rows.** A row that names a related entity should expose that entity's id, not only its display fields, wherever a caller is expected to act on that entity. This is a rule about actionable ids, not an audit result: `PlayoutListItemResponseModel.ScheduleName` still ships without a schedule id, which is fine while nothing asks a caller to address a schedule from that row. `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.