Two instances of one defect, plus the guard that makes the class non-recurring.
ToolCatalog.ChannelFields() declared 27 of UpdateChannelRequest's 28 properties. The missing
one was graphicsElementIds. PUT /api/v1/channels/{id} is a full replace and the tool's own
description says "send the full desired state" — which the schema could not express. An agent that
faithfully GET-edit-PUT a channel detached every graphics element, including the built-in On
Now/Next overlay, with a 200 and no error. Nothing surfaced until the overlay stopped rendering.
Fixing that one field would have left the mechanism intact — and the mechanism had already produced
a second live instance that #754 did not know about: ScheduleFlags() omitted padToNearestMinute,
which UpdateProgramScheduleHandler writes unconditionally, so ersatztv_update_schedule silently
cleared a configured pad the same way.
Live proof (ersatztv-test), with the bug as its own positive control
step
graphicsElementIds
before
[1]
update omitting the field — 200, isError=false
[] ← the bug
update with the field
[1] restored
The guard (this is the actual fix)
Two tests in ToolCatalogTests, asserting against the generated v1.json (linked into the test
project — no project reference to the ASP.NET host):
every POST/PUT/PATCH tool declares exactly its endpoint's request-body fields, each with a
matching type;
every tool, read and write, declares exactly its endpoint's query parameters (#757, folded
in at the user's request). An omitted one is unreachable, not merely undocumented, because ToolArgumentValidator rejects undeclared arguments. ersatztv_list_playouts had lost its
channel-name query filter and ersatztv_get_playout_items its showFiller; the guard found
exactly those two and nothing else.
Body derivation mirrors ErsatzTvApiClient exactly, so the guard cannot disagree with the routing it
guards. The query test accumulates mismatches and asserts once — failing on the first would invite
fixing one tool at a time, which is how the twin in this very issue stayed hidden.
Verified by mutation, not assumed. Dropping either field, retyping either, drifting an array's
element type, dropping either query parameter, adding a phantom query parameter, and deleting the
copied spec each turn the suite red and name the fault.
Review
Three adversarial rounds (cold, worktree-isolated, review-only). Round 1 BLOCKED: the schedule
twin got the schema fix but not its warning; my docs/mcp.md claim that "every write is a full
replace" was false for four tools; and the guard compared names only — the reviewer proved by
mutation that retyping padToNearestMinute to a string stayed green. Round 2 BLOCKED: I had filed ersatztv_update_playout as leave-unchanged when it is mixed (DailyRebuildTime is nulled
unconditionally), so the paragraph written to prevent the #754 shape would have caused it. Round 3 MERGEABLE, re-confirmed on the merging sha 29abd470e.
Recorded rather than fixed, in the decision record's scope section: the type comparison is lossy at
the catalog's ceiling (objects collapse to object, enums to string), the >1 type-union assertion
is deliberate but unexercised, and the api-docs freshness conjunction covers a DTO's own properties
only, not nested models.
Follow-ups filed
#758 — GetPagedPlayouts counts before both the query and Channel != null filters, so a
filtered page reports the unfiltered total. Pre-existing; #757 is what exposes it. Same family as #690.
#759 — the query guard silently ignores a $ref'd parameter where the body guard fails loudly.
Dormant (zero such parameters today), filed with that evidence.
Verification
61/61 MCP tests · 441 script tests · dotnet format exit 0 · no BOMs · decisions-validate: OK ·
live E2E on ersatztv-test.
Caveat, stated rather than buried: the padToNearestMinute half has no live round-trip. The
deployed test image predates that field entirely (its /openapi/v1.json lacks it), so the API ignored
it. Environment limitation, not a defect — the field is real on main, which is what the guard
asserts against.
## What
Two instances of one defect, plus the guard that makes the class non-recurring.
`ToolCatalog.ChannelFields()` declared **27 of `UpdateChannelRequest`'s 28** properties. The missing
one was `graphicsElementIds`. `PUT /api/v1/channels/{id}` is a **full replace** and the tool's own
description says *"send the full desired state"* — which the schema could not express. An agent that
faithfully GET-edit-PUT a channel **detached every graphics element**, including the built-in On
Now/Next overlay, with a `200` and no error. Nothing surfaced until the overlay stopped rendering.
Fixing that one field would have left the mechanism intact — and the mechanism had **already produced
a second live instance** that #754 did not know about: `ScheduleFlags()` omitted `padToNearestMinute`,
which `UpdateProgramScheduleHandler` writes unconditionally, so `ersatztv_update_schedule` silently
cleared a configured pad the same way.
## Live proof (ersatztv-test), with the bug as its own positive control
| step | `graphicsElementIds` |
|---|---|
| before | `[1]` |
| update **omitting** the field — `200`, `isError=false` | `[]` ← the bug |
| update **with** the field | `[1]` restored |
## The guard (this is the actual fix)
Two tests in `ToolCatalogTests`, asserting against the generated `v1.json` (linked into the test
project — no project reference to the ASP.NET host):
- every **POST/PUT/PATCH** tool declares exactly its endpoint's request-body fields, **each with a
matching type**;
- every tool, **read and write**, declares exactly its endpoint's **query parameters** (#757, folded
in at the user's request). An omitted one is *unreachable*, not merely undocumented, because
`ToolArgumentValidator` rejects undeclared arguments. `ersatztv_list_playouts` had lost its
channel-name `query` filter and `ersatztv_get_playout_items` its `showFiller`; the guard found
exactly those two and nothing else.
Body derivation mirrors `ErsatzTvApiClient` exactly, so the guard cannot disagree with the routing it
guards. The query test **accumulates** mismatches and asserts once — failing on the first would invite
fixing one tool at a time, which is how the twin in this very issue stayed hidden.
**Verified by mutation, not assumed.** Dropping either field, retyping either, drifting an array's
element type, dropping either query parameter, adding a phantom query parameter, and deleting the
copied spec each turn the suite red and name the fault.
## Review
Three adversarial rounds (cold, worktree-isolated, review-only). Round 1 **BLOCKED**: the schedule
twin got the schema fix but not its warning; my `docs/mcp.md` claim that "every write is a full
replace" was false for four tools; and the guard compared **names only** — the reviewer proved by
mutation that retyping `padToNearestMinute` to a string stayed green. Round 2 **BLOCKED**: I had filed
`ersatztv_update_playout` as leave-unchanged when it is **mixed** (`DailyRebuildTime` is nulled
unconditionally), so the paragraph written to prevent the #754 shape would have caused it. Round 3
**MERGEABLE**, re-confirmed on the merging sha `29abd470e`.
Recorded rather than fixed, in the decision record's scope section: the type comparison is lossy at
the catalog's ceiling (objects collapse to `object`, enums to `string`), the `>1` type-union assertion
is deliberate but unexercised, and the `api-docs` freshness conjunction covers a DTO's own properties
only, not nested models.
## Follow-ups filed
- **#758** — `GetPagedPlayouts` counts before both the `query` and `Channel != null` filters, so a
filtered page reports the unfiltered total. Pre-existing; #757 is what *exposes* it. Same family as
#690.
- **#759** — the query guard silently ignores a `$ref`'d parameter where the body guard fails loudly.
Dormant (zero such parameters today), filed with that evidence.
## Verification
61/61 MCP tests · 441 script tests · `dotnet format` exit 0 · no BOMs · `decisions-validate: OK` ·
live E2E on ersatztv-test.
**Caveat, stated rather than buried:** the `padToNearestMinute` half has **no** live round-trip. The
deployed test image predates that field entirely (its `/openapi/v1.json` lacks it), so the API ignored
it. Environment limitation, not a defect — the field is real on `main`, which is what the guard
asserts against.
fixes #754
fixes #757
`ToolCatalog.ChannelFields()` declared 27 of `UpdateChannelRequest`'s 28
properties. The missing one was `graphicsElementIds`. Because
`PUT /api/v1/channels/{id}` is a full replace and the tool's own description
says "send the full desired state", an agent that faithfully GET-edit-PUT a
channel detached every attached graphics element -- including the built-in On
Now/Next overlay -- with a 200 and no error, observable only as missing pixels
at the next transition.
It is declared on the update tool only: `CreateChannelRequest` has no such
property and the tool schemas are `additionalProperties:false`, so putting it in
the shared helper would make every create call send an unknown property.
Fixing that one field would have left the mechanism intact, and the mechanism
had already produced a second live instance: `ScheduleFlags()` omitted
`padToNearestMinute`, which both schedule requests carry and
`UpdateProgramScheduleHandler` writes unconditionally, so
`ersatztv_update_schedule` silently cleared a configured pad the same way. That
one belongs in the shared helper, and is added there.
So the guard is the fix: `Every_Write_Tool_Should_Declare_Exactly_Its_OpenApi_Request_Body_Fields`
asserts every POST/PUT/PATCH tool's body set against the generated `v1.json`
(linked into the test project), deriving the body exactly as `ErsatzTvApiClient`
does so the guard cannot disagree with the routing it guards. The covered tool
set is pinned by name rather than only filtered, and a missing or non-`$ref`
spec fails loudly instead of comparing against an empty set. Verified by
mutation: dropping either field, or the copied spec, turns the suite red and
names the fault.
fixes#754
Decisions-Edit: yes
Independent review found three defects in the first pass.
1. HIGH. `ersatztv_update_schedule` got the schema fix but not the guidance.
Declaring `padToNearestMinute` makes it EXPRESSIBLE, not SENT: it is
optional, and the handler writes it unconditionally, so a rename that omits
it still clears a configured pad. The channel tool carries exactly that
warning and the schedule tool -- this PR's own second instance -- did not.
It now does.
2. MEDIUM-HIGH. docs/mcp.md asserted "every write here is a full replace" one
paragraph after describing `add_collection_items` as an idempotent ADD. It
is false for four tools (add-collection-items, update_collection,
update_playout.scheduleFile, enable_jellyfin_library_sync), and an agent
believing it would assume add-items REPLACES membership -- a new bug in the
opposite direction. The claim is now scoped, in the doc and in the decision
record's `rule:` frontmatter, which is the string the catalog and MemPalace
mirror.
3. MEDIUM. The guard compared NAMES only, so type drift was invisible: retyping
`padToNearestMinute` to a string left the suite green, and that is the one
field this PR adds. It now compares name AND type, normalizing the
generator's ["null", T] and $ref forms (enum -> string, model -> object) and
resolving arrays to their element type. Verified by mutation: the reviewer's
exact Int->Str mutation, an array element-type drift, and an unrelated
Int->Str all go red; the array-of-$ref shape
(ReplaceRemoteLibraryPreferencesRequest.libraries) is resolved recursively
rather than assumed scalar.
The record now also states the guard's SCOPE rather than implying more: query
parameters are uncovered on every verb, DELETE is uncovered, and two read tools
already drift (#757). It also notes the guard is a two-job conjunction with the
`api-docs` freshness job, not self-contained.
Decisions-Edit: yes
Folds ersatztv#757 into this PR at the user's request, and fixes review round 2.
Query-parameter parity (#757). A second test compares every tool's routed
QueryParameters against the spec's parameters[in=query] -- reads included,
because the drift was entirely on reads. `ersatztv_list_playouts` now declares
`query` (case-insensitive substring on the CHANNEL name, per
GetPagedPlayoutsHandler) and `ersatztv_get_playout_items` declares `showFiller`
(per GetFuturePlayoutItemsByIdHandler's FillerKind.None filter). Those two were
the complete drift set -- the guard reported them, not a hand audit.
The test ACCUMULATES mismatches and asserts once, so one run reports the whole
drift set. Failing on the first would hand back one tool at a time and invite
fixing them one at a time, which is precisely how the #754 twin stayed hidden.
Verified by mutation: dropping either newly declared parameter goes red, and a
phantom query arg on a write tool goes red (the reviewer's round-1 finding 5,
which was green before this).
Review round 2 (CONFIRMED at UpdatePlayoutHandler.cs:31). My round-1 docs fix
sorted the write tools into full-replace vs leave-unchanged and filed
`ersatztv_update_playout` under leave-unchanged. It is MIXED: `scheduleFile` is
leave-unchanged, but `DailyRebuildTime` is set to null unconditionally before a
supplied value is re-applied, so setting scheduleFile while omitting
dailyRebuildTime silently clears the daily reset. The paragraph written to
prevent the #754 shape would have caused it on the one tool with mixed
semantics. Now called out explicitly as the easy one to get wrong.
Also from round 2, recorded rather than fixed: the type comparison is lossy at
the catalog's ceiling (every object component collapses to `object`, every enum
to `string`), so a swapped model or enum is invisible -- confirmed green when
`logo` is repointed at an unrelated model. Deeper comparison would assert a
distinction no tool schema carries, and an opaque object is copied through from
a GET verbatim, so it cannot cause a silent clear. The >1 type-union assertion
is deliberate but unexercised. The api-docs freshness conjunction covers a DTO's
own properties only, not nested models outside ^ErsatzTV/Controllers/Api/.
fixes#757
Decisions-Edit: yes
Round-3 review found one statement in the PR that was not true of the code:
showFiller's description named four of FillerKind's seven non-None values
(pre/mid/post-roll, tail), omitting Fallback, GuideMode and DecoDefault. The
handler filters `ShowFiller || FillerKind == None`, so it excludes ALL of them,
and an agent debugging a missing Fallback or Deco item would not have found it
in that list. Verified against Core/Domain/Filler/FillerKind.cs.
Also records the anti-vacuity argument the reviewer surfaced, which was the
strongest one in this PR and was going unstated: the pre-existing
Every_Query_Parameter_Should_Be_A_Declared_Property filters on
`QueryParameters is { Count: > 0 }` -- a filter on the very property it asserts
-- so a tool that lost its query parameters escaped it entirely. That is
precisely how list_playouts and get_playout_items hid. The new unfiltered test
reports them as unreachable and the old one checks the routed-vs-declared half;
neither subsumes the other, which is why the inner duplicate was removed rather
than kept.
Decisions-Edit: yes
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What
Two instances of one defect, plus the guard that makes the class non-recurring.
ToolCatalog.ChannelFields()declared 27 ofUpdateChannelRequest's 28 properties. The missingone was
graphicsElementIds.PUT /api/v1/channels/{id}is a full replace and the tool's owndescription says "send the full desired state" — which the schema could not express. An agent that
faithfully GET-edit-PUT a channel detached every graphics element, including the built-in On
Now/Next overlay, with a
200and no error. Nothing surfaced until the overlay stopped rendering.Fixing that one field would have left the mechanism intact — and the mechanism had already produced
a second live instance that #754 did not know about:
ScheduleFlags()omittedpadToNearestMinute,which
UpdateProgramScheduleHandlerwrites unconditionally, soersatztv_update_schedulesilentlycleared a configured pad the same way.
Live proof (ersatztv-test), with the bug as its own positive control
graphicsElementIds[1]200,isError=false[]← the bug[1]restoredThe guard (this is the actual fix)
Two tests in
ToolCatalogTests, asserting against the generatedv1.json(linked into the testproject — no project reference to the ASP.NET host):
matching type;
in at the user's request). An omitted one is unreachable, not merely undocumented, because
ToolArgumentValidatorrejects undeclared arguments.ersatztv_list_playoutshad lost itschannel-name
queryfilter andersatztv_get_playout_itemsitsshowFiller; the guard foundexactly those two and nothing else.
Body derivation mirrors
ErsatzTvApiClientexactly, so the guard cannot disagree with the routing itguards. The query test accumulates mismatches and asserts once — failing on the first would invite
fixing one tool at a time, which is how the twin in this very issue stayed hidden.
Verified by mutation, not assumed. Dropping either field, retyping either, drifting an array's
element type, dropping either query parameter, adding a phantom query parameter, and deleting the
copied spec each turn the suite red and name the fault.
Review
Three adversarial rounds (cold, worktree-isolated, review-only). Round 1 BLOCKED: the schedule
twin got the schema fix but not its warning; my
docs/mcp.mdclaim that "every write is a fullreplace" was false for four tools; and the guard compared names only — the reviewer proved by
mutation that retyping
padToNearestMinuteto a string stayed green. Round 2 BLOCKED: I had filedersatztv_update_playoutas leave-unchanged when it is mixed (DailyRebuildTimeis nulledunconditionally), so the paragraph written to prevent the #754 shape would have caused it. Round 3
MERGEABLE, re-confirmed on the merging sha
29abd470e.Recorded rather than fixed, in the decision record's scope section: the type comparison is lossy at
the catalog's ceiling (objects collapse to
object, enums tostring), the>1type-union assertionis deliberate but unexercised, and the
api-docsfreshness conjunction covers a DTO's own propertiesonly, not nested models.
Follow-ups filed
GetPagedPlayoutscounts before both thequeryandChannel != nullfilters, so afiltered page reports the unfiltered total. Pre-existing; #757 is what exposes it. Same family as
#690.
$ref'd parameter where the body guard fails loudly.Dormant (zero such parameters today), filed with that evidence.
Verification
61/61 MCP tests · 441 script tests ·
dotnet formatexit 0 · no BOMs ·decisions-validate: OK·live E2E on ersatztv-test.
Caveat, stated rather than buried: the
padToNearestMinutehalf has no live round-trip. Thedeployed test image predates that field entirely (its
/openapi/v1.jsonlacks it), so the API ignoredit. Environment limitation, not a defect — the field is real on
main, which is what the guardasserts against.
fixes #754
fixes #757
`ToolCatalog.ChannelFields()` declared 27 of `UpdateChannelRequest`'s 28 properties. The missing one was `graphicsElementIds`. Because `PUT /api/v1/channels/{id}` is a full replace and the tool's own description says "send the full desired state", an agent that faithfully GET-edit-PUT a channel detached every attached graphics element -- including the built-in On Now/Next overlay -- with a 200 and no error, observable only as missing pixels at the next transition. It is declared on the update tool only: `CreateChannelRequest` has no such property and the tool schemas are `additionalProperties:false`, so putting it in the shared helper would make every create call send an unknown property. Fixing that one field would have left the mechanism intact, and the mechanism had already produced a second live instance: `ScheduleFlags()` omitted `padToNearestMinute`, which both schedule requests carry and `UpdateProgramScheduleHandler` writes unconditionally, so `ersatztv_update_schedule` silently cleared a configured pad the same way. That one belongs in the shared helper, and is added there. So the guard is the fix: `Every_Write_Tool_Should_Declare_Exactly_Its_OpenApi_Request_Body_Fields` asserts every POST/PUT/PATCH tool's body set against the generated `v1.json` (linked into the test project), deriving the body exactly as `ErsatzTvApiClient` does so the guard cannot disagree with the routing it guards. The covered tool set is pinned by name rather than only filtered, and a missing or non-`$ref` spec fails loudly instead of comparing against an empty set. Verified by mutation: dropping either field, or the copied spec, turns the suite red and names the fault. fixes #754 Decisions-Edit: yesRound-3 review found one statement in the PR that was not true of the code: showFiller's description named four of FillerKind's seven non-None values (pre/mid/post-roll, tail), omitting Fallback, GuideMode and DecoDefault. The handler filters `ShowFiller || FillerKind == None`, so it excludes ALL of them, and an agent debugging a missing Fallback or Deco item would not have found it in that list. Verified against Core/Domain/Filler/FillerKind.cs. Also records the anti-vacuity argument the reviewer surfaced, which was the strongest one in this PR and was going unstated: the pre-existing Every_Query_Parameter_Should_Be_A_Declared_Property filters on `QueryParameters is { Count: > 0 }` -- a filter on the very property it asserts -- so a tool that lost its query parameters escaped it entirely. That is precisely how list_playouts and get_playout_items hid. The new unfiltered test reports them as unreachable and the old one checks the routed-vs-declared half; neither subsumes the other, which is why the inner duplicate was removed rather than kept. Decisions-Edit: yesReview-verdict: MERGEABLE @
29abd473 adversarial rounds (2 BLOCKED then fixed); re-confirmed on merging sha. Mutation-verified guard; live E2E on ersatztv-test. Follow-ups #758, #759.