--- key: api.paging-zero-based title: 2026-07-25 — Paging is 0-based everywhere; every wrapper must say so (OpenAPI still doesn't) (#616) 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, and where it still isn't.** The MCP tool catalog and these docs say it explicitly. The generated OpenAPI `pageNum` parameters carry **no description at all** (12 of them), so a REST consumer reading only `v1.json` still has to infer the base from the default — a real remaining gap, tracked separately rather than fixed here. Treat "every wrapper says 0-based" as the target this record sets, not a property already true of the OpenAPI surface. **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.