Two LOWs from the re-review of af39123e. Both are the same defect I have already
corrected twice on this branch: a universal claim that the corrected body right
below it disproves. These read as normative, so a reader who stops at the title
gets the false version.
- The record was titled "...and every wrapper says so" while its own body admits
the 12 OpenAPI pageNum parameters carry no description at all (tracked as
#633). Retitled to state the target and name the exception.
- docs/mcp.md led with "Rows that reference another entity carry that entity's
id" — but PlayoutListItemResponseModel.ScheduleName ships with no schedule id.
Restated as a rule about ACTIONABLE references, matching the wording the record
already uses, with the known exception named.
The two remaining LOWs are accepted deferrals, not fixed here: the >100 rerun
collection truncation needs paging-to-completeness plus a multi-page fixture
(#634), and the catalog test's forward-looking gap (it compares tool names, not
PathTemplate against the paged-endpoint set) would need the MCP test project to
reference the controllers to close properly.
Docs only; catalog regenerated. No behaviour change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4.9 KiB
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; every wrapper must say so (OpenAPI still doesn't) (#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 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.