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
@@ -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,