UpdateChannelHandler.Validate checked name/number/EPG/logo/group but never validated the
incoming graphicsElementIds. The reconcile blindly Added a ChannelGraphicsElement for any
submitted id, so PUT /api/v1/channels/{id} with a non-existent id hit FK_ChannelGraphicsElement_GraphicsElement_GraphicsElementId -> DbUpdateException -> an
unhandled 500 (data-safe, the transaction rolled back, but inconsistent with every other FK field
on the same full-replace DTO).
Separately, GetAllGraphicsElementsForApiHandler and GraphicsElementSeeder keyed the builtIn
discriminator off Path.GetFileName(e.Path) == GraphicsElementDefaults.OnNowNextFileName alone —
filename-only, so a user element named exactly on-now-next.yml in any of the other graphics
folders (image/motion/subtitle/script) would also report builtIn:true.
Fix, as the code now stands
Existence validators: ErsatzTV.Application/Validators/IdListValidation.cs adds a shared IdsMustExist used by UpdateChannelHandler (graphicsElementIds, watermarkId,
fallbackFillerId, mirrorSourceChannelId) and UpdateDecoHandler's graphics-element twin —
rejects unknown ids with a 422 whose detail names the request field
([GraphicsElementIds] Graphics element(s) do not exist: …).
Raw-count cap and its bound: IdsMustExist rejects a submitted list over Validators.MaximumIdListCount (512) before Distinct or any DB work — the count is taken from
the raw list because the request pays for parsing/materializing it regardless of how many ids
are actually distinct or consumed. MaximumReportedMissingIds (10) bounds how many rejected ids
the 422 body echoes back, so an oversized request can't produce an oversized response.
Lost-race recovery path: UpdateChannelHandler.ApplyUpdateRequestTranslatingLostRace catches DbUpdateException from the save (a concurrent delete of a graphics element/watermark/etc.
between validation and write can still hit the FK) and re-runs the whole validator set — not
just the graphics-element half — against a fresh context, translating a real FK loss back into
the same 422 the validator would have returned. Re-running the full set (rather than naming
fields one by one) is what keeps this path from silently missing the next FK the DTO gains. UpdateDecoHandler carries the same twin recovery path for its own FK fields.
builtIn discriminator: GraphicsElementDefaults.IsOnNowNext(path, kind) is now the one
identity predicate — full seeded path (OnNowNextSeededPath, folder-qualified) compared
ordinally, AND Kind == Text — used in memory by both GetAllGraphicsElementsForApiHandler and GraphicsElementSeeder.SeedOnNowNext's own "does the built-in row already exist" check, so the
two call sites can no longer disagree about the same row. Comparison stays in memory rather than
a SQL Where because GraphicsElement.Path takes no explicit collation, so SQLite compares
case-sensitively while MySQL would use the server default (normally case-insensitive) — pushing
the comparison into SQL would let the two providers disagree.
Deco twins: UpdateDecoHandler mirrors the channel handler's validation, count cap and
lost-race recovery for its own graphics-element ids, with one difference: a deco's DecoMode
can make the apply path discard the graphics-element list entirely, so the existence check is
gated off in that case (idsAreConsumed: false) while the count cap still applies unconditionally
— the list is still parsed and materialized out of the request body whatever the apply path does
with it afterwards.
dotnet test ErsatzTV.Infrastructure.Tests --no-build — Passed: 114, Failed: 0, Skipped: 0, Total: 114.
(No ErsatzTV.Application.Tests project exists in this repo.)
BOM check on all 16 touched .cs files — none carry a UTF-8 BOM.
scripts/decisions_validate.py — OK. build_decisions_catalog.py --check — catalog up to date.
Live-E2E (port 8460, measured this session)
PUT /api/v1/channels/1 with graphicsElementIds:[99999] (unknown id) → HTTP 422, detail: "[GraphicsElementIds] Graphics element(s) do not exist: 99999".
PUT /api/v1/channels/1 with graphicsElementIds:[1] (the seeded built-in element) → HTTP 200, echoing graphicsElementIds:[1].
Review
Three implementation runs across nine review rounds total; the final run's Codex cross-family
review ran in all three of its rounds. The last remaining Codex should-fix from that final run —
a pre-existing already-attached-principal race unrelated to this issue's two Done-when items — is
pre-existing on main and deferred to #921 rather than fixed here. The raw id-list count cap
(now shipped, see "Fix" above) was itself deferred once earlier in the process to #917 before
being pulled back in and implemented in this branch's 9fc54fed8 commit.
Cross-family review status: codex — ran in rounds one to three of the final run.
Deferred
#917 — remains open. This PR's MaximumIdListCount cap in IdListValidation.cs bounds the validators it added (graphics elements, watermark, fallback filler, mirror source, and the
deco twins) — one piece of #917's ask — but #917's own scope is repo-wide: it also names the
pre-existing, still-unbounded ApplyUpdateRequest reconcile loop
(O(existing x desired)RemoveAll/foreach) and the other pre-existing FK validators
(FFmpegProfileMustExist, WatermarkMustExist, FillerPresetMustExist, schedule-item
equivalents) that this branch did not touch. Left open for that broader sweep and its own
decision record.
#921 — the already-attached-principal race Codex flagged in the final review round is
pre-existing on main (not introduced by this branch) and is out of scope for #568's two
Done-when items; tracked there for a separate fix.
The issue's second Done-when box lists "case-sensitive" among the properties of the filename-only
match to remove. The shipped IsOnNowNext discriminator deliberately keeps the comparison
case-sensitive (ordinal) — see GraphicsElementDefaults.cs remarks and the mutation table
(row 33) that pins it — because the seeded path's casing is fixed by the seeder itself, and an
ordinal comparison is required to keep the SQLite and MySQL providers from disagreeing (SQLite
compares GraphicsElement.Path case-sensitively with no explicit collation, MySQL would use its
server default). What the fix removes is the filename-only and folder-agnostic parts of the
match; case-sensitivity is retained by design, not left unaddressed.
fixes #568
refs #74, #917, #921
## Root cause
`UpdateChannelHandler.Validate` checked name/number/EPG/logo/group but never validated the
incoming `graphicsElementIds`. The reconcile blindly `Add`ed a `ChannelGraphicsElement` for any
submitted id, so `PUT /api/v1/channels/{id}` with a non-existent id hit
`FK_ChannelGraphicsElement_GraphicsElement_GraphicsElementId` -> `DbUpdateException` -> an
unhandled 500 (data-safe, the transaction rolled back, but inconsistent with every other FK field
on the same full-replace DTO).
Separately, `GetAllGraphicsElementsForApiHandler` and `GraphicsElementSeeder` keyed the `builtIn`
discriminator off `Path.GetFileName(e.Path) == GraphicsElementDefaults.OnNowNextFileName` alone —
filename-only, so a user element named exactly `on-now-next.yml` in any of the other graphics
folders (image/motion/subtitle/script) would also report `builtIn:true`.
## Fix, as the code now stands
- **Existence validators**: `ErsatzTV.Application/Validators/IdListValidation.cs` adds a shared
`IdsMustExist` used by `UpdateChannelHandler` (graphicsElementIds, watermarkId,
fallbackFillerId, mirrorSourceChannelId) and `UpdateDecoHandler`'s graphics-element twin —
rejects unknown ids with a 422 whose `detail` names the request field
(`[GraphicsElementIds] Graphics element(s) do not exist: …`).
- **Raw-count cap and its bound**: `IdsMustExist` rejects a submitted list over
`Validators.MaximumIdListCount` (512) before `Distinct` or any DB work — the count is taken from
the raw list because the request pays for parsing/materializing it regardless of how many ids
are actually distinct or consumed. `MaximumReportedMissingIds` (10) bounds how many rejected ids
the 422 body echoes back, so an oversized request can't produce an oversized response.
- **Lost-race recovery path**: `UpdateChannelHandler.ApplyUpdateRequestTranslatingLostRace` catches
`DbUpdateException` from the save (a concurrent delete of a graphics element/watermark/etc.
between validation and write can still hit the FK) and re-runs the *whole* validator set — not
just the graphics-element half — against a fresh context, translating a real FK loss back into
the same 422 the validator would have returned. Re-running the full set (rather than naming
fields one by one) is what keeps this path from silently missing the next FK the DTO gains.
`UpdateDecoHandler` carries the same twin recovery path for its own FK fields.
- **`builtIn` discriminator**: `GraphicsElementDefaults.IsOnNowNext(path, kind)` is now the one
identity predicate — full seeded path (`OnNowNextSeededPath`, folder-qualified) compared
ordinally, AND `Kind == Text` — used in memory by both `GetAllGraphicsElementsForApiHandler` and
`GraphicsElementSeeder.SeedOnNowNext`'s own "does the built-in row already exist" check, so the
two call sites can no longer disagree about the same row. Comparison stays in memory rather than
a SQL `Where` because `GraphicsElement.Path` takes no explicit collation, so SQLite compares
case-sensitively while MySQL would use the server default (normally case-insensitive) — pushing
the comparison into SQL would let the two providers disagree.
- **Deco twins**: `UpdateDecoHandler` mirrors the channel handler's validation, count cap and
lost-race recovery for its own graphics-element ids, with one difference: a deco's `DecoMode`
can make the apply path discard the graphics-element list entirely, so the existence check is
gated off in that case (`idsAreConsumed: false`) while the count cap still applies unconditionally
— the list is still parsed and materialized out of the request body whatever the apply path does
with it afterwards.
## Gate (measured this session)
- `dotnet build ErsatzTV.sln -c Debug` — 0 errors, 0 warnings.
- `dotnet test ErsatzTV.Tests --no-build` — Passed: 2137, Failed: 0, Skipped: 6, Total: 2143.
- `dotnet test ErsatzTV.Core.Tests --no-build` — Passed: 716, Failed: 0, Skipped: 1, Total: 717.
- `dotnet test ErsatzTV.Infrastructure.Tests --no-build` — Passed: 114, Failed: 0, Skipped: 0, Total: 114.
(No `ErsatzTV.Application.Tests` project exists in this repo.)
- BOM check on all 16 touched `.cs` files — none carry a UTF-8 BOM.
- `dotnet format whitespace . --folder --verify-no-changes --include <touched files>` — clean.
- `scripts/check-doc-narrative.py --diff origin/main` — 0 advisory warnings (non-blocking check).
- `scripts/decisions_validate.py` — OK. `build_decisions_catalog.py --check` — catalog up to date.
## Live-E2E (port 8460, measured this session)
- `PUT /api/v1/channels/1` with `graphicsElementIds:[99999]` (unknown id) →
**HTTP 422**, `detail: "[GraphicsElementIds] Graphics element(s) do not exist: 99999"`.
- `PUT /api/v1/channels/1` with `graphicsElementIds:[1]` (the seeded built-in element) →
**HTTP 200**, echoing `graphicsElementIds:[1]`.
## Review
Three implementation runs across nine review rounds total; the final run's Codex cross-family
review ran in all three of its rounds. The last remaining Codex should-fix from that final run —
a pre-existing already-attached-principal race unrelated to this issue's two Done-when items — is
pre-existing on `main` and deferred to #921 rather than fixed here. The raw id-list count cap
(now shipped, see "Fix" above) was itself deferred once earlier in the process to #917 before
being pulled back in and implemented in this branch's `9fc54fed8` commit.
Cross-family review status: codex — ran in rounds one to three of the final run.
## Deferred
- **#917** — remains open. This PR's `MaximumIdListCount` cap in `IdListValidation.cs` bounds the
*validators* it added (graphics elements, watermark, fallback filler, mirror source, and the
deco twins) — one piece of #917's ask — but #917's own scope is repo-wide: it also names the
pre-existing, still-unbounded `ApplyUpdateRequest` reconcile loop
(`O(existing x desired)` `RemoveAll`/`foreach`) and the other pre-existing FK validators
(`FFmpegProfileMustExist`, `WatermarkMustExist`, `FillerPresetMustExist`, schedule-item
equivalents) that this branch did not touch. Left open for that broader sweep and its own
decision record.
- **#921** — the already-attached-principal race Codex flagged in the final review round is
pre-existing on `main` (not introduced by this branch) and is out of scope for #568's two
Done-when items; tracked there for a separate fix.
- The issue's second Done-when box lists "case-sensitive" among the properties of the filename-only
match to remove. The shipped `IsOnNowNext` discriminator deliberately keeps the comparison
case-sensitive (ordinal) — see `GraphicsElementDefaults.cs` remarks and the mutation table
(row 33) that pins it — because the seeded path's casing is fixed by the seeder itself, and an
ordinal comparison is required to keep the SQLite and MySQL providers from disagreeing (SQLite
compares `GraphicsElement.Path` case-sensitively with no explicit collation, MySQL would use its
server default). What the fix removes is the filename-only and folder-agnostic parts of the
match; case-sensitivity is retained by design, not left unaddressed.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
UpdateChannelHandler.Validate never checked incoming graphicsElementIds against
GraphicsElements, so PUT /api/v1/channels/{id} with a non-existent id hit
FK_ChannelGraphicsElement_GraphicsElement_GraphicsElementId at SaveChangesAsync
and surfaced as an unhandled 500. Add GraphicsElementIdsMustExist, following the
existing FFmpegProfileMustExist/WatermarkMustExist/FillerPresetMustExist shape,
so an unknown id now returns 422 for parity with every other FK field on this
full-replace DTO.
GetAllGraphicsElementsForApiHandler and GraphicsElementSeeder.GetBuiltInElementId
keyed builtIn off Path.GetFileName(e.Path) == OnNowNextFileName -- folder-agnostic,
so a user element named exactly on-now-next.yml in any other template folder would
also report builtIn:true. Both now compare against
GraphicsElementDefaults.OnNowNextSeededPath, the full path the seeder actually
writes to.
Follow-up from the #74 whole-branch review (2026-07-22), deferred as
data-safe/not SPA-reachable.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
Review round on #568 found the branch changed builtIn identity from a bare
filename comparison to the full seeded path (GraphicsElementDefaults.
OnNowNextSeededPath) but left several places still asserting the old rule:
- docs/decisions/records/graphics/channel-level-attachment.md and
on-now-next-on-by-default.md (both status: active) still described a
filename-only match; corrected in place and cross-referenced.
- docs/api-conventions.md §8 quoted the retired
`Path.GetFileName(element.Path) == OnNowNextFileName` expression verbatim;
replaced with the current OnNowNextSeededPath comparison and a note on the
UpdateChannelHandler 422 hardening.
- Three in-code comments (GraphicsElementDefaults.cs, GraphicsElementSeeder.cs,
ChannelGraphicsDefaults.cs) still said "identity is the filename".
- docs/graphics-elements.md's mutation-coverage table (row 10, row 18) named
clauses that no longer exist or no longer redden any test post-#568;
re-measured directly (removing the seeded-path check reddens
Ignores_A_Non_Built_In_Element_With_A_Different_Filename and
Ignores_A_Same_Named_Same_Kind_Element_Outside_The_Seeded_Folder; removing
the Kind==Text filter alone reddens nothing, so it moves to the "known
clauses with no red" list with that measurement dated).
Also closed the should-fix twin: UpdateDecoHandler's graphicsElementIds and
watermarkIds are top-level ReplaceDecoRequest fields in the same position as
UpdateChannelRequest.graphicsElementIds (not the deep-FK-in-a-nested-list
carve-out), and the reconcile in ApplyUpdateRequest blindly Added a join row
for any incoming id -- the identical FK-constraint-to-500 defect #568 fixed
on the channel path. Added GraphicsElementIdsMustExist/WatermarkIdsMustExist
validators mirroring UpdateChannelHandler's, pinned by
UpdateDecoGraphicsElementsTests (reddens when either validator alone is
removed -- verified).
Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
The branch moved the `builtIn` discriminator from a bare filename to the full
seeded path, but split how the two sites evaluate it: the API handler compares
in memory (ordinal) while GetBuiltInElementId's new `.Where(e => e.Path ==
OnNowNextSeededPath)` compares in SQL. GraphicsElement.Path takes no explicit
collation -- TvContext.OnModelCreating pins one only on the listed name/title
columns -- so SQLite answers that case-sensitively and MySQL uses the server
default, which is normally case-INsensitive. On MySQL the two discriminators
could therefore disagree about the same row: AttachOnNowNextByDefault would
resolve a case-variant user element as the built-in one while the API reported
builtIn:false for it.
Collapse both onto GraphicsElementDefaults.IsOnNowNext, ordinal, applied in
memory. GetBuiltInElementId goes back to loading the Text candidates and
filtering in memory (the shape it had before this branch), keeping only the
`Kind` enum filter in SQL.
The prose claimed more than the code did. "A filename-only comparison is
case-sensitive-by-accident" appeared in four places as a defect the full-path
fix removed; a full-path comparison is exactly as case-sensitive, so the clause
said nothing and implied a fix that had not happened. Case sensitivity is now
deliberate and stated as such -- the built-in element is the exact file the
seeder wrote, at the exact path it wrote it to -- and the reason the comparison
is kept out of SQL is recorded where the predicate lives.
docs/decisions/records/graphics/channel-level-attachment.md said BuiltIn was
"computed by comparing the row's `Path` to GraphicsElementDefaults.
OnNowNextFileName", which was true of neither the pre-#568 rule (filename to
filename) nor the current one; an active record resolved by key now states the
current predicate in its own sentence rather than in a parenthetical.
Two tests pin the ordinal rule against a loosening to OrdinalIgnoreCase, one
per site. Measured: OrdinalIgnoreCase reddens exactly
GetAllGraphicsElementsForApi_Should_Not_Mark_A_Case_Variant_Of_The_Seeded_Path_As_BuiltIn
and Ignores_A_Case_Variant_Of_The_Seeded_Path, 2 failed / 77 passed of the 79
graphics tests. They do NOT pin provider independence -- under SQLite's BINARY
collation an equivalent SQL comparison answers identically, so no test in this
suite can distinguish the two. That is stated at each site rather than left for
a reader to assume the tests cover it.
Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
The mutation-coverage table's claims were measured against an earlier shape of
GetBuiltInElementId and are re-taken here, because the lookup changed twice on
this branch (filename -> seeded path, then SQL -> in-memory IsOnNowNext) and a
claim about which tests a mutation reddens does not survive either move on its
own.
Measured 2026-09-05, each mutation applied alone to the committed tree:
- Row 10, the seeded-path filter removed: 3 red, not the 2 the row listed.
Ignores_A_Case_Variant_Of_The_Seeded_Path joins the two already named,
because without the filter every Text row resolves as the built-in one.
- Row 24 is new: IsOnNowNext loosened from Ordinal to OrdinalIgnoreCase reddens
exactly the two case-variant tests, 2 failed / 77 passed. One row covers both
discriminator sites because they now share the predicate.
- The Kind==Text filter's "no red" bullet is re-measured across the WHOLE
ErsatzTV.Tests project -- 2121 passed, 6 skipped, 0 failed -- rather than the
11 tests of the one file that names GetBuiltInElementId. ChannelGraphicsDefaults
reaches the lookup from the channel-create handlers as well, so the narrower
population could not have seen a red there. The conclusion is unchanged; what
changes is that it is now measured over the population that could falsify it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
Review found the new UpdateDecoHandler FK validators ran unconditionally while
ApplyUpdateRequest reads either id list ONLY under DecoMode.Override or Merge --
under Inherit/Disable it Clear()s the join and ignores the field. So the branch
turned a previously-succeeding save into a 422 over ids that were about to be
discarded, and the SPA reaches that shape: DecosScreen's toReplaceRequest sends
watermarkIds/graphicsElementIds from the draft whatever the mode selector says,
while the picker itself is disabled off-Override. RefreshGraphicsElementsHandler
deletes rows whose template file is gone (cascading the join away), so a stale
editor draft could be locked out of saving a deco back to Inherit, with a 422
naming an element the disabled UI does not even show.
Measured before the fix on the review's E2E instance: PUT /api/v1/decos/1 with
graphicsElementsMode=Inherit and graphicsElementIds=[999] returned 422
"Graphics element(s) do not exist: 999".
The mode predicate is now named once per collection -- ConsumesWatermarkIds /
ConsumesGraphicsElementIds -- and read by both the apply path and its validator,
rather than the apply path holding one copy and the validator implying another.
A second copy is what let the two disagree in the first place.
Two tests pin the gate, one per collection, each reddening when its guard alone
is removed:
Should_Ignore_An_Unknown_GraphicsElementId_When_The_Mode_Does_Not_Consume_It
Should_Ignore_An_Unknown_WatermarkId_When_The_Mode_Does_Not_Consume_It
Measured 2026-09-05, each guard removed alone from the committed tree: 1 failed
/ 4 passed, and the failure is exactly the test named for that guard. Both
assert the apply-path outcome as well as the accept, so a validator that stopped
rejecting for some other reason would not satisfy them.
Refs #568
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
Two review findings, both about docs the branch already rewrote.
The mutation-coverage table in docs/graphics-elements.md ended up with two rows
numbered 24 -- the new IsOnNowNext row was inserted after 23 without checking
what followed -- while 18 was vacated when the Kind-filter row moved to the
"no red" list. The section's own prose cites rows by number ("the IsOnNowNext
clause (row 10)"), so a duplicate id makes a citation ambiguous. The new row
becomes 33, the next unused number, and the rule that made it 24 in the first
place is now written down: a row number is an identity, not a position, so a new
row takes the next unused number, nothing is renumbered, and a retired clause
leaves its number vacant rather than having it reused under a new meaning. Both
row claims were re-measured and are unchanged; only the id moves.
#568's second half is titled "builtIn discriminator is filename-only,
case-sensitive, folder-agnostic", and the branch removes the first and third
while deliberately keeping case sensitivity -- which reads like two thirds of a
done-when box. It is not: the remedy the same box prescribes, "full seeded
relative path", is exactly as case-sensitive as the filename match it replaces,
so the three adjectives describe one predicate rather than name three separable
demands. Read the other way the box would be unsatisfiable by its own remedy.
The reason case sensitivity is kept -- a case-INsensitive test hands the built-in
identity to a user element differing from the seeded path only in case -- lived
only in GraphicsElementDefaults.cs, where a reader arriving from the issue title
would not find it. It is now in the active record that owns the discriminator.
Refs #568
Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
Its graphics twin seeds an element, attaches it, then saves with Inherit and an
unknown id, so "the join is empty afterwards" distinguishes a cleared attachment
from one that was never there. The watermark half asserted the same emptiness on
a deco that had no watermarks to begin with -- true of the fixture regardless of
what the handler did, which is a fixture that omits the field it means to test.
Seed a ChannelWatermark, attach it under Override, then save with Disable and
watermarkId 777. Re-measured 2026-09-05 with the ConsumesWatermarkIds guard
removed alone from the committed tree: 1 failed / 4 passed, the failure being
Should_Ignore_An_Unknown_WatermarkId_When_The_Mode_Does_Not_Consume_It. Whole
project green with the guard in place: 2123 passed, 6 skipped, 0 failed.
Refs #568
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
EnsureBuiltInElementRow decided whether the built-in row already existed with its
own `AnyAsync(e => e.Path == target)` -- the one discriminator site left comparing
in SQL after 28827a7d3 moved the rest in memory. Two ways it could answer
differently from GetBuiltInElementId, each leaving the built-in element
undiscoverable for the life of the install: string equality in SQL is the
provider's collation to decide, so on MySQL's normally case-insensitive default a
case-variant row satisfied the check and the canonical row was never created; and
it ignored Kind, so a row of another kind at the seeded path suppressed the Text
row the lookup resolves.
Ask GetBuiltInElementId instead, so the existence question and the resolution
question are the same code. The wrong-kind half is observable under SQLite and is
now pinned; the collation half is not (BINARY and an ordinal comparison agree on
every input) and stays held by keeping the comparison out of SQL.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
The seeder now resolves the built-in row through GetBuiltInElementId, which puts
that lookup on a second call path, so every row whose clause the new call can
reach was re-run against this tree: 10 and 33 unchanged, 18 reinstated (the
Kind == Text filter has a red now that a wrong-kind row at the seeded path can
suppress the row the lookup needs), 21 unchanged, 22 gains a third red, and 34
is new (the existence check re-derived as SQL instead of asking the lookup).
Two stale measurements went with it. The "known clauses with no red" bullet for
the Kind filter quoted 2121 passed against a tree that produces 2123, having been
taken before the branch's last two tests existed -- the whole bullet is gone now
that the clause has a red. And the per-fixture-filter trap counted thirteen
multi-test rows with two spanning two fixture classes, true on origin/main and
false here since the branch added rows: fifteen and three, both recounted from
the table, with a note that they are.
Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
The comment asserted an outcome ("also reddens if the Kind filter is dropped")
with nothing tying it to a measurement -- the shape testing.mutation-claims-are-
executed exists to refuse. Both clauses it covers are rows of the mutation table
in docs/graphics-elements.md, measured against this tree; cite them.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
Five test comments asserted "reddens if <validator> alone is removed" with
nothing binding the sentence to a measurement -- the shape
testing.mutation-claims-are-executed refuses, and the shape whose CLAIMS half of
the manifest cannot reach a .NET proof. The repo's record for those is the
mutation table, so each claim got a row: all five mutated in turn against this
tree with the whole ErsatzTV.Tests project re-run (the tuple-arity fix included,
since a mutation that does not compile is not a result).
35 GraphicsElementIdsMustExist out of UpdateChannelHandler.Validate -> 2 red;
36/37 the deco graphics/watermark validators out of UpdateDecoHandler.Validate
-> 1 red each; 38/39 the two Consumes* mode gates -> 1 red each. Sixteen rows now
redden more than one test; three still span two fixture classes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
`Ignores_A_Same_Named_Element_Of_A_Different_Kind` seeded an Image row at
`/templates/image/on-now-next.yml` and asserted the backfill ignores it. Under the
bare-filename lookup that row was rejected by the `Kind == Text` filter alone, which is
what its comment described. Under the full-path predicate the path rejects it first, so
neither clause is load-bearing for it: dropping the `Kind` filter reddens only
`A_Row_Of_Another_Kind_At_The_Seeded_Path_Does_Not_Suppress_The_Built_In_Row` (row 18) and
dropping `IsOnNowNext` reddens only the three tests of row 10. The test survived both and
its comment claimed a mechanism it no longer exercised. Its scenario is the conjunction of
two already-pinned negatives and is strictly weaker than
`Ignores_A_Same_Named_Same_Kind_Element_Outside_The_Seeded_Folder`, so it is retired rather
than reshaped, and graphics-elements.md now says why the combination is deliberately not
shipped — otherwise the next reader re-adds it.
Row 40 records the API-side half of the discriminator, which had a measured red and no row.
Measured whole-project on this tree, `dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj`:
baseline `Failed: 0, Passed: 2123, Skipped: 6, Total: 2129`; with
`BuiltIn = GraphicsElementDefaults.IsOnNowNext(e.Path)` reverted to
`Path.GetFileName(e.Path) == GraphicsElementDefaults.OnNowNextFileName`,
`Failed: 1, Passed: 2122`, the sole red being
`GetAllGraphicsElementsForApi_Should_Not_Mark_Same_Filename_Outside_Seeded_Folder_As_BuiltIn`.
The table's two self-counts were recounted from the table after adding the row and both
still hold: sixteen rows redden more than one test, three of those span two fixture classes
(13, 22, 33).
Refs #568
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
The §8 Channel-graphics aside cited the deep-FK-in-a-nested-list exception as "below";
that exception is §3b line 314 and the aside is line ~944, so the pointer sent the reader
the wrong way. It now names the section (§3b above) rather than a direction alone, so a
later reflow cannot invert it again. Re-wrapped the same passage so `deep-FK-in-a-nested-list`
no longer straddles a soft line break — Markdown joins those with a space and the term
rendered with a stray gap mid-word.
Refs #568
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
The sentence explaining why the wrong-kind-and-wrong-folder case is not shipped said
"no single-clause mutation can let it through" — an unbounded quantifier over a
population nothing here measures. What is actually established is narrower and is
established: rows 10 and 18 are the two clauses of `GetBuiltInElementId`, each measured,
and dropping either leaves the other rejecting such a row. The claim now says that, and
names those rows as its evidence.
Refs #568
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
Three of the four findings standing on the 2026-09-05 16:24 review verdict, which the
branch had not answered.
The count cap is the blocking one. The three id-list validators took whatever the
request carried, so the only bound on `graphicsElementIds`/`watermarkIds` was the
Kestrel body cap -- a transport limit, not a collection limit. The earlier disposition
deferred it to #917 on the grounds that `ApplyUpdateRequest` reconciles the same list
uncapped anyway; that is true and does not answer the ask, because the reconcile is
downstream of a validator that can refuse the request outright. One shared
`Validators.IdsMustExist` now carries the cap for all three, counted on the RAW list
before `Distinct` (a million copies of one id costs the same to parse and materialize
whatever the distinct count is) and before any database work.
The same helper is where the field name and the diagnostic cap now live. The 422 said
"Graphics element(s) do not exist: 999" without naming which request field carried the
999, and echoed every rejected id -- an oversized request answered with an oversized
response. Both fixed once, in the shared place, so the three sites cannot drift.
`Kind` moves into `GraphicsElementDefaults.IsOnNowNext`. The seeder required
`Kind == Text` and the API's `builtIn` did not, so an Image row at the exact seeded path
was `builtIn:true` on the wire while `GetBuiltInElementId` refused to treat it as the
built-in element -- two sites disagreeing about one row, which is the shape #568 exists
to close. Identity is now one predicate applied whole at both sites; the seeder's SQL
`Kind` filter is gone rather than kept as a duplicate, since a duplicate guard would mask
the predicate's own clause.
Also the fourth finding, the check-then-write race: `RefreshGraphicsElements` can delete a
validated element between `Validate` and `SaveChangesAsync`, handing the join insert the
FK violation the validator exists to prevent. A transaction does not close it -- neither
provider locks rows the validator merely read -- so both handlers catch `DbUpdateException`,
re-ask the existence question on a fresh context, and return the validator's own 422 when
an id has since gone; anything else keeps its own exception. Foreign keys are off in
`InMemoryTvContext`, so the trigger is simulated by an armed save-failure interceptor while
the recovery itself runs against real post-delete state.
Refs #568
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
Nine rows of the mutation table in docs/graphics-elements.md name a clause that this
branch's last commit moved, merged or gave new callers, and a red set is a measurement of
the tree it ships in. All were re-taken against the whole ErsatzTV.Tests project
(2139 tests, 6 skipped) on the code as it now stands, and four new rows added for the
clauses the fix introduced.
What moved and why the numbers changed:
- Row 10 was the seeded-path filter alone. `Kind` now lives inside `IsOnNowNext`, so
dropping the lookup's `Where` drops both halves at once and reddens four tests, not
three.
- Row 18 was the seeder's SQL `Kind == Text` filter and is now the `kind` conjunct of the
shared predicate, so it reddens the API site too — a second cross-fixture row.
- Rows 35-37 pick up the count-cap tests, since the cap rides in the validator they
disarm. Rows 33, 34, 38, 39 re-measured unchanged.
- Row 40's mutation text follows the API call's new two-argument shape; it reddens the new
wrong-kind test as well.
- Rows 41-44 are the new clauses: the raw-count cap (one clause, three call sites, which
is what its red set shows), the diagnostic-id truncation, and the two lost-race catches.
The two self-counted figures above the table were recounted from the table itself rather
than adjusted: twenty-one multi-test rows and five cross-fixture ones (13, 18, 22, 33, 41).
The deco lost-race test is renamed so no two rows cite the same test name.
api-conventions.md gains the three rules the fix establishes for any write path with a
top-level FK id list — bound the raw list, name the field, translate a lost check-then-write
race — in the handler-hardening checklist where they belong rather than as a #568 anecdote.
Refs #568
Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
Two holes the review round found in the previous fix, both of the same shape: a
guard that names its own fields instead of deriving them.
The deco validators short-circuited the entire Validators.IdsMustExist call when
the DecoMode does not consume the ids, which took the 512-item raw-count cap with
it -- an arbitrarily large array under Inherit/Disable parsed and materialized
with nothing bounding it. Only the EXISTENCE half is the apply path's business,
so the mode predicate is now a required argument of the shared validator and gates
that half alone; the cap runs under every mode.
The channel recovery path rechecked GraphicsElementIdsMustExist alone, so a
watermark deleted between validation and SaveChangesAsync still surfaced as the
unhandled 500 the fix exists to remove -- WatermarkId, FFmpegProfileId,
FallbackFillerId and MirrorSourceChannelId are all written by the same save and
lose the same race. Both handlers now re-ask the whole of Validate on
DbUpdateException, so a validator added later is covered without editing the
recovery path.
The API-site outside-folder discriminator test seeded an Image row, so the Kind
conjunct rejected it whatever the path comparison did: a composite revert to
Path.GetFileName(path) == filename && kind == Text passed every API test. It now
carries the seeded Kind, mirroring the seeder-site twin, so only the path half can
reject it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
api-conventions.md now says which half of an id-list validator a sibling field may
gate (existence, never the raw-count cap) and that a lost-race recovery re-asks the
whole validator set rather than the fields whoever wrote the catch remembered.
Those, with the bound and the field-named 422, are one convention with residuals, so
they get a record -- api.top-level-id-list-validation -- and a task-signal row.
The record states what #568 does NOT settle: three validators on two DTOs is a
per-field constant, not the repo-wide rule #917 owns, and it says to expect #917 to
replace the mechanism.
graphics-elements.md: rows 35-44 re-measured against the whole ErsatzTV.Tests
project on this tree, because the fix moved five of their red sets -- Validate is
now also what the recovery path re-runs, so removing a validator from it reddens
that handler's race test too. Rows 45-47 are new and measured the same way. The
"redden more than one test" figure is recounted from the table (21 -> 24); the
cross-fixture set is unchanged at five.
The negative discriminator rows now carry a stated seeding rule: vary one half of
the identity and hold the other at the seeded value. Varying both leaves the row
rejected by the pre-#568 predicate as well, so a composite revert to it would pass
every test at that site.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
Orchestrated-session workflow: nine cold review rounds across three runs (correctness Opus high in its own worktree, conformance sonnet high); Codex cross-family review ran in every round of the final run — its last finding, a pre-existing already-attached-principal race, is deferred to #921 by the orchestrator, and the count cap to #917, both stated in the PR body and the closing record. Gate on the pushed head: ErsatzTV.Tests 2137, Core.Tests 716, Infrastructure.Tests 114 passed, 0 failed; live PUT with an unknown graphicsElementId 422 naming the field, valid PUT 200. The builtIn discriminator stays case-sensitive by design (ordinal, mutation row 33). Verdict on the pushed head.
Review-verdict: MERGEABLE @ fcdc381
Orchestrated-session workflow: nine cold review rounds across three runs (correctness Opus high in its own worktree, conformance sonnet high); Codex cross-family review ran in every round of the final run — its last finding, a pre-existing already-attached-principal race, is deferred to #921 by the orchestrator, and the count cap to #917, both stated in the PR body and the closing record. Gate on the pushed head: ErsatzTV.Tests 2137, Core.Tests 716, Infrastructure.Tests 114 passed, 0 failed; live PUT with an unknown graphicsElementId 422 naming the field, valid PUT 200. The builtIn discriminator stays case-sensitive by design (ordinal, mutation row 33). Verdict on the pushed head.
timothy
merged commit b8ea62bfa0 into main2026-09-05 22:52:19 +02:00
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.
fixes #568
refs #74, #917, #921
Root cause
UpdateChannelHandler.Validatechecked name/number/EPG/logo/group but never validated theincoming
graphicsElementIds. The reconcile blindlyAdded aChannelGraphicsElementfor anysubmitted id, so
PUT /api/v1/channels/{id}with a non-existent id hitFK_ChannelGraphicsElement_GraphicsElement_GraphicsElementId->DbUpdateException-> anunhandled 500 (data-safe, the transaction rolled back, but inconsistent with every other FK field
on the same full-replace DTO).
Separately,
GetAllGraphicsElementsForApiHandlerandGraphicsElementSeederkeyed thebuiltIndiscriminator off
Path.GetFileName(e.Path) == GraphicsElementDefaults.OnNowNextFileNamealone —filename-only, so a user element named exactly
on-now-next.ymlin any of the other graphicsfolders (image/motion/subtitle/script) would also report
builtIn:true.Fix, as the code now stands
ErsatzTV.Application/Validators/IdListValidation.csadds a sharedIdsMustExistused byUpdateChannelHandler(graphicsElementIds, watermarkId,fallbackFillerId, mirrorSourceChannelId) and
UpdateDecoHandler's graphics-element twin —rejects unknown ids with a 422 whose
detailnames the request field(
[GraphicsElementIds] Graphics element(s) do not exist: …).IdsMustExistrejects a submitted list overValidators.MaximumIdListCount(512) beforeDistinctor any DB work — the count is taken fromthe raw list because the request pays for parsing/materializing it regardless of how many ids
are actually distinct or consumed.
MaximumReportedMissingIds(10) bounds how many rejected idsthe 422 body echoes back, so an oversized request can't produce an oversized response.
UpdateChannelHandler.ApplyUpdateRequestTranslatingLostRacecatchesDbUpdateExceptionfrom the save (a concurrent delete of a graphics element/watermark/etc.between validation and write can still hit the FK) and re-runs the whole validator set — not
just the graphics-element half — against a fresh context, translating a real FK loss back into
the same 422 the validator would have returned. Re-running the full set (rather than naming
fields one by one) is what keeps this path from silently missing the next FK the DTO gains.
UpdateDecoHandlercarries the same twin recovery path for its own FK fields.builtIndiscriminator:GraphicsElementDefaults.IsOnNowNext(path, kind)is now the oneidentity predicate — full seeded path (
OnNowNextSeededPath, folder-qualified) comparedordinally, AND
Kind == Text— used in memory by bothGetAllGraphicsElementsForApiHandlerandGraphicsElementSeeder.SeedOnNowNext's own "does the built-in row already exist" check, so thetwo call sites can no longer disagree about the same row. Comparison stays in memory rather than
a SQL
WherebecauseGraphicsElement.Pathtakes no explicit collation, so SQLite comparescase-sensitively while MySQL would use the server default (normally case-insensitive) — pushing
the comparison into SQL would let the two providers disagree.
UpdateDecoHandlermirrors the channel handler's validation, count cap andlost-race recovery for its own graphics-element ids, with one difference: a deco's
DecoModecan make the apply path discard the graphics-element list entirely, so the existence check is
gated off in that case (
idsAreConsumed: false) while the count cap still applies unconditionally— the list is still parsed and materialized out of the request body whatever the apply path does
with it afterwards.
Gate (measured this session)
dotnet build ErsatzTV.sln -c Debug— 0 errors, 0 warnings.dotnet test ErsatzTV.Tests --no-build— Passed: 2137, Failed: 0, Skipped: 6, Total: 2143.dotnet test ErsatzTV.Core.Tests --no-build— Passed: 716, Failed: 0, Skipped: 1, Total: 717.dotnet test ErsatzTV.Infrastructure.Tests --no-build— Passed: 114, Failed: 0, Skipped: 0, Total: 114.(No
ErsatzTV.Application.Testsproject exists in this repo.).csfiles — none carry a UTF-8 BOM.dotnet format whitespace . --folder --verify-no-changes --include <touched files>— clean.scripts/check-doc-narrative.py --diff origin/main— 0 advisory warnings (non-blocking check).scripts/decisions_validate.py— OK.build_decisions_catalog.py --check— catalog up to date.Live-E2E (port 8460, measured this session)
PUT /api/v1/channels/1withgraphicsElementIds:[99999](unknown id) →HTTP 422,
detail: "[GraphicsElementIds] Graphics element(s) do not exist: 99999".PUT /api/v1/channels/1withgraphicsElementIds:[1](the seeded built-in element) →HTTP 200, echoing
graphicsElementIds:[1].Review
Three implementation runs across nine review rounds total; the final run's Codex cross-family
review ran in all three of its rounds. The last remaining Codex should-fix from that final run —
a pre-existing already-attached-principal race unrelated to this issue's two Done-when items — is
pre-existing on
mainand deferred to #921 rather than fixed here. The raw id-list count cap(now shipped, see "Fix" above) was itself deferred once earlier in the process to #917 before
being pulled back in and implemented in this branch's
9fc54fed8commit.Cross-family review status: codex — ran in rounds one to three of the final run.
Deferred
MaximumIdListCountcap inIdListValidation.csbounds thevalidators it added (graphics elements, watermark, fallback filler, mirror source, and the
deco twins) — one piece of #917's ask — but #917's own scope is repo-wide: it also names the
pre-existing, still-unbounded
ApplyUpdateRequestreconcile loop(
O(existing x desired)RemoveAll/foreach) and the other pre-existing FK validators(
FFmpegProfileMustExist,WatermarkMustExist,FillerPresetMustExist, schedule-itemequivalents) that this branch did not touch. Left open for that broader sweep and its own
decision record.
pre-existing on
main(not introduced by this branch) and is out of scope for #568's twoDone-when items; tracked there for a separate fix.
match to remove. The shipped
IsOnNowNextdiscriminator deliberately keeps the comparisoncase-sensitive (ordinal) — see
GraphicsElementDefaults.csremarks and the mutation table(row 33) that pins it — because the seeded path's casing is fixed by the seeder itself, and an
ordinal comparison is required to keep the SQLite and MySQL providers from disagreeing (SQLite
compares
GraphicsElement.Pathcase-sensitively with no explicit collation, MySQL would use itsserver default). What the fix removes is the filename-only and folder-agnostic parts of the
match; case-sensitivity is retained by design, not left unaddressed.
🤖 Generated with Claude Code
https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
UpdateChannelHandler.Validate never checked incoming graphicsElementIds against GraphicsElements, so PUT /api/v1/channels/{id} with a non-existent id hit FK_ChannelGraphicsElement_GraphicsElement_GraphicsElementId at SaveChangesAsync and surfaced as an unhandled 500. Add GraphicsElementIdsMustExist, following the existing FFmpegProfileMustExist/WatermarkMustExist/FillerPresetMustExist shape, so an unknown id now returns 422 for parity with every other FK field on this full-replace DTO. GetAllGraphicsElementsForApiHandler and GraphicsElementSeeder.GetBuiltInElementId keyed builtIn off Path.GetFileName(e.Path) == OnNowNextFileName -- folder-agnostic, so a user element named exactly on-now-next.yml in any other template folder would also report builtIn:true. Both now compare against GraphicsElementDefaults.OnNowNextSeededPath, the full path the seeder actually writes to. Follow-up from the #74 whole-branch review (2026-07-22), deferred as data-safe/not SPA-reachable. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiVTwo review findings, both about docs the branch already rewrote. The mutation-coverage table in docs/graphics-elements.md ended up with two rows numbered 24 -- the new IsOnNowNext row was inserted after 23 without checking what followed -- while 18 was vacated when the Kind-filter row moved to the "no red" list. The section's own prose cites rows by number ("the IsOnNowNext clause (row 10)"), so a duplicate id makes a citation ambiguous. The new row becomes 33, the next unused number, and the rule that made it 24 in the first place is now written down: a row number is an identity, not a position, so a new row takes the next unused number, nothing is renumbered, and a retired clause leaves its number vacant rather than having it reused under a new meaning. Both row claims were re-measured and are unchanged; only the id moves. #568's second half is titled "builtIn discriminator is filename-only, case-sensitive, folder-agnostic", and the branch removes the first and third while deliberately keeping case sensitivity -- which reads like two thirds of a done-when box. It is not: the remedy the same box prescribes, "full seeded relative path", is exactly as case-sensitive as the filename match it replaces, so the three adjectives describe one predicate rather than name three separable demands. Read the other way the box would be unsatisfiable by its own remedy. The reason case sensitivity is kept -- a case-INsensitive test hands the built-in identity to a user element differing from the seeded path only in case -- lived only in GraphicsElementDefaults.cs, where a reader arriving from the issue title would not find it. It is now in the active record that owns the discriminator. Refs #568 Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiVThe comment asserted an outcome ("also reddens if the Kind filter is dropped") with nothing tying it to a measurement -- the shape testing.mutation-claims-are- executed exists to refuse. Both clauses it covers are rows of the mutation table in docs/graphics-elements.md, measured against this tree; cite them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiVReview-verdict: MERGEABLE @
fcdc381Orchestrated-session workflow: nine cold review rounds across three runs (correctness Opus high in its own worktree, conformance sonnet high); Codex cross-family review ran in every round of the final run — its last finding, a pre-existing already-attached-principal race, is deferred to #921 by the orchestrator, and the count cap to #917, both stated in the PR body and the closing record. Gate on the pushed head: ErsatzTV.Tests 2137, Core.Tests 716, Infrastructure.Tests 114 passed, 0 failed; live PUT with an unknown graphicsElementId 422 naming the field, valid PUT 200. The builtIn discriminator stays case-sensitive by design (ordinal, mutation row 33). Verdict on the pushed head.