fix(616): document paging as 0-based, expose channelId on playout detail

#616 filed three MCP/API paging traps. Two were real; one was not, and one was
already half-fixed on main. Verified each against the code before changing it.

REAL — pageNum documented as 1-based. `ToolCatalog.Page()` described pageNum as
"1-based page number" while every paged controller defaults it to 0, floors it
with `Math.Max(0, pageNum)`, and skips `PageNum * PageSize`. A caller that
trusted the description started at page 1 and silently lost the first page: no
error, just a short set that reads as data loss rather than an off-by-one (it
cost #487 a verification pass). Fixed in the description rather than by making
the MCP layer 1-based: /api/v1 is additive-only post-freeze, 0-based is
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 two different things on two
surfaces a reader reads together.

NOT REAL — "pageSize caps the page but the offset honors the requested value".
Not reproducible on any endpoint. Every controller clamps before passing, every
handler skips by the clamped size, and GetCollectionItemsHandler re-clamps
defensively. The reported observation (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. The issue's own trap-1 table
states this. Pinned by test rather than "fixed".

ALREADY FIXED — playout LIST rows gained channelId in #297 (2026-07-22), three
days before #616 was filed; the report was measured against prod, which runs an
older :prod image. The DETAIL response (PlayoutResponseModel) genuinely still
lacked it, so channelId is added there (additive) and the reset_channel_playout
argument now names the trap: the id spaces overlap numerically, so passing a
playout id silently resets a different channel and returns a plausible 202.

Tests, both mutation-verified (each fails when its fix is reverted):
- ToolCatalogTests pins "0-based" on EVERY paged tool's pageNum description,
  with a non-empty guard so it can't pass vacuously over an empty tool set.
- GetCollectionItemsHandlerTests pins 0-based page boundaries and proves the
  offset derives from the clamped pageSize (page 1 at pageSize=500 returns
  items 101-150; the mutation that honors 500 returns an empty page).

Docs: new decision record api.paging-zero-based (catalog regenerated), the
api-conventions paging bullet, and a Paging section in docs/mcp.md. OpenAPI
v1.json + web/src/api/generated/v1.d.ts regenerated for the added field.

fixes #616

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-25 23:16:00 +02:00
co-authored by Claude Opus 5
parent e03c36e371
commit 8d35a2792f
12 changed files with 229 additions and 4 deletions
@@ -8,6 +8,7 @@ public record PlayoutResponseModel(
PlayoutScheduleKind ScheduleKind,
string ChannelName,
string ChannelNumber,
int ChannelId,
ChannelPlayoutMode PlayoutMode,
string ScheduleName,
string? ScheduleFile,
@@ -23,6 +24,7 @@ public record PlayoutResponseModel(
PlayoutScheduleKind scheduleKind,
string channelName,
string channelNumber,
int channelId,
ChannelPlayoutMode playoutMode,
string scheduleName,
string? scheduleFile,
@@ -37,6 +39,7 @@ public record PlayoutResponseModel(
scheduleKind,
channelName,
channelNumber,
channelId,
playoutMode,
scheduleName,
scheduleFile,
+29
View File
@@ -202,6 +202,35 @@ public class ToolCatalogTests
(tool.QueryParameters ?? new HashSet<string>()).ShouldNotContain("ifMatch");
}
// #616: the catalog described pageNum as "1-based" while every paged REST controller defaults it
// to 0 and skips pageNum * pageSize. A caller that trusted the description started at page 1 and
// silently lost the first page — no error, just a short set that reads like missing data. The
// description is the whole contract an MCP client has, so it is pinned here for EVERY paged tool.
[Test]
public void Paged_Tools_Should_Document_PageNum_As_Zero_Based()
{
ToolDefinition[] paged = ToolCatalog.All
.Where(t => t.InputSchema.RootElement.GetProperty("properties").TryGetProperty("pageNum", out _))
.ToArray();
// Guard the guard: if the catalog stops exposing paged tools this test must fail loudly
// rather than pass vacuously over an empty set.
paged.ShouldNotBeEmpty();
foreach (ToolDefinition tool in paged)
{
string description = tool.InputSchema.RootElement
.GetProperty("properties")
.GetProperty("pageNum")
.GetProperty("description")
.GetString()
.ShouldNotBeNull();
description.ShouldContain("0-based");
description.ShouldNotContain("1-based");
}
}
[Test]
public void Scan_Library_Tool_Should_Register_Deep_As_A_Query_Parameter()
{
+18 -3
View File
@@ -161,7 +161,12 @@ public static class ToolCatalog
"ersatztv_reset_channel_playout",
"Queue a rebuild of a channel's playout (202 Accepted; 409 if a build is already running).",
"/api/v1/channels/{id}/playout/reset",
[IdPath("Channel id.")],
[
IdPath(
"CHANNEL id — not the playout id. The two id spaces overlap numerically, so "
+ "passing a playout id here silently resets a different channel; take this "
+ "value from a playout row's channelId field (issue #616).")
],
[Str("mode", "Optional playout build mode; omit for the default. GET a playout to see valid values.", arg: In.Query)]),
Delete("ersatztv_delete_channel", "Delete a channel.", "/api/v1/channels/{id}", IdPath("Channel id.")),
Put(
@@ -230,10 +235,20 @@ public static class ToolCatalog
private static Arg ObjArray(string name, string description, bool required = false) =>
new(name, "array", description, required, In.Body, ItemType: "object");
// Paging mirrors the REST API it wraps, which is 0-BASED everywhere (issue #616): every paged
// controller defaults pageNum to 0 and skips `pageNum * pageSize`. The description said "1-based",
// so a caller that trusted it silently skipped the first page and read the result as data loss.
// Documented rather than translated: a 1-based MCP over a 0-based API would make the same
// parameter name mean two different things depending on which surface you were reading.
// pageSize is clamped server-side (100 on most endpoints), so an over-large value yields
// clamped-width pages — it does not widen the offset.
private static Arg[] Page() =>
[
Int("pageNum", "1-based page number (optional).", arg: In.Query),
Int("pageSize", "Page size (optional).", arg: In.Query)
Int("pageNum", "0-based page number; the first page is 0 (optional, default 0).", arg: In.Query),
Int(
"pageSize",
"Page size (optional). Clamped server-side, so pages may be narrower than requested.",
arg: In.Query)
];
// The channel create/update body (CreateChannelRequest / UpdateChannelRequest — the id comes from
@@ -61,6 +61,93 @@ public class GetCollectionItemsHandlerTests
page.Page.Select(i => i.Title).ShouldBe(["Zeta", "Alpha", "Beta"]);
}
// Paging semantics, pinned because #616 reported them as two bugs that measurement did not
// support. pageNum is 0-BASED (the trap: the MCP catalog documented it as 1-based, so a caller
// starting at 1 silently skipped the first page and read a short set as data loss).
[Test]
public async Task Handle_Should_Treat_PageNum_As_Zero_Based()
{
await SeedNumberedCollection(150);
var handler = new GetCollectionItemsHandler(_db.Factory);
Either<BaseError, PagedLibraryBrowseItemsResponseModel> first =
await handler.Handle(new GetCollectionItems(10, 0, 10), CancellationToken.None);
Either<BaseError, PagedLibraryBrowseItemsResponseModel> second =
await handler.Handle(new GetCollectionItems(10, 1, 10), CancellationToken.None);
// Page 0 is the FIRST page, not a skipped one; page 1 is the second.
first.RightToSeq().Single().Page.Select(i => i.Title).First().ShouldBe("Item 001");
second.RightToSeq().Single().Page.Select(i => i.Title).First().ShouldBe("Item 011");
}
// The second half of #616's claim was that an over-large pageSize caps the returned page but
// leaves the OFFSET computed from the requested value, so page 1 at pageSize=500 would land past
// item 500. It does not: the size is clamped first and the offset derives from the clamped value.
[Test]
public async Task Handle_Should_Derive_Offset_From_The_Clamped_PageSize()
{
await SeedNumberedCollection(150);
var handler = new GetCollectionItemsHandler(_db.Factory);
// pageSize 500 clamps to 100, so page 1 starts at item 101 and runs to the end (50 items).
// If the offset honored the requested 500, this page would start past the collection and be
// empty — which is exactly what the mutation of this fix produces.
Either<BaseError, PagedLibraryBrowseItemsResponseModel> result =
await handler.Handle(new GetCollectionItems(10, 1, 500), CancellationToken.None);
PagedLibraryBrowseItemsResponseModel page = result.RightToSeq().Single();
page.TotalCount.ShouldBe(150);
page.Page.Count.ShouldBe(50);
page.Page.Select(i => i.Title).First().ShouldBe("Item 101");
page.Page.Select(i => i.Title).Last().ShouldBe("Item 150");
}
private async Task SeedNumberedCollection(int count)
{
await using TvContext context = _db.CreateContext();
var library = new LocalLibrary
{
Id = 1,
Name = "Library",
MediaKind = LibraryMediaKind.Movies,
Paths = []
};
var path = new LibraryPath
{
Id = 1,
Path = "/media",
Library = library,
LibraryFolders = [],
MediaItems = []
};
library.Paths.Add(path);
var collection = new Collection
{
Id = 10,
Name = "Manual",
UseCustomPlaybackOrder = false,
MediaItems = [],
CollectionItems = [],
MultiCollections = [],
MultiCollectionItems = []
};
var movies = new List<Movie>();
for (var i = 1; i <= count; i++)
{
// Zero-padded so the handler's title ordering matches numeric order.
movies.Add(MakeMovie(1000 + i, path, $"Item {i:D3}"));
collection.CollectionItems.Add(new CollectionItem { MediaItemId = 1000 + i });
}
context.LocalLibraries.Add(library);
context.Movies.AddRange(movies);
context.Collections.Add(collection);
await context.SaveChangesAsync();
}
private async Task SeedCollectionGraph(bool useCustomPlaybackOrder)
{
await using TvContext context = _db.CreateContext();
@@ -166,6 +166,25 @@ public class PlayoutControllerTests
.Seed.ShouldBe(4242);
}
// #616: the playout DETAIL response carried channelName/channelNumber but no channelId, while
// reset_channel_playout takes a CHANNEL id. The two id spaces overlap numerically, so a caller
// that reached for the row's `id` reset a different channel and got a plausible 202 back. The
// list rows gained channelId in #297; this pins the same field on the detail response, and the
// distinct ids below prove it is the channel's, not the playout's.
[Test]
public async Task GetById_Should_Surface_ChannelId_Distinct_From_PlayoutId()
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ChannelId = 400 }));
IActionResult result = await _controller.GetById(9, CancellationToken.None);
PlayoutResponseModel body = result.ShouldBeOfType<OkObjectResult>()
.Value.ShouldBeOfType<PlayoutResponseModel>();
body.Id.ShouldBe(9);
body.ChannelId.ShouldBe(400);
}
[Test]
public async Task GetAll_Should_Surface_Seed()
{
@@ -1432,6 +1451,7 @@ public class PlayoutControllerTests
vm.ScheduleKind,
vm.ChannelName,
vm.ChannelNumber,
vm.ChannelId,
vm.PlayoutMode,
vm.ScheduleName,
vm.ScheduleFile,
@@ -818,6 +818,7 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
vm.ScheduleKind,
vm.ChannelName,
vm.ChannelNumber,
vm.ChannelId,
vm.PlayoutMode,
vm.ScheduleName,
vm.ScheduleFile,
+5
View File
@@ -28954,6 +28954,7 @@
"scheduleKind",
"channelName",
"channelNumber",
"channelId",
"playoutMode",
"scheduleName",
"scheduleFile",
@@ -28979,6 +28980,10 @@
"channelNumber": {
"type": "string"
},
"channelId": {
"type": "integer",
"format": "int32"
},
"playoutMode": {
"$ref": "#/components/schemas/ChannelPlayoutMode"
},
+6 -1
View File
@@ -41,7 +41,12 @@ Exemplars:
- **Paged GET with clamped params**: `ErsatzTV/Controllers/Api/LogsController.cs`
`pageNum` clamped via `Math.Max(0, pageNum)`, `pageSize` via `Math.Clamp(pageSize, 1, MaxPageSize)`
(`MaxPageSize = 100`). Any new paged endpoint should clamp the same way — don't trust client
input for page math.
input for page math. **`pageNum` is 0-based** across the whole surface (the first page is `0`) and
the offset is always derived from the *clamped* `pageSize`, so an over-large `pageSize` yields
narrower pages — it never widens the offset. Say "0-based" in the description of any paging
parameter you expose, including on wrapper surfaces like the MCP tool catalog: describing it as
1-based makes a caller skip the first page silently, which reads as data loss rather than as an
off-by-one (ersatztv#616). See `api.paging-zero-based`.
- **Sortable GET with allow-listed sort params**: same file — `sortField`/`sortDirection` are
normalized against a fixed allow-list (`AllowedSortFields`) rather than trusted or rejected with
a 422: an unrecognized `sortField` silently falls back to the default field, an unrecognized
+1
View File
@@ -19,6 +19,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `api.logs-sort-params` | `GET /api/logs` takes allow-listed `sortField` (`timestamp`\|`level`) and `sortDirection` (`asc`\|`desc`) query params, normalized (not rejected) on an unrecognized value. | 2026-07-11 | [link](records/api/logs-sort-params.md) |
| `api.mediatr-passthrough` | The REST API is thin controllers over existing MediatR handlers, with no new service/business-logic layer. | 2026-06 | [link](records/api/mediatr-passthrough.md) |
| `api.openapi-mirrors-runtime` | The generated OpenAPI spec is made to match the runtime Newtonsoft wire contract (via `NewtonsoftSchemaNamingTransformer`), not the reverse. | 2026-07-09 | [link](records/api/openapi-mirrors-runtime.md) |
| `api.paging-zero-based` | `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 CLAMPED `pageSize`, so an over-large `pageSize` narrows the page without widening the offset. A paging parameter description that omits or contradicts "0-based" is a defect. | 2026-07-25 | [link](records/api/paging-zero-based.md) |
| `api.parentid-drillin` | Media drill-in (season/episode/artist/music-video) is served by an optional `parentId` query param on library-browse, not dedicated per-kind child-listing endpoints. | 2026-07-07 | [link](records/api/parentid-drillin.md) |
| `api.playout-build-lock-409` | Every id-keyed playout/channel mutation endpoint checks `IEntityLocker.IsPlayoutLocked(id)` and returns 409 Conflict while a build is in-flight, mirroring Blazor's disabled-buttons behavior; reset-all stays 202 and silently skips locked playouts. | 2026-07-10 | [link](records/api/playout-build-lock-409.md) |
| `api.postcommit-cancellation-none` | Once a mutation commits, its entire compensating side effect (enqueues, publishes, reindexes, cache refresh, and any post-commit lookup gating one of those) runs on `CancellationToken.None` so a late client disconnect can't half-abort an already-committed change. | 2026-07-11 | [link](records/api/postcommit-cancellation-none.md) |
@@ -0,0 +1,42 @@
---
key: api.paging-zero-based
title: 2026-07-25 — Paging is 0-based everywhere, and every wrapper says so (#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 CLAMPED `pageSize`, so an over-large `pageSize` narrows the page without widening the offset. 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)` + `Math.Clamp(pageSize, 1, MaxPageSize)` in the controller, `Skip(PageNum * PageSize)` in the handler'
---
Every paged controller on `/api/v1` defaults `pageNum` to `0`, floors it with `Math.Max(0, pageNum)`,
clamps `pageSize` with `Math.Clamp(pageSize, 1, MaxPageSize)`, and passes **both clamped values** to a
handler that skips `PageNum * PageSize`. That makes paging uniformly 0-based, and makes the offset a
function of the clamped size rather than the requested one.
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.
+16
View File
@@ -84,6 +84,22 @@ When a response carries an `ETag` header (versioned aggregates emit it on GET an
replace PUT), the tool result appends a `\n[etag: "N"]` marker so an agent can round-trip it as
`ifMatch` on a subsequent write.
### Paging (`api.paging-zero-based`)
The paged tools take `pageNum`/`pageSize` and pass them through unchanged — the MCP layer does **not**
translate them, so they mean exactly what they mean on `/api/v1`:
- **`pageNum` is 0-based.** The first page is `0`. Starting at `1` silently skips a page and returns a
short set with no error, which is easy to misread as missing data (ersatztv#616 — the catalog used
to describe it as 1-based, and #487 lost a verification pass to it).
- **`pageSize` is clamped server-side** (100 on most endpoints, 200 on a few). An over-large value
gives you narrower pages, not a wider offset: `pageSize=500&pageNum=1` returns items 101200, not
5011000. Page to completeness against `totalCount` rather than assuming your requested size held.
Rows that reference another entity carry that entity's id, not just its display fields — take the
channel id for `ersatztv_reset_channel_playout` from a playout row's `channelId`, never from its `id`
(that is the *playout* id, and the two id spaces overlap numerically).
### Optimistic concurrency (`api-conventions.md` §7a)
Only the **replace-all aggregate PUTs** honor `If-Match` — here that is
+1
View File
@@ -1168,6 +1168,7 @@ export interface components {
"scheduleKind": components["schemas"]["PlayoutScheduleKind"];
"channelName": string;
"channelNumber": string;
"channelId": number;
"playoutMode": components["schemas"]["ChannelPlayoutMode"];
"scheduleName": string;
"scheduleFile": null | string;