Files
ersatztv/docs/decisions/records/api/paging-zero-based.md
T
timothyandClaude Opus 5 37fd30dce7
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 19s
PR Gates / Docs update reminder (pull_request) Successful in 20s
PR Gates / decisions lifecycle (pull_request) Successful in 20s
Review verdict / Set review-verdict status (pull_request) Successful in 3s
PR Gates / Script tests (pytest) (pull_request) Successful in 37s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m29s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 20s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 15m41s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 15m57s
review-verdict/h10 Review-verdict: MERGEABLE @ 37fd30d
fix(631): quote a record title whose unquoted # was a YAML comment
Third latent defect surfaced purely by running scripts/tests in CI — and this one was
already red on origin/main, where test_frontmatter_reader_matches_pyyaml_on_every_real_record
fails.

`api/paging-zero-based.md` carried an UNQUOTED plain scalar title ending
"... (#616, #633)". In YAML a space followed by `#` starts a COMMENT, so PyYAML reads the
title as truncated at "(#616," while the hand-written dependency-free reader keeps the
whole line. PyYAML is correct per spec; the record's title was genuinely losing "#633)"
for any spec-compliant consumer, which includes MemPalace's ingest.

Fixed by quoting the scalar; both parsers now agree byte-for-byte.

Asked "instance or class?" before fixing: swept every frontmatter field of all 179
records under both parsers and compared. This is the ONLY divergence in the corpus, so a
one-line fix is the right scope — no lint rule needed, and #621's structural guard
already makes a parse FAILURE loud (this was a parse-to-different, which no structural
check can see).

216 passed under jq 1.8.2 and jq 1.6.

Refs #631

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 13:27:05 +02:00

5.8 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; every wrapper must say so (#616, #633) 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 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.

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.