docs(568): the rule the branch establishes gets a record, and every row the fix moved is re-measured
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 7s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 13s
PR Gates / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 20s
PR Gates / decisions lifecycle (pull_request) Successful in 18s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 12s
Review verdict / Set review-verdict status (pull_request_target) Successful in 26s
review-verdict/h10 Review-verdict: MERGEABLE @ fcdc381 (base: main)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m23s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m23s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 17m33s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m7s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s

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
This commit is contained in:
2026-09-05 21:35:15 +02:00
co-authored by Claude Fable 5.1
parent 9fc54fed87
commit fcdc381246
5 changed files with 88 additions and 19 deletions
+1
View File
@@ -30,6 +30,7 @@ doc below, or that changes which sections a task signal points to.**
| CI / release pipeline work | `docs/ci-cd.md` + `docs/decisions/release-ci-governance.md` |
| Proposing a new guard / CI check / regression test convention | `docs/defect-shapes-773.md` §4 (detector menu + the classes where no detector is plausible), then the rules every guard must satisfy: `docs/decisions/records/testing/guard-derives-population-from-source.md`, `…/guard-ships-with-mutation-proof.md` and `…/mutation-claims-are-executed.md` (a `MUTATION` grade carries a DECLARED clause mutation that is re-run every suite — and so does a PROSE claim that some mutation reddens, or does not redden, a named test, wherever it is written: it is a `CLAIMS` entry in the same manifest, bound to its site and verbatim quote, or it is not written) — plus `…/verification-code-needs-its-own-proof.md`, which extends the same obligation BEYOND guards to the harness, wrapper or checker doing the checking, and says where its proof lives when the checker holds no row. Then `…/guard-pins-the-artifact-not-a-shape.md` for the SHAPE of the predicate itself: over an artifact whose GRAMMAR the predicate does not implement, pin the artifact WHOLE by default — matching a shape inside it is the exception and must carry that record's four-part argument; that record's `rule:` is the one place the qualifying grammars are enumerated |
| Adding or bounding a consequential numeric config field (an FFmpeg profile tunable, a pipeline knob) | `docs/api-conventions.md` §3d — reject out of range with a 422 naming the bound and its consequence, never accept-then-rewrite; validate against the constants the renderer reads, keep the render-time clamp for pre-existing rows, and let an UNCHANGED legacy value through on update. Then `api.ffmpeg-profile-numeric-bounds` |
| Adding or changing a write path that takes a top-level LIST of FK ids (`graphicsElementIds`, `watermarkIds`) | `docs/api-conventions.md` §3b — bound the RAW list through `Validators.IdsMustExist` before `Distinct`, reject an unknown id with a 422 that names the request field, gate only the EXISTENCE half on whatever makes the apply path discard the list, and translate a lost race by re-running the handler's whole `Validate` on a fresh context. Then `api.top-level-id-list-validation`; the repo-wide rollout is #917 |
| Testing a surface gated by config / an env var / a credential | `docs/decisions/records/testing/deny-path-at-production-config-value.md` — cover the setting absent, at its production value, and each opt-out, and assert the DENY branch |
| Touching a full-replace write path or a hand-built request object | `docs/decisions/records/testing/full-replace-asserts-field-list.md` — derive the field list from the DTO and assert set equality; reconcile by id where child state exists. In the SPA the same rule is enforced by the type system: `docs/spa-conventions.md` §4b — build the body as `Complete<T>`, annotating BOTH the wrapper parameter and every construction site |
| Writing or editing any doc, or answering a review finding in prose | `docs/decisions/records/docs/no-session-narrative.md` — the doc records the END STATE; the path to it goes in the commit message. Apply the who-benefits test, and read the carve-out before you cut (dated measurements, stated snapshot boundaries and tested-and-rejected results stay) |
+22 -10
View File
@@ -307,7 +307,12 @@ handler's validation when a lookup fails, so the controller-side mapping falls o
`Validators.IdsMustExist` (#568), which counts the **raw** list before `Distinct` and before any
database work — deduplication is not what the request costs to parse and materialize — and caps how
many rejected ids the 422 echoes back, so an oversized request does not earn an oversized response.
The rollout to the other FK-id validators on these DTOs is #917.
The count is the REQUEST's bound, not the apply path's: where another field of the same body (a
deco's `DecoMode`) makes the reconcile discard the list, the mode gates the EXISTENCE half only —
a discarded list was still parsed and materialized out of the body. Pass that predicate to
`IdsMustExist` as `idsAreConsumed` rather than short-circuiting the call, which takes the cap with
it. The rollout to the other FK-id validators on these DTOs is #917; the rule and its residuals are
`api.top-level-id-list-validation`.
- **Name the request field in the validation message**, `[GraphicsElementIds] …`, using the same
`[{GetMemberName(expression)}]` prefix `Validators.NotEmpty`/`NotLongerThan`/`AtLeast` already
produce. A full-replace DTO carries several id lists, and `Graphics element(s) do not exist: 42`
@@ -316,10 +321,13 @@ handler's validation when a lookup fails, so the controller-side mapping falls o
`SaveChangesAsync`** — a background job deleting the principal row (e.g. `RefreshGraphicsElements`
removing elements whose template file is gone) restores the very 500 the validator was added to
prevent. A transaction does not close that window: neither provider locks the rows the validator
merely READ. Catch `DbUpdateException` around the save, re-ask the existence question on a **fresh**
context (the failed one still tracks the changes it could not write), and return the validator's own
422 if an id has since gone — re-throwing anything else, so a real fault is not reported as a client
error (#568).
merely READ. Catch `DbUpdateException` around the save, re-ask the question on a **fresh** context
(the failed one still tracks the changes it could not write), and return the validator's own 422 if
an id has since gone — re-throwing anything else, so a real fault is not reported as a client error
(#568). **Re-run the handler's whole `Validate`, never a named subset of its fields**: every FK the
save writes loses the same race, and a recovery path that enumerates the fields it knows about
silently omits the next one the DTO gains. Re-running the validator set covers a check added later
by construction.
- **Dereferencing a request `string` (e.g. `request.Name.Length`) is a latent 500** — request DTOs
carry no `#nullable` context (§2), so a `string Name` binds `null` from `name: null`/an omitted field
and there is no implicit `[Required]`; a raw `.Length`/`.Trim()` throws `NullReferenceException` → an
@@ -961,13 +969,17 @@ every other field on this full-replace DTO. The identical shape existed on `PUT
(`ReplaceDecoRequest.graphicsElementIds`/`watermarkIds`, also top-level fields, not the
`deep-FK-in-a-nested-list` carve-out of §3b above) and is hardened the same way by
`UpdateDecoHandler.Validate` (`GraphicsElementIdsMustExist`/`WatermarkIdsMustExist`, #568). Each
deco validator is gated on the same `Override`/`Merge` mode predicate that makes the apply path
consume its id list — under `Inherit`/`Disable` the reconcile clears the join and ignores the ids,
so validating them there would 422 a request over ids it was about to discard. The SPA sends both
id lists whatever the mode selector says, so that shape arrives from the real editor.
deco validator passes the same `Override`/`Merge` mode predicate that makes the apply path consume
its id list — under `Inherit`/`Disable` the reconcile clears the join and ignores the ids, so
validating them there would 422 a request over ids it was about to discard. The SPA sends both id
lists whatever the mode selector says, so that shape arrives from the real editor. The predicate is
an argument (`idsAreConsumed`) rather than a short-circuit around the call, because it gates the
existence half alone and the raw-count cap applies under every mode.
All three id lists go through one shared validator, `Validators.IdsMustExist`, which is where the
count cap (`MaximumIdListCount`), the request field named in the message and the cap on echoed ids
are written once rather than three times.
are written once rather than three times. Both handlers translate a lost race the same way, by
re-running their own `Validate` on a fresh context after a `DbUpdateException` — so every FK on the
DTO is covered, not just the id lists this issue was about.
`GraphicsElementResponseModel` exposes a server-derived `builtIn`, computed by
`GetAllGraphicsElementsForApiHandler` as
`GraphicsElementDefaults.IsOnNowNext(element.Path, element.Kind)` — ordinal equality against the full
+1
View File
@@ -35,6 +35,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `api.search-field-values-unicode-fold` | The EF-sourced facet fields (`genre`, `show_genre`, `studio`, `director`, `writer`, `actor`, `tag`, `network`, `collection`, `video_codec`, `album`, and `artist`'s entity half) reach stored values whose prefix carries an uppercase non-ASCII character, on BOTH providers, with no row budget and no accepted loss. The defect was SQLite-only and ONE-SIDED: SQLite's `LOWER()` folds ASCII only (`lower('Édith')` is `'Édith'` unchanged), so the predicate UNDER-matched, which no later stage can repair. MySQL was already correct — its `LOWER()` is Unicode-aware, so `LOWER('Édith')` really is `'édith'` and the existing predicate reaches the row unaided. The fix is a SECOND, ADDITIVE query taken only when `isSqlite && q contains a non-ASCII character`: raw Dapper SQL `SELECT DISTINCT <col> AS Value FROM <table> WHERE [<discriminator> AND] etv_upper(<col>) LIKE @Pattern ESCAPE '\' ORDER BY <col> LIMIT @Limit`, where `etv_upper` is a `SqliteConnection.CreateFunction` scalar implementing `ToUpperInvariant`. Every other case — all-ASCII `q`, and MySQL for all `q` — runs today's EF query BYTE-IDENTICALLY. Keeping selectivity in SQL here is NOT the refuted family from `api.search-field-values-sources`: those four attempts bounded a walk around a predicate that could not be made correct over JSON escape text, whereas this is a correct fold on a plain column in an ordinary `LIMIT`ed query. It narrows that record's "Known limitation inherited, not introduced" clause; everything else it settles still holds. | 2026-07-27 | [link](records/api/search-field-values-unicode-fold.md) |
| `api.search-paging-cap` | Search stays capped at 100 items per media kind; an overflowing kind's "See all" reuses library-browse paging instead of adding new API surface. | 2026-07-11 | [link](records/api/search-paging-cap.md) |
| `api.selection-projection-include-chain` | Every handler that projects an aggregate carrying a tagged-union selection loads it through ONE shared `<Aggregate>QueryExtensions` include chain — `RerunCollectionQueryExtensions.IncludeSelectionDetails()`, joining the existing `ProgramScheduleItemQueryExtensions.IncludeScheduleItemDetails()` — called by the paged-list handler and the by-id handler alike, so the two cannot drift. The media-item flattening switch is likewise ONE shared helper, `MediaCollections.Mapper.ProjectMediaItemToViewModel`, covering all ten selectable media types including `RemoteStream`, whose named projection is `MediaItems.Mapper.ProjectToNamedViewModel` (it cannot be an overload of `ProjectToViewModel(RemoteStream)`, which already exists returning the unrelated `RemoteStreamViewModel`; C# will not overload on return type). That switch NEVER ends in `_ => null`: a null MediaItem is the legitimate not-a-media-item case, while an unrecognized non-null subtype keeps its id and takes a conspicuous `[unsupported media type: X]` name. Fail-soft is deliberate — throwing would fail an entire paged GET over one unreadable row. Finally, every metadata navigation inside `MediaItems.Mapper` is read through `Optional(...).Flatten()` and degrades to the `"???"` placeholder, because those projections are reached from handlers whose include chains differ and a bare `x.Season.Show.ShowMetadata` is a latent 500 on some other caller GET. | 2026-07-28 | [link](records/api/selection-projection-include-chain.md) |
| `api.top-level-id-list-validation` | A list of foreign-key ids that is a TOP-LEVEL field of a full-replace write DTO (`UpdateChannelRequest.graphicsElementIds`, `ReplaceDecoRequest.graphicsElementIds`/`watermarkIds`) is validated like every other FK field on that DTO, through the one shared primitive `Validators.IdsMustExist`. Four properties, in this order: (1) the RAW list length is capped at `Validators.MaximumIdListCount` before `Distinct` and before any database work, because deduplication is not what the request cost to parse and materialize; (2) an id with no matching row is a 422, never the FK constraint reaching `SaveChangesAsync` as an unhandled 500; (3) the message carries the request field name in brackets, `[GraphicsElementIds] ...`, the prefix `Validators.NotEmpty`/`NotLongerThan`/`AtLeast` already produce, and echoes at most ten missing ids plus a count; (4) where a SIBLING field of the same request makes the apply path discard the list — a deco `DecoMode` outside `Override`/`Merge` — that predicate is passed in as `idsAreConsumed` and gates the EXISTENCE half ONLY. It never gates the cap: a discarded list was parsed and materialized out of the request body all the same. Because validation and the write are two statements, a handler that writes any FK also catches `DbUpdateException` around the save and re-runs ITS OWN `Validate` on a FRESH context, returning the validator's own 422 when a principal row has since gone and re-throwing otherwise. The whole validator set is re-asked, never a named subset of fields. This is NOT the deep-FK carve-out of api-conventions.md 3b, which stays: ids nested inside item-list request bodies are still not existence-checked at that depth. | 2026-09-05 | [link](records/api/top-level-id-list-validation.md) |
| `api.versioning-v1` | The entire `/api` surface is versioned to `/api/v1` uniformly (no unversioned corner); legacy unversioned callers are rewritten in-pipeline (not redirected) with Deprecation/Link/Sunset headers, and post-freeze `/api/v1` is additive-only — a breaking change requires `/api/v2`. | 2026-07-13 | [link](records/api/versioning-v1.md) |
| `blazor.rollback-tag` | The commit immediately preceding the Blazor-removal merge is tagged `blazor-final` (not a `v*` tag, so it doesn't trigger a prod release build) as the documented rollback/restore path. | 2026-07-11 | [link](records/blazor/rollback-tag.md) |
| `blazor.ui-removed` | The legacy Blazor Server UI (`Pages/`, `Shared/`, `ViewModels/`, `Validators/`, MudBlazor + 8 other packages, Blazor Startup wiring) is fully deleted now that the SPA has parity; the legacy `MapWhen` branch is kept only for controllers/docs/OpenAPI/`LegacyUiRedirects`, and the catch-all fallback 302s any unmatched non-api/artwork/docs/openapi path to `/app`. | 2026-07-11 | [link](records/blazor/ui-removed.md) |
@@ -0,0 +1,45 @@
---
key: api.top-level-id-list-validation
title: '2026-09-05 — A top-level list of FK ids on a full-replace write DTO is bounded, existence-checked, named in its own 422, and re-asked after a lost race (#568)'
status: active
since: '2026-09-05'
supersedes: none
superseded-by: none
rule: 'A list of foreign-key ids that is a TOP-LEVEL field of a full-replace write DTO (`UpdateChannelRequest.graphicsElementIds`, `ReplaceDecoRequest.graphicsElementIds`/`watermarkIds`) is validated like every other FK field on that DTO, through the one shared primitive `Validators.IdsMustExist`. Four properties, in this order: (1) the RAW list length is capped at `Validators.MaximumIdListCount` before `Distinct` and before any database work, because deduplication is not what the request cost to parse and materialize; (2) an id with no matching row is a 422, never the FK constraint reaching `SaveChangesAsync` as an unhandled 500; (3) the message carries the request field name in brackets, `[GraphicsElementIds] ...`, the prefix `Validators.NotEmpty`/`NotLongerThan`/`AtLeast` already produce, and echoes at most ten missing ids plus a count; (4) where a SIBLING field of the same request makes the apply path discard the list — a deco `DecoMode` outside `Override`/`Merge` — that predicate is passed in as `idsAreConsumed` and gates the EXISTENCE half ONLY. It never gates the cap: a discarded list was parsed and materialized out of the request body all the same. Because validation and the write are two statements, a handler that writes any FK also catches `DbUpdateException` around the save and re-runs ITS OWN `Validate` on a FRESH context, returning the validator''s own 422 when a principal row has since gone and re-throwing otherwise. The whole validator set is re-asked, never a named subset of fields. This is NOT the deep-FK carve-out of api-conventions.md 3b, which stays: ids nested inside item-list request bodies are still not existence-checked at that depth.'
signals: 'unknown graphicsElementIds returns 500 · unbounded client-supplied id array · which id list does this 422 mean · FK deleted between validation and SaveChangesAsync · deco mode gate skips the count cap · IdsMustExist · MaximumIdListCount · idsAreConsumed · ApplyUpdateRequestTranslatingLostRace · paths: `ErsatzTV.Application/Validators/IdListValidation.cs`, `ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs`, `ErsatzTV.Application/Scheduling/Commands/UpdateDecoHandler.cs`, `docs/api-conventions.md` 3b · issues: #568, #917, #74'
mechanics: '`Validators.IdsMustExist<T>(input, expression, noun, idsAreConsumed, findExisting, ct)` in `ErsatzTV.Application/Validators/IdListValidation.cs`; the field name comes from `GetMemberName(expression)`, so it cannot drift from the DTO. Pinned by `UpdateChannelGraphicsElementsTests` and `UpdateDecoGraphicsElementsTests`; the clause mutations and their measured red sets are rows 35-47 of the table in `docs/graphics-elements.md`.'
---
- **The cap belongs to the request, the existence check belongs to the apply path.** These are two
different questions and the first attempt at the mode gate conflated them: the deco validators
short-circuited the whole `IdsMustExist` call when `DecoMode` was `Inherit`/`Disable`, which was
right about existence and wrong about the count. A validator may not reject an id the reconcile
was going to throw away — the SPA sends both id lists whatever the mode selector says, so a draft
holding a since-deleted element must still be able to save the deco back to `Inherit` — but the
array was still bound, allocated and materialized before any of that was known. Hence
`idsAreConsumed` as a parameter of the shared primitive rather than an `if` around the call site:
the primitive decides what the flag may switch off, and no call site can widen it.
- **A recovery path that names its own fields is the defect it is recovering from.** Re-asking only
the graphics-element question after a `DbUpdateException` left `WatermarkId`, `FFmpegProfileId`,
`FallbackFillerId` and `MirrorSourceChannelId` — every one of them written by the same
`SaveChangesAsync`, every one of them a real FK — surfacing the same unhandled 500 the catch exists
to remove. Re-running the handler's own `Validate` is what makes the coverage derive from the
validator set instead of from whoever last edited the catch. A transaction is not an alternative:
neither provider locks rows the validator merely READ, so the concurrent delete commits either way.
The context must be a fresh one — the failed context still tracks the changes it could not write,
and would answer the existence query out of them.
- **Bounded, but only here — and deliberately.** Three validators on two DTOs carry this cap; the
other FK validators on the same DTOs, and the `O(existing x desired)` reconcile loops the ids then
feed, do not. That is not an oversight and it is not a finished rule: capping three sites and not
the rest is a per-field constant, and #917 owns the repo-wide question (where the bound lives, how
the limit is derived, and bounding the apply paths as well as the validators). Cite this record for
the SHAPE a bounded id list takes; expect #917 to replace the mechanism.
- **`MaximumIdListCount` is a ceiling on abuse, not a product limit.** 512, against tables an
operator curates by hand where a few dozen rows is a large install. Nothing reachable from the
editor can approach it, which is what lets it be a hard rejection rather than a clamp.
- **A 422 that echoes every rejected id turns an oversized request into an oversized response.** Ten
ids and a count: enough to fix the payload by hand, bounded by construction.
+19 -9
View File
@@ -148,6 +148,13 @@ test asserting such a row is ignored therefore cannot appear in row 18's red set
cannot fail, and the combination is deliberately not shipped. Row 10 drops the identity call
entirely, which is a different mutation from dropping either half.
The same arithmetic constrains how each negative case is SEEDED, at both discriminator sites: a
row varies exactly one half of the identity and holds the other at the seeded value. The
outside-folder rows carry `Kind == Text`, the wrong-kind rows sit at the seeded path. A negative row
that varies both is refused by the surviving half of the *pre-#568* predicate too — filename plus
`Kind == Text` — so a composite revert to it would pass every discriminator test at that site while
restoring the folder-agnostic bug the issue reports (#568).
`IsOnNowNext` is **ordinal**, and every caller applies it **in memory** rather than in a `Where`
clause. That is not incidental: `GraphicsElement.Path` takes no explicit collation (`TvContext`
pins one only on the listed name/title columns), so a SQL `Path ==` comparison is case-sensitive
@@ -179,7 +186,7 @@ Three traps this table is built to avoid:
- **Measure against the whole project, never a per-fixture filter.** A filtered run structurally
cannot observe a red in another fixture, so it under-reports the failure set while looking precise.
Twenty-one rows below redden more than one test, and five of them (13, 18, 22, 33, 41) span two
Twenty-four rows below redden more than one test, and five of them (13, 18, 22, 33, 41) span two
fixture classes — those five are exactly what a per-fixture filter cannot see. Both figures are
counted from the table itself, so a row added without recounting them makes this bullet quietly
false — recount both whenever a row is added or its red set changes.
@@ -261,13 +268,16 @@ red.
| 32 | the lineup create path stops attaching the built-in element | `Should_Attach_The_Built_In_On_Now_Next_Element` |
| 33 | `IsOnNowNext` loosened from `Ordinal` to `OrdinalIgnoreCase` (#568) — one row for both discriminator sites, since they share the predicate | `GetAllGraphicsElementsForApi_Should_Not_Mark_A_Case_Variant_Of_The_Seeded_Path_As_BuiltIn`<br>`Ignores_A_Case_Variant_Of_The_Seeded_Path` |
| 34 | `EnsureBuiltInElementRow`'s existence check re-derived as a SQL `AnyAsync(e => e.Path == target)` instead of asking `GetBuiltInElementId` (#568) | `A_Row_Of_Another_Kind_At_The_Seeded_Path_Does_Not_Suppress_The_Built_In_Row` |
| 35 | `GraphicsElementIdsMustExist` removed from `UpdateChannelHandler.Validate` (#568) — the unknown-id 422 the issue asks for, and with it the count cap and the diagnostic cap that validator carries | `Should_Cap_The_Ids_Echoed_Back_In_The_Unknown_Id_422`<br>`Should_Reject_A_Duplicate_Heavy_List_On_Its_Raw_Count`<br>`Should_Reject_One_More_Than_The_Maximum_Number_Of_GraphicsElementIds`<br>`Should_Reject_Unknown_GraphicsElementId_With_422_Not_500`<br>`Should_Reject_When_One_Of_Several_GraphicsElementIds_Is_Unknown` |
| 36 | `GraphicsElementIdsMustExist` removed from `UpdateDecoHandler.Validate` (#568) | `Should_Reject_More_Than_The_Maximum_Number_Of_GraphicsElementIds`<br>`Should_Reject_Unknown_GraphicsElementId_With_A_Validation_Error_Not_A_Throw` |
| 37 | `WatermarkIdsMustExist` removed from `UpdateDecoHandler.Validate` (#568) | `Should_Reject_More_Than_The_Maximum_Number_Of_WatermarkIds`<br>`Should_Reject_Unknown_WatermarkId_With_A_Validation_Error_Not_A_Throw` |
| 38 | the `ConsumesGraphicsElementIds` mode gate removed from `UpdateDecoHandler.GraphicsElementIdsMustExist` (#568) — a validator may only reject ids the apply path would consume | `Should_Ignore_An_Unknown_GraphicsElementId_When_The_Mode_Does_Not_Consume_It` |
| 39 | the `ConsumesWatermarkIds` mode gate removed from `UpdateDecoHandler.WatermarkIdsMustExist` (#568) | `Should_Ignore_An_Unknown_WatermarkId_When_The_Mode_Does_Not_Consume_It` |
| 35 | `GraphicsElementIdsMustExist` removed from `UpdateChannelHandler.Validate` (#568) — the unknown-id 422 the issue asks for, and with it the count cap and the diagnostic cap that validator carries, and the lost-race recheck, which re-runs `Validate` whole | `Should_Cap_The_Ids_Echoed_Back_In_The_Unknown_Id_422`<br>`Should_Reject_A_Duplicate_Heavy_List_On_Its_Raw_Count`<br>`Should_Reject_One_More_Than_The_Maximum_Number_Of_GraphicsElementIds`<br>`Should_Reject_Unknown_GraphicsElementId_With_422_Not_500`<br>`Should_Reject_When_One_Of_Several_GraphicsElementIds_Is_Unknown`<br>`Should_Translate_An_Element_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
| 36 | `GraphicsElementIdsMustExist` removed from `UpdateDecoHandler.Validate` (#568) — and so from the lost-race recheck too, which re-runs `Validate` whole | `Should_Reject_More_Than_The_Maximum_Number_Of_GraphicsElementIds`<br>`Should_Reject_Too_Many_GraphicsElementIds_Even_Under_A_Mode_That_Does_Not_Consume_Them`<br>`Should_Reject_Unknown_GraphicsElementId_With_A_Validation_Error_Not_A_Throw`<br>`Should_Translate_A_Deco_Element_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
| 37 | `WatermarkIdsMustExist` removed from `UpdateDecoHandler.Validate` (#568) — and so from the lost-race recheck too | `Should_Reject_More_Than_The_Maximum_Number_Of_WatermarkIds`<br>`Should_Reject_Too_Many_WatermarkIds_Even_Under_A_Mode_That_Does_Not_Consume_Them`<br>`Should_Reject_Unknown_WatermarkId_With_A_Validation_Error_Not_A_Throw`<br>`Should_Translate_A_Deco_Watermark_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
| 38 | `idsAreConsumed: ConsumesGraphicsElementIds(request)` replaced by `idsAreConsumed: true` at `UpdateDecoHandler.GraphicsElementIdsMustExist`'s call site (#568) — a validator may only reject ids the apply path would consume | `Should_Ignore_An_Unknown_GraphicsElementId_When_The_Mode_Does_Not_Consume_It` |
| 39 | the same replacement at `UpdateDecoHandler.WatermarkIdsMustExist`'s call site (#568) | `Should_Ignore_An_Unknown_WatermarkId_When_The_Mode_Does_Not_Consume_It` |
| 40 | `GetAllGraphicsElementsForApiHandler`'s `BuiltIn` reverted from `GraphicsElementDefaults.IsOnNowNext(e.Path, e.Kind)` to `Path.GetFileName(e.Path) == GraphicsElementDefaults.OnNowNextFileName` (#568) — the API-side call, the folder-agnostic and kind-agnostic shape the issue reports | `GetAllGraphicsElementsForApi_Should_Not_Mark_A_Wrong_Kind_Row_At_The_Seeded_Path_As_BuiltIn`<br>`GetAllGraphicsElementsForApi_Should_Not_Mark_Same_Filename_Outside_Seeded_Folder_As_BuiltIn` |
| 41 | the raw-count cap removed from `Validators.IdsMustExist` (#568) — one clause, three call sites, so its red set is what shows the cap is shared rather than restated | `Should_Reject_A_Duplicate_Heavy_List_On_Its_Raw_Count`<br>`Should_Reject_More_Than_The_Maximum_Number_Of_GraphicsElementIds`<br>`Should_Reject_More_Than_The_Maximum_Number_Of_WatermarkIds`<br>`Should_Reject_One_More_Than_The_Maximum_Number_Of_GraphicsElementIds` |
| 41 | the raw-count cap removed from `Validators.IdsMustExist` (#568) — one clause, three call sites, so its red set is what shows the cap is shared rather than restated | `Should_Reject_A_Duplicate_Heavy_List_On_Its_Raw_Count`<br>`Should_Reject_More_Than_The_Maximum_Number_Of_GraphicsElementIds`<br>`Should_Reject_More_Than_The_Maximum_Number_Of_WatermarkIds`<br>`Should_Reject_One_More_Than_The_Maximum_Number_Of_GraphicsElementIds`<br>`Should_Reject_Too_Many_GraphicsElementIds_Even_Under_A_Mode_That_Does_Not_Consume_Them`<br>`Should_Reject_Too_Many_WatermarkIds_Even_Under_A_Mode_That_Does_Not_Consume_Them` |
| 42 | `Validators.DescribeIds` stops truncating and joins every missing id (#568) | `Should_Cap_The_Ids_Echoed_Back_In_The_Unknown_Id_422` |
| 43 | the `DbUpdateException` catch disarmed in `UpdateChannelHandler.ApplyUpdateRequestTranslatingLostRace` (#568) — retyped to an exception the save cannot raise, so the FK failure propagates as it did before the fix | `Should_Translate_An_Element_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
| 44 | the same catch disarmed in `UpdateDecoHandler.ApplyUpdateRequestTranslatingLostRace` (#568) | `Should_Translate_A_Deco_Element_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
| 43 | the `DbUpdateException` catch disarmed in `UpdateChannelHandler.ApplyUpdateRequestTranslatingLostRace` (#568) — retyped to an exception the save cannot raise, so the FK failure propagates as it did before the fix | `Should_Translate_A_Watermark_Deleted_Between_Validation_And_Save_Into_The_Same_422`<br>`Should_Translate_An_Element_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
| 44 | the same catch disarmed in `UpdateDecoHandler.ApplyUpdateRequestTranslatingLostRace` (#568) | `Should_Translate_A_Deco_Element_Deleted_Between_Validation_And_Save_Into_The_Same_422`<br>`Should_Translate_A_Deco_Watermark_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
| 45 | the lost-race recheck in `UpdateChannelHandler.ApplyUpdateRequestTranslatingLostRace` narrowed from `Validate` back to `GraphicsElementIdsMustExist` alone (#568) — the recovery enumerating its own fields instead of re-asking the validator set, which omits every other FK the same save writes | `Should_Translate_A_Watermark_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
| 46 | the same narrowing in `UpdateDecoHandler.ApplyUpdateRequestTranslatingLostRace` (#568) | `Should_Translate_A_Deco_Watermark_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
| 47 | the `idsAreConsumed` gate hoisted ABOVE the raw-count cap in `Validators.IdsMustExist` (#568) — the shape the deco mode gate had while it short-circuited the whole call, which took the cap with it | `Should_Reject_Too_Many_GraphicsElementIds_Even_Under_A_Mode_That_Does_Not_Consume_Them`<br>`Should_Reject_Too_Many_WatermarkIds_Even_Under_A_Mode_That_Does_Not_Consume_Them` |