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
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
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
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
Clears the still-live findings from #172 (verified against main; #2/#4/#7 and the
auth/search/Trakt tail were already deliberate-documented or fixed since 2026-07-07).
- Null/empty Name → 500 (10 create/replace handlers). Block/Template/DecoTemplate/Deco
Create+Replace/Update + UpdateFFmpegProfile did `request.Name.Length > 50` on a
client-nullable string → unhandled NullReferenceException → HTTP 500 (no global
exception filter). Now `string.IsNullOrWhiteSpace(request.Name) || .Length > 50` →
422; also rejects empty/whitespace names, matching the group-create handlers'
NotEmpty behavior. CreatePlaylist coalesces null→"" at the DTO so it was an
empty-name persist, not a 500; guarded the same way.
- ReplaceTemplateItems overlap validation iterated with an `item == otherItem`
record value-equality skip, so two exact-duplicate items were value-equal and
bypassed the intersection check (both persisted). Now index-based (i != j) so
duplicates register as a self-intersection and are rejected 422.
- Trimmed the unreachable 404 ProducesResponseType from POST /api/blocks/groups and
POST /api/templates/groups (a create has no parent lookup that can 404); v1.json
regenerated.
- Regression tests: all 10 name-guard paths + the duplicate-items path (19 cases).
- Docs: decisions.md entry + api-conventions.md §3b null-safe-validation bullet.
fixes#172
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Independent Codex review of the fix diff surfaced two real findings the fork pass missed:
- Medium: the #251 affected-playout QUERIES in ReplaceDecoTemplateItemsHandler and
UpdateDecoHandler still ran on the request `cancellationToken`, so a cancellation
landing after SaveChanges committed but before those queries executed would throw
before the CancellationToken.None enqueue — the edit committed but no playout Reset,
re-opening the stale-content bug in that window. Run the entire post-commit
invalidation (queries + enqueue) on CancellationToken.None so the side effect can't
be half-aborted once the data has changed.
- Low: UpdateDefaultDecoHandler enqueued a Reset for request.PlayoutId even when
ExecuteUpdateAsync matched 0 rows (nonexistent playout), creating a background build
request for an id that isn't there. Guard the enqueue on rows-updated > 0 so the
enqueued set equals the affected set. Added a regression test.
Also corrected the ReplaceProgramScheduleItemsHandler comments: the schedule-item
hierarchy is TPT (table-per-type), not TPH — the SetValues reconcile is safe either way
(same-runtime-type guard; no discriminator to corrupt), Codex confirmed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#252: ReplaceProgramScheduleItems deleted and re-inserted every item on every
save (even a no-op PUT-back), and PlayoutScheduleItemFillGroupIndex.ProgramScheduleItemId
is OnDelete(Cascade) — so every schedule save silently wiped persisted
fill-group/shuffle enumerator progression for all playouts using the schedule.
Switch to a positional in-place reconcile: for a same-typed slot, copy scalars via
CurrentValues.SetValues (BuildItem stays the single source of item construction, so
no field is dropped) and rebuild the watermark/graphics join rows, keeping the item
id — and with it the fill-group index. Subtype change / surplus falls back to
delete+insert for that slot only. The request DTO carries no stable item id, so
position is the only key here; true content-aware stable identity is deferred to the
shared concurrency/round-trip contract in #253.
#251: deco / deco-template CONTENT edits (and default-deco assignment) only take
effect on a playout Reset build — deco/break/default-filler content is applied during
Reset, a Continue keeps the frozen filler items, and BlockKey change-detection has no
deco dimension to self-heal. The editors enqueued nothing (a commented-out TODO in
ReplaceDecoTemplateItemsHandler), so filler/break content stayed stale indefinitely
until a manual Reset. Enqueue BuildPlayout(Reset) for exactly the affected playouts:
- ReplaceDecoTemplateItemsHandler: playouts via PlayoutTemplate.DecoTemplateId
- UpdateDecoHandler: playouts via Playout.DecoId and via deco-template items
- UpdateDefaultDecoHandler: the reassigned playout (adjacent same-class fix)
Post-commit enqueues use CancellationToken.None (audit #22 policy).
Tests: DecoInvalidationTests + ReplaceProgramScheduleItemsReconcileTests, each proven
non-vacuous against a negative control (inverted the primitive, verified 0 CS errors so
the --no-build run used a fresh dll). Full ErsatzTV.Tests suite green (1067).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
UpdateDecoHandler copied every break-content field except MediaItemId,
so TelevisionShow/Season/Artist break content validated then persisted
with a null MediaItemId (upstream bug surfaced by the new REST API).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix validation in new form layout
* pin mediatr to last oss version
* update dependencies
* cleanup code in core
* cleanup code in ffmpeg
* cleanup code in infra
* cleanup code in scanner
* cleanup code in application
* cleanup main code
* cleanup test code
* solution-wide code cleanup
* add deco groups and decos; set default deco for block playout
* use block playout default deco for watermark
* add deco templates, groups and deco template editor
* associate deco template with playout template
* use deco template item watermark for playback
* update changelog for decos