# API conventions — "Add a REST endpoint" checklist Purpose: a precise, file-path-and-exemplar checklist for adding or changing a `/api/*` endpoint on the ErsatzTV fork, so an agent with no prior context in this repo can do it correctly on the first pass. **Update this doc in the same PR that changes any convention below.** This is a companion to `docs/contributing.md` (general CQRS/LanguageExt/testing conventions) and `docs/ci-cd.md` (build/release pipeline) — read those first for anything not API-specific. ## 1. Controller shape Controllers live in `ErsatzTV/Controllers/Api/*.cs`, one per domain (e.g. `TemplateController.cs`, `BlockController.cs`, `LogsController.cs`). Every action needs this attribute set: - `[ApiController]` on the class. - `[HttpGet/Post/Put/Delete("/api/v1/...")]` on the action, with `Name = "..."` on at least the primary GET (used by the SPA's OpenAPI-generated client and by route-assertion tests). **The route is versioned and absolute** (`/api/v1/...`, leading slash, full path on the method attribute — no class-level `[Route]`). This is enforced: `ApiRouteVersioningTests` (sibling of `ApiControllerSecurityTests`) reflects over every `[ApiController]` action in `Controllers.Api` and fails if an effective route doesn't match `^/api/v\d+/`. The **only** controllers with a class-level `[Route]` are the two whose ~all actions share a parametrized prefix — `ScannerController` (`[Route("/api/v1/scan/{scanId:guid}")]`) and `ScriptedScheduleController` (`[Route("/api/v1/scripted/playout/build/{buildId:guid}")]`) — and there the method segments are relative (`[HttpPost("progress")]`). A browser-nav endpoint deliberately outside `/api` (`AuthController`'s `GET /auth/oidc/login`) is out of scope for the versioning rule. See `docs/decisions.md` 2026-07-13 (#286) for the versioning contract (additive-only after freeze; the legacy `/api/*`→`/api/v1/*` in-pipeline rewrite in `ApiVersionRewriteMiddleware`). - `[Tags("Domain")]` — groups the endpoint in Swagger UI / the SPA's generated client namespace. - `[EndpointSummary("...")]` — one-line description; optionally `[EndpointDescription("...")]` for more detail. - `[EndpointGroupName("general")]` — **REQUIRED**. Endpoints without it are excluded from the generated OpenAPI document entirely (verified: every action in `TemplateController.cs` and `LogsController.cs` has it; there is no other endpoint group in use). - `[ProducesResponseType(typeof(X), StatusCodes.StatusYyy)]` for every status code the action can actually return (200/201/204 success, plus 404/422 as applicable) — this drives the generated TypeScript response types on the SPA side. Exemplars: - **Full CRUD controller**: `ErsatzTV/Controllers/Api/TemplateController.cs` — group CRUD + item CRUD + copy, all four verbs, `ToCreatedResult`/`ToErrorResult` usage. - **Paged GET with clamped params**: `ErsatzTV/Controllers/Api/LogsController.cs` — `pageNum` clamped via `Math.Max(0, pageNum)`, `pageSize` via `Math.Clamp(pageSize, 1, MaxPageSize)` (`MaxPageSize = 100`). Any new paged endpoint should clamp the same way — don't trust client input for page math. **`pageNum` is 0-based** across the whole surface (the first page is `0`) and the offset is always derived from the *clamped* `pageSize`, so an over-large `pageSize` yields narrower pages — it never widens the offset. Say "0-based" in the description of any paging parameter you expose, including on wrapper surfaces like the MCP tool catalog: describing it as 1-based makes a caller skip the first page silently, which reads as data loss rather than as an off-by-one (ersatztv#616). Put that description on the parameter itself with `[Description("...")]` (`System.ComponentModel`, on the `[FromQuery]` parameter) so it reaches the generated OpenAPI document — an attribute-free paging parameter is emitted with no description at all, leaving a REST consumer to infer the base from `default: 0` (ersatztv#633). State the endpoint's **own** cap, never one global number: the caps differ (100 typical, 200 auto-tune members, 1000 `search/all-items`). `OpenApiPagingContractTests` pins this and names the expected set of paged operations, so a new paged endpoint fails until it is added there **with** descriptions. See `api.paging-zero-based`. **The handler behind it must compute its total from the SAME query it pages.** Build one `IQueryable`, apply every filter to it, then take both `CountAsync` and the page from that object — never `dbContext..CountAsync(ct)` beside a separate page query, and never a second `CountAsync(pred, ct)` restating the predicate. Take the shape even before the first filter exists — a handler with no filter today is where the drift is introduced tomorrow. The drifted state is silent and shaped like working software: the page is right, the total is wrong, and the client believes the total, so the SPA paginates to pages that can never fill and an MCP caller pages toward a completeness target it cannot reach. Eager-loading `.Include(...)` belongs on the page chain only, appended after the count — a `COUNT` does not materialize the graph. See `api.paged-count-matches-page-query` (ersatztv#690, #758). - **Sortable GET with allow-listed sort params**: same file — `sortField`/`sortDirection` are normalized against a fixed allow-list (`AllowedSortFields`) rather than trusted or rejected with a 422: an unrecognized `sortField` silently falls back to the default field, an unrecognized `sortDirection` falls back to the default direction. Copy this pattern (normalize, don't 422) for any new sortable endpoint — it matches the pageNum/pageSize clamp precedent above and keeps a bad query string from ever producing an error response for a read-only listing. ## 2. DTOs: where they live and their nullable context - **Response DTOs**: `ErsatzTV.Core/Api//*ResponseModel.cs`. They are `record`s that mirror the shape of the Application-layer ViewModel for that domain — **never expose a VM type directly** from a controller. Convert framework types the SPA can't consume as-is (e.g. `CultureInfo` → its name string). - As of #288, **all** response-model files under `ErsatzTV.Core/Api/` carry `#nullable enable` (including the two enum wire-mirrors `Settings/XmltvBlockBehavior.cs` / `XmltvTimeZone.cs`), so the pragma is now universal, not "most". The project sets `disable` (`ErsatzTV.Core/ErsatzTV.Core.csproj`), under which a non-null `string` member emits a spurious `nullable: true` in the schema — so **add `#nullable enable` at the top of every new response-model file** and mark a member `?` only when the mapper can actually emit null. `FFmpegProfileResponseModel.cs` is no longer an exception. - **Raw-VM wrapping (#288).** The last controllers returning Application ViewModels directly were wrapped, so no `/api/*` action returns a `*ViewModel` type anymore: `CollectionController` → `MediaCollectionResponseModel` (`Id, Name, CollectionType, UseCustomPlaybackOrder` — drops the `MediaCardViewModel` scaffolding and the header-only `Version`), `ScheduleController` → `ProgramScheduleResponseModel` (VM minus `Version`), `SmartCollectionController` → the existing `SmartCollectionResponseModel`, `ResolutionController.GetResolutionByName` → `ResolutionResponseModel`, and the **channel detail** GETs/writes → **`ChannelDetailResponseModel`** (the full editable field set the SPA channel editor needs — distinct from the lean list `ChannelResponseModel`; drops only the derived `webEncodedName`). When wrapping, expose exactly what the client reads: a leaner projection is right for a list, a faithful detail projection for an editor. - **Request DTOs**: `ErsatzTV/Controllers/Api/Requests/*Request.cs`. No `#nullable enable` pragma — match the existing files (e.g. `CreateFFmpegProfileRequest.cs`, `ReplaceScheduleItemsRequest.cs`). A request record typically carries a `ToCommand()` (or `ToCommand(int parentId)`, `ToReplaceCommand(int index)`) method that maps it to the Application-layer command type. - **`ErsatzTV.Application` has no nullable context** (no `` = C# default `disable` for that TFM in this repo — confirms CS8632 would otherwise fire) — do **not** add `?` nullable annotations to types living there; that's a Core/Api-layer-only convention. A static mapper that lives in `ErsatzTV.Application` but returns a Core/Api response DTO with nullable members is fine (e.g. `ScheduleItemResponseMapper`) — the nullability lives on the DTO record, not the mapper. - **Shared `{id, name}` embeds**: use `ErsatzTV.Core/Api/NamedIdResponseModel.cs` (`record NamedIdResponseModel(int Id, string Name)`) when a response DTO needs to embed a list of named references (e.g. a schedule item's `watermarks` / `graphicsElements`) rather than minting a one-off `(int, string)` record per domain. - **Server-declared capability fields.** When the SPA must decide whether an action is possible, the server declares it as structured metadata on the response DTO (`{Availability, ...}`) rather than the SPA inferring it from a display string. Examples: health-check remediation (`api.healthcheck-remediation-dto`) and channel preview capability (`ChannelPreviewResponseModel`). - **Server-derived rollup verdict + hand-maintained string union**: when a DTO needs a computed verdict rather than a raw fact, add a nested response model carrying a `Status` plus a `Faults`/ detail array, both typed as plain `string`/`string[]` backed by a `const string` class (e.g. `ChannelHealthStatus`, `ChannelFault`), **not** a C# enum — keeps the OpenAPI schema a bare `string`/`string[]` and lets the SPA hand-maintain its own TS union, exactly like `ChannelPreviewAvailability`. Exemplar: `ChannelHealthResponseModel` (`{Status, Faults[], PlayoutCount, BrokenSourceItemCount}`) nested as `health` on `ChannelResponseModel` / `ChannelDetailResponseModel` — computed **read-time** from the built playout timeline (never cached at build time, since the underlying `MediaItem.State` flips on scan) via one bounded aggregate query, not per-row N+1. See `docs/decisions.md` → `api.channel-health-object` (#415). - **Flatten polymorphic VMs for the wire**: when an Application ViewModel is an abstract/polymorphic record (subtypes carrying extra fields), the OpenAPI schema only captures the base shape — promote every subtype field to a nullable top-level member on a flat response DTO and pattern-match the concrete VM in the mapper. Exemplar: `ScheduleItemResponseModel` (issue #126, see `docs/decisions.md` 2026-07-10). Keep the flat DTO's **mutation** fields named 1:1 with the matching request DTO so GET→PUT is lossless (guard with a round-trip handler test). - **Optional enum filter via query param**: to filter a list endpoint by an enum, add a nullable enum parameter to the query record (default `null`) and bind it with `[FromQuery] TEnum? name` on the action; filter server-side only when it has a value. Exemplar: `?fillerKind=` on `GET /api/v1/filler-presets` (`GetAllFillerPresetsForApi(FillerKind? FillerKind = null)`). An invalid enum value is rejected by model binding (400) — no handler-side guard needed. - **Optional bool query param (flag / cache-bust)**: bind `[FromQuery] bool name` (absent → `false`) and thread it into the query record with a defaulted parameter so existing callers are unaffected. Exemplars: `?deep=` on `POST /api/v1/libraries/{id}/scan` (§3b), and `?refresh=` on `GET /api/v1/health` (`GetAllHealthCheckResultsForApi(bool Refresh = false)`) which forces a fresh run past the service's TTL result cache — the cached poll path is the default, the flag is the explicit opt-out (see `decisions.md` 2026-07-19, #431). - **"Clear to none" vs "inherit" on a coalescing DTO: a typed `clear` enum list, not null.** When a create/patch DTO resolves a field as `request.X ?? inherited.X` (e.g. `advanced.X ?? template.X`), `null` already means *inherit*, so it cannot also mean *set to none*. Add an optional `clear` field typed as a **list of a string enum** naming the fields to force to none — additive, so omitted = inherit stays byte-stable for existing clients. Validate that a field is not both set and cleared (reject as 422). **Define the enum in `ErsatzTV.Core`** (not the Application command) so `Startup.UseStringEnumSchemas` renders it as a string enum in the spec — an Application-layer enum shows as a bare `integer`. Exemplar: `CreateChannelFromLineupClearField` on `POST /api/v1/channels/from-lineup` (`decisions.md` 2026-07-21, `api.from-lineup-clear-to-none`, #135). - **Evolving a frozen DTO: deprecate-in-place, add the richer field, never remove.** `/api/v1` is frozen-additive (#286), so when a response field's shape needs to grow, keep the old member populated (mark it deprecated in an XML/`//` comment) and add the replacement alongside. Exemplar: `HealthCheckResponseModel` (#164) kept flat `string? Link` (still populated) and added `Remediation { Kind, Target }` (a nested model with an in-app-route-vs-external-doc kind) plus `Brief`. `Remediation.Kind` is a mapped **string** ("ExternalDoc"/"AppRoute"), not a wire enum — same pattern as `Status`. See `decisions.md` 2026-07-17 (#164). ### 2a. Flattening a tagged-union selection (read path) Several DTOs flatten a "exactly one of these navigations is populated" tagged union to a single `selectedId` + `selectedName` pair (`RerunCollectionResponseModel`, and the playlist-item shape). Two rules, both learned from #671, where the list endpoint returned a null selection for **every** row and the detail GET 500'd for two of its media types: - **One include chain per projected aggregate, shared by every handler that projects it.** Put it in a `QueryExtensions` extension method and call it from the list handler *and* the by-id handler. Exemplars: `RerunCollectionQueryExtensions.IncludeSelectionDetails()`, `ProgramScheduleItemQueryExtensions.IncludeScheduleItemDetails()`. Two hand-maintained chains drift, and the one that drifts is usually the paged list, whose rows are individually less obviously wrong. Applying it before `Skip`/`Take` is fine — EF applies the includes to the paged subquery, so the cost is bounded by `PageSize`, not by the table. - **The id and the name must not share a single point of failure.** When both are read off the same eager-loaded navigation, the id is only ever as available as the name — so an un-included type doesn't merely render an unlabelled badge, it drops the selected id, and an editor that round-trips that id silently clears the user's stored selection. Accordingly a media-item flattening switch never ends in `_ => null`: an unrecognized subtype keeps its id and takes a conspicuous `[unsupported media type: X]` name. Throwing is the wrong lever — it would fail an entire paged GET over one unreadable row. The shared switch is `MediaCollections.Mapper.ProjectMediaItemToViewModel`. Corollary for the mappers themselves: `MediaItems.Mapper`'s projections are reached from handlers whose include chains differ, so every metadata navigation is read through `Optional(...).Flatten()` and degrades to the `"???"` placeholder rather than throwing. A bare `x.Season.Show.ShowMetadata` inside a projection is a latent 500 on some other caller's GET. **And it is not only navigations.** `SongMetadata.Artists` is a nullable EF *primitive collection* (a JSON array in one column), which `FallbackMetadataProvider` leaves unassigned for a song whose tags failed to read — and `string.Join` throws `ArgumentNullException` on a null sequence, not a `NullReferenceException`. Adding an include is therefore not automatically safe: it can promote a latent throw on a previously-unloaded member into a live 500 that fails the whole page. When you widen an include chain, audit what the newly-reachable projection dereferences. ## 3. Error mapping Central helper: `ErsatzTV/Extensions/ApiResults.cs`. Use these extension methods instead of hand-rolling `IActionResult` status codes: | Method | Input | Output | |---|---|---| | `ToErrorResult()` | `BaseError` | 404 if `NotFoundError`, **412 if `PreconditionFailedError`** (optimistic-concurrency mismatch, §7a), **409 if `LockedError`** (a handler's own lock-acquire lost the race, §3a), else 422 (`ProblemDetails`) | | `ToCreatedResult(location, body)` | `Either` | `Left` → `ToErrorResult()`; `Right` → 201 + `Location` header | | `ToUpdatedResult()` | `Either` | `Left` → `ToErrorResult()`; `Right` → 200 + body | | `ToDeletedResult()` | `Either` | `Left` → `ToErrorResult()`; `Right` → 204 | | `ToGetResult()` | `Option` | `Some` → 200 + body; `None` → 404 | | `ApiResults.NotFoundProblem(detail?)` | — | 404 `ProblemDetails` directly (e.g. when a controller has to pre-check existence itself, see `TemplateController.DeleteGroup`) | | `ApiResults.ConflictProblem(title, detail)` | — | 409 `ProblemDetails` directly — for a mutation that races a background operation holding a lock (see §3a) | ### 3a. 409 when a mutation races a background lock When an endpoint mutates an entity that a background operation may be actively rebuilding under an `IEntityLocker` lock, guard the mutation and return **409 Conflict** (`ApiResults.ConflictProblem`) while the lock is held. This mirrors the Blazor UI, which disables the same actions while the lock event is live. **This guard is advisory check-then-act, not mutual exclusion.** It narrows the race but does not eliminate it: a build already queued can acquire the lock a moment *after* the check passes, and the mutation then interleaves with the build anyway. That residual window is accepted where the consequences are self-healing (a playout half-mutated during a build is corrected by the next rebuild). If an entity's consequences were NOT self-healing, this pattern would be insufficient — the mutation would need to actually acquire the lock for its duration instead. Established by issue #215 (`PlayoutController` + `ChannelController.ResetPlayout`): inject `IEntityLocker`, and at the top of every id-keyed mutation (`PUT`/`POST`/`DELETE`) check `IsPlayoutLocked(id)` → `ConflictProblem("Playout build in progress", ...)`; add `[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]` to each guarded action. Precedent for the 409 shape: `TraktController` (its private `ConflictProblem()`). Two nuances: - **Fire-and-forget bulk operations don't 409** — `POST /api/v1/playouts/reset-all` stays 202; its handler (`ResetAllPlayoutsHandler`) *skips* locked playouts, matching Blazor + the handler semantics. Only per-id mutations 409. As of #235 the handler returns a `ResetAllPlayoutsResult` (`QueuedPlayoutIds` / `SkippedLocked` / `SkippedUnsupported`) and the controller returns the 202 **with a `ResetAllPlayoutsResponseModel` body** reporting what was queued vs. skipped (locked, or an unsupported `ExternalJson`/`None` kind) — a fire-and-forget bulk op still reports its outcome rather than silently swallowing skips. - **Surface the lock state to clients** so they can pre-disable the buttons: stamp an `IsLocked` boolean onto the list DTO (`PlayoutListItemResponseModel`, set from `IsPlayoutLocked` in the controller's list projection) rather than adding a push channel — and (as of #235) onto the single-playout GET DTO (`PlayoutResponseModel.IsLocked`, set the same way in every action that maps it) so a client polling one playout has the same flag. The SPA reads it and, on a 409, refreshes to pick up the flag. - **Async-op success is 202, not 200** — an endpoint whose success path only *queues* a background rebuild returns **202 Accepted**, not 200 (#235: `POST /api/v1/channels/{id:int}/playout/reset` resolves the channel's playout by the immutable channel **`Id`** (`GetPlayoutIdByChannelId`), guards on `IsPlayoutLocked` → 409, queues a `BuildPlayout` → `AcceptedResult`, and 404s when the channel has no playout). It is keyed on `{id:int}`, **not** `{channelNumber}` — the single-item Channel admin contract keys on `Id`, never the user-mutable `Number` (re-keyed in #197 Bundle C; see `docs/decisions.md` 2026-07-12). The broadcast-side lookup (`GetPlayoutIdByChannelNumber`, used by `HlsSessionWorker`) stays number-keyed — a separate contract. Reserve 200 for a synchronous durable result. - **Handler-side atomic lock loss also maps to 409, via a typed error, not 422** (issue #316 review): when the *handler itself* is the one that atomically acquires an `IEntityLocker` lock (not just a controller pre-check) and loses the race, return `new LockedError(...)` (`ErsatzTV.Core/Errors/LockedError.cs`, sibling of `NotFoundError`/`PreconditionFailedError`) from the handler — `ToErrorResult()` maps it to 409 automatically. Exemplar: `PrepareTroubleshootingPlaybackHandler` — `TroubleshootController` does a cheap `IsTroubleshootingPlaybackLocked()` pre-check (advisory, §3a's check-then-act caveat applies), but the handler's own `LockTroubleshootingPlayback()` is the atomic acquire; if *that* loses the race it returns `LockedError`, so the 409 survives even when the pre-check passed a moment too early. Don't let a handler-side lock loss fall through to the generic 422 `BaseError.New(...)`. ### 3b. Map a "queue a background job" outcome to status codes with an enum, not a `bool` When an endpoint *starts* a background operation guarded by an `IEntityLocker` lock, return an **outcome enum from the handler** and map it in the controller — don't collapse distinct outcomes into a lying `bool`/200. Exemplar (issue #232): `QueueLibraryScanByLibraryId` → `QueueLibraryScanResult { Queued | NotFound | SyncDisabled | AlreadyScanning }`, mapped by `LibrariesController.ScanLibrary` to **202** (`AcceptedResult`, queued), **404** (`ApiResults.NotFoundProblem`), **422** (`UnprocessableEntityObjectResult` + `ProblemDetails`, a domain precondition such as sync-disabled), and **409** (`ApiResults.ConflictProblem`, the lock is already held = already scanning). Here the acquired lock **is** the running job, so `LockLibrary(id) == false` means "already scanning" → 409 (a variant of §3a where the lock is the operation itself, not a mutation racing it). Add `[ProducesResponseType]` for 202/404/409/422 and `typeof(ProblemDetails)` on the error ones. **Guard the lock→enqueue** with the `EnqueueWithTraktLock` compensating-unlock pattern (`TraktController`): if a `WriteAsync` throws after a successful `Lock*`, `Unlock*` in a `catch` and rethrow — one lock ⇄ exactly one release. A second exemplar (issue #235 slice B), where the lock lives on the **controller** rather than in a handler: `POST /api/v1/media-sources/{plex|jellyfin|emby}/{id}/scan-collections?deep=` acquires the per-source collections lock (`entityLocker.LockPlexCollections()` etc.) — the lock IS the running collections scan, so a `false` = 409 — then `WriteAsync`es `Synchronize{X}Collections(id, ForceScan: true, deep)` to the scanner channel and returns **202**. `ScannerService` releases that lock in a `finally` when it processes the message; the controller compensating-unlocks in a `catch` if the enqueue throws. `POST /api/v1/libraries/{id}/scan?deep=` similarly threads an optional `[FromQuery] bool deep` into `QueueLibraryScanByLibraryId(id, DeepScan)`. **Status counterpart for a lock-backed async op.** A queue-triggering endpoint whose "is it running?" state lives in a lock/registry should expose a **GET status surface** the SPA can poll to reconcile its optimistic pending flag, rather than relying on a client-side timeout. Two exemplars: `GET /api/v1/libraries/scan-status` reads `IScannerProxyService.GetActiveScans()` (per-library, with percent); `GET /api/v1/media-sources/collections-scan-status` (#271) reads `IEntityLocker.Are{X}CollectionsLocked()` and returns one `{family}` entry per **family-global** collections lock that's held (no id, no percent — the lock granularity dictates the DTO shape). Return only the *active* entries (empty list = nothing running), mirroring the queue op's own lock. `NotFoundError : BaseError` lives in `ErsatzTV.Core/Errors/NotFoundError.cs` — return it from a handler's validation when a lookup fails, so the controller-side mapping falls out for free. **Handler-hardening checklist** when you touch (or add) a handler behind a new/changed endpoint: - Missing FK existence checks that would otherwise 500 → convert to a `NotFoundError` (404) or a validation `BaseError` (422). - Dictionary-indexer lookups (`dict[key]`) that can throw `KeyNotFoundException` → guard or use `TryGetValue`. - Silent item filtering (e.g. `.Where(x => x.IsValid)` dropping bad rows without telling the caller) → surface as 422 instead of silently returning a shorter list. - Unbounded `int`/`TimeSpan` inputs from the request → clamp or validate, per the Logs pagination pattern above. - **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 unhandled **500** (there is no global exception filter). Validate names null-safe: reuse the `Validators.NotEmpty(x => x.Name).Bind(_ => x.NotLongerThan(50)(x => x.Name))` combinator (`ErsatzTV.Application/Validators/StringValidation.cs`; both are null-safe via `Optional`), the same pattern the group-create handlers already use — or at minimum guard `string.IsNullOrWhiteSpace(name)` before any member access. Fixed across 10 create/replace handlers in issue #172 (was `if (request.Name.Length > 50)`). - **Known, deliberate exception**: deep FK ids nested inside item-list request bodies (e.g. a schedule item's `CollectionId`) are **not** existence-checked at that depth — this is established precedent from the schedules endpoints (see issue #172) and intentional to avoid N+1 validation queries; don't "fix" this without discussing it first. ### 3c. A durable-save PUT that also triggers a background sync as a side effect Some PUT-replace endpoints (issue #202: `PUT /api/v1/media-sources/{plex|jellyfin|emby}/{id}/libraries`) have a synchronous durable write as their primary purpose — the response must reflect that write — but Blazor's editor also fired off a background sync per newly-enabled library after the save. This is a **different shape from §3b**: §3b is for an endpoint whose entire job *is* starting a background operation (so the outcome enum drives the status code); here the enqueue is a fire-and- forget side effect of an otherwise-ordinary write, and the response must still be **200 with the durably-saved data**, not 202. Pattern (see `PlexMediaSourcesController.ReplaceLibraryPreferences` / `EnqueuePostSaveSync`): 1. Dispatch the write command; on `Left`, return the error — nothing is enqueued. 2. **Reload** the saved rows through the same query the GET uses (per §7) — needed both for the response and because ids can change as a result of the save (e.g. a disable-then-re-add). 3. Iterate the reloaded rows and, per row that needs a sync, `LockLibrary(id)` — **skip rows whose lock is already held** (mirrors Blazor's `if (Locker.LockLibrary(id))` loop; no 409 for the save itself, only a silent skip for that one row's sync). 4. Enqueue the background message(s) for that row; if a multi-message enqueue can throw partway through, wrap it in `try/catch` and release the lock in the `catch` (compensating unlock) before rethrowing — the standard `EnqueueWithTraktLock` one-lock-⇄-one-release discipline (§3b), just invoked from inside a write endpoint instead of a dedicated "start background job" endpoint. 5. Return the reloaded data with `200 OK` — never 202 — since the durable save already happened; the enqueue is best-effort and its failure (after a caught/compensated exception) surfaces as a 500 on this same request rather than silently dropping the sync. This is server-side (not SPA-orchestrated) because the SPA has no access to the scanner channel, and having the SPA fire a second request after the save would open a crash window between "saved" and "synced". See `docs/decisions.md` 2026-07-11 (#202) for the fuller rationale and the specific bug this pattern corrected (Blazor's Plex `Unlock` ordering released the library lock before a dependent second message ran). ### 3d. Bound a consequential numeric field with a 422 — never accept-then-rewrite A write path that accepts an out-of-range number, stores a *different* one and returns `200` teaches the caller nothing and leaves the stored config no longer describing the behavior: the SPA keeps rendering what was typed while the pipeline uses the substitute, and a machine client that `PUT`s a value reads back another. **Validate to the documented range and return 422 naming the bound**, with the consequence of exceeding it in the message ("…leaves the QSV upload pool with too little headroom and the transcode writes nothing at all"), so the error teaches the bound instead of hiding it. Three rules that come with it (exemplar: `ErsatzTV.Application/FFmpegProfiles/FFmpegProfileBounds.cs`, ersatztv#735): - **Put the constants where the renderer reads them, and validate against those** — `FFmpegState` owns `MinimumQsvExtraHardwareFrames`, `Minimum/MaximumReadRate` and the defaults, and the write-path validator reads those symbols rather than restating numbers. **The SPA cannot: it restates each bound as a literal** (`web/src/screens/ffmpegProfileDraft.ts`), and nothing pins the two together, so raising a server bound leaves every test green while the form keeps enforcing the old one. Mirror the value AND the wording of the server's message, and treat the drift as a known residual rather than assuming the literal is checked. - **Keep the render-time clamp as well.** It is what makes the change migration-free: rows written before the validation existed, or out of band, still cannot reach FFmpeg unbounded. Validation is the primary guard; the clamp is belt-and-braces, and both need a test. - **On update, reject a NEWLY submitted out-of-range value, not an unchanged legacy one.** The SPA sends the whole profile back on every edit, so rejecting a stored-but-out-of-range value would make an old row uneditable over a field the operator never touched — and, when the field is conditionally rendered, cannot even see. Compare against the stored value and let an unchanged one through — **and mirror the exemption in the client**, or the form blocks a save the server would have accepted and the row is uneditable in the surface that matters (`validate(draft, stored)` in `ffmpegProfileDraft.ts`; the add/copy path passes no stored draft and stays strict, matching the create handler's `stored: null`). `null` keeps meaning **unset**, resolved by the renderer to the value it used before the field was configurable — never materialized into a stored number on save, so an untouched profile behaves identically. Document the range in the schema with `[property: Description("…")]` on the request record's positional parameter (`System.ComponentModel`); it renders into `v1.json`. ### 3e. An ABSENT collection means unrestricted; an explicitly EMPTY one is rejected A nullable collection on a write path carries two different requests that are easy to collapse into one, and collapsing them is how ersatztv#880 shipped a 200 that stored a row which could never apply. Decide both, separately: - **Absent** (the property is missing, or explicitly `null` — Newtonsoft maps both to `null`) means *the client is not expressing a restriction*. Normalize it to the **permissive** value, which is whatever the READ side already substitutes for the same absence. It must not become the empty set: for a filter, empty is the maximally *restrictive* value, so `?? []` silently inverts the request. - **Explicitly empty** (`[]`) is a different, well-formed request. If the empty set has no meaningful outcome — a conjunctive filter that then matches nothing — **reject it with a 422 naming the consequence**, per §3d. Do not accept-then-rewrite it into the permissive value: that would make `[]` and absence indistinguishable again, in the other direction. Distinguishing them requires the request record's property to be **nullable** (`List?`, with a per-file `#nullable enable` where the project has annotations off), because `?? …` on a non-nullable-annotated `List` cannot tell absence from empty. Normalization belongs in the request record's `ToReplaceItem`; **rejection does not belong beside it.** Three rules that come with it: - **Say "send `null`", not "omit the property".** Making a C# property nullable does NOT make it optional in the generated schema: all three recurrence properties are still listed in `required` in `v1.json`, with type `["null","array"]`. A client generated from the published contract therefore *cannot* omit them, and an error message telling it to would be instructing a schema violation. Omission still works at runtime; `null` is the form that is also contract-legal. - **Validate where the STORED value is in hand — the handler, not the controller.** §3d's rule that an *unchanged* bad value must still be accepted applies here in its sharpest form: these are whole-list replace PUTs, so rejecting a pre-existing empty set would make every *other* item in the list uneditable over a row the operator never touched. That comparison needs the stored row, which the controller does not have and the handler already loaded. Exemplar: `RecurrenceSetBounds` (`ErsatzTV.Application/Scheduling`), one validator called from both replace handlers — the same shape as `FFmpegProfileBounds`. The exemption is **per field**, not per row: a row grandfathered on `DaysOfWeek` still cannot newly empty `MonthsOfYear`. - **Derive the validated set from the list the handler actually writes.** In the alternate-schedule path that is `incoming`, which *excludes* the highest-`Index` catch-all — the handler discards that item's recurrence along with its date range, so an empty set there cannot make anything "never apply" and rejecting it would state a reason that is false for that item. Walking the same list the writes iterate is what keeps the check and its subject from drifting; do not re-derive "which item is the catch-all" in a second place (`api.put-replace-index-order`). ## 4. Artwork contract API response DTOs return **rooted, directly-usable artwork URLs** — e.g. `/artwork/posters/...`, `/artwork/thumbnails/...`, plus passthrough for `http://`/`https://` absolute URLs and for Jellyfin/Emby proxy variants. This was established by PR #181 in `ErsatzTV.Application/LibraryBrowse/Queries/GetLibraryBrowseItemsHandler.cs` (private `Artwork(...)` helper, ~line 1330) — copy that helper's logic (or call a shared version of it) for any new API surface that returns artwork paths. Comment in that file: *"Returns a rooted, directly-usable artwork URL for the SPA's ``. Blazor pages rely on GetPosterUrl to prefix `artwork/posters/`... but the SPA [needs it pre-rooted]."* Do **not** reuse the Application-layer Mappers used by Blazor (e.g. `MediaCards`/`Television` mappers) for new API DTOs — those still return the old Blazor-convention relative paths. Map from the domain/VM directly and root the path yourself, following the PR #181 pattern. Channel **logos** live under a different route than posters/thumbnails: an uploaded logo roots to `/iptv/logos/{file}` (served by `IptvController`). An **external logo URL is no longer stored as a URL** — since #525, `PUT`/`POST /api/v1/channels…` downloads it, decode-validates it, and caches it at save time, so `Artwork.Path` holds a content-hash name and the browse/guide DTOs emit an `/iptv/logos/…` URL exactly as for an uploaded logo. Browse-surface DTOs (`ChannelResponseModel` list, `ChannelGuideChannelResponseModel` guide) get this rooted `Logo` URL from the single `Channels.Mapper.GetLogoUrl` helper (#464), which returns `null` when the channel has no logo so the SPA falls back to its generated initials icon. The raw un-rooted `{path, contentType}` form is still used only by the channel **editor** DTO (`ChannelDetailResponseModel.Logo`), which round-trips it back on save. **New logo-download rejections (#525).** `PUT /api/v1/channels/{id}`, the two channel-create endpoints, and `POST /api/v1/artwork/uploads` now reject a logo that cannot be used. The failure is a `BaseError`, so it surfaces as this API's standard **422 `ValidationProblemDetails`** (via `ToErrorResult()`), **not** a 400 — a 400 here still means model-binding/validation-attribute failure. Rejected cases: an external URL that is unreachable, times out (>10s), is oversized (>10 MiB), is not an image, or is a decode bomb (over 50 MP total pixels or 600 frames); an upload gets the same decode-budget check. The `detail` names the reason (e.g. *"Could not download logo from … : Connection refused"*, *"Remote image … returned content type 'text/html'"*, *"Image cannot be used: … pixel limit"*). Response **shapes are unchanged** — only the error set — so the OpenAPI models did not change. (Verified by local live-E2E: good URL → cached `/iptv/logos/`; unreachable/non-image → 422.) `GET /api/v1/watermarks` returns picker-grade rows that carry `imageSource` alongside `id`/`name` (#67), so a client can find the seeded logo-driven `Channel Bug` preset without matching its user-editable name. The full geometry still requires `GET /api/v1/watermarks/{id}`. ### 4a. Artwork content type is sniffed, never client-supplied (issue #283) The uploaded-artwork surfaces (channel logo, watermark) must never trust a client-declared content type — doing so was a stored-XSS chain (upload `