diff --git a/docs/README.md b/docs/README.md index ab8ee63f5..e6b15be11 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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`, 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) | diff --git a/docs/api-conventions.md b/docs/api-conventions.md index 1118036fb..e49d98959 100644 --- a/docs/api-conventions.md +++ b/docs/api-conventions.md @@ -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 diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 6c52cd5b2..8d8fab068 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -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 AS Value FROM WHERE [ AND] etv_upper() LIKE @Pattern ESCAPE '\' ORDER BY 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 `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) | diff --git a/docs/decisions/records/api/top-level-id-list-validation.md b/docs/decisions/records/api/top-level-id-list-validation.md new file mode 100644 index 000000000..ccb66ab21 --- /dev/null +++ b/docs/decisions/records/api/top-level-id-list-validation.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(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. diff --git a/docs/graphics-elements.md b/docs/graphics-elements.md index a2ec2303b..ff9c07d72 100644 --- a/docs/graphics-elements.md +++ b/docs/graphics-elements.md @@ -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`
`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`
`Should_Reject_A_Duplicate_Heavy_List_On_Its_Raw_Count`
`Should_Reject_One_More_Than_The_Maximum_Number_Of_GraphicsElementIds`
`Should_Reject_Unknown_GraphicsElementId_With_422_Not_500`
`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`
`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`
`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`
`Should_Reject_A_Duplicate_Heavy_List_On_Its_Raw_Count`
`Should_Reject_One_More_Than_The_Maximum_Number_Of_GraphicsElementIds`
`Should_Reject_Unknown_GraphicsElementId_With_422_Not_500`
`Should_Reject_When_One_Of_Several_GraphicsElementIds_Is_Unknown`
`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`
`Should_Reject_Too_Many_GraphicsElementIds_Even_Under_A_Mode_That_Does_Not_Consume_Them`
`Should_Reject_Unknown_GraphicsElementId_With_A_Validation_Error_Not_A_Throw`
`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`
`Should_Reject_Too_Many_WatermarkIds_Even_Under_A_Mode_That_Does_Not_Consume_Them`
`Should_Reject_Unknown_WatermarkId_With_A_Validation_Error_Not_A_Throw`
`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`
`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`
`Should_Reject_More_Than_The_Maximum_Number_Of_GraphicsElementIds`
`Should_Reject_More_Than_The_Maximum_Number_Of_WatermarkIds`
`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`
`Should_Reject_More_Than_The_Maximum_Number_Of_GraphicsElementIds`
`Should_Reject_More_Than_The_Maximum_Number_Of_WatermarkIds`
`Should_Reject_One_More_Than_The_Maximum_Number_Of_GraphicsElementIds`
`Should_Reject_Too_Many_GraphicsElementIds_Even_Under_A_Mode_That_Does_Not_Consume_Them`
`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`
`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`
`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`
`Should_Reject_Too_Many_WatermarkIds_Even_Under_A_Mode_That_Does_Not_Consume_Them` |