# Decisions — append-only log Purpose: why the codebase does what it does, so agents don't "fix" an established convention or relitigate a settled call. Append new entries at the bottom in date order (plus a TOC line in the Index); **update this doc in the same PR that changes any fact below** (or that establishes a new convention worth recording). **Append-only, enforced (ersatztv#303 H9).** A commit or PR that *deletes or modifies* an existing line is blocked by the Husky `commit-msg` hook and the CI `decisions-guard` job; insertions anywhere are always allowed. The block is lifted only by the **`[decisions-edit]`** token in the commit message, for the two legitimate reasons to touch history: - **Fix a factual error** in a past entry. - **Supersede a reversed decision** — add the new dated entry, then prepend a `> **Superseded YYYY-MM by .**` banner to the old entry and tag its Index line `(superseded)`. Keep the old entry — *why we changed our mind* is the point; never silently rewrite it. **Consolidation** (prune/merge superseded entries, refresh the Index) is primarily a step in the release checklist (`docs/ci-cd.md` → Versioning & releases), done at each release/milestone with `[decisions-edit]` — not ad hoc mid-arc. A **size floor** backstops it between releases: the `decisions-guard` CI job warns (non-blocking) once this file exceeds **1800 lines** — the point where it no longer fits one default 2000-line agent Read — so append-only can't grow past what an agent can read in a pass. The metric is the file's read cost (line count), not the entry count. Together these keep append-only from accreting stale, contradictory, or unreadably-large history. --- ## Index Decisions are split between the chronological log **in this file** and four **topic files** under `docs/decisions/` — large same-topic clusters extracted at the v26.9.0 consolidation (full rationale preserved). Check the relevant topic file below for its subject; otherwise scan the in-file entries. **Topic files:** - [`decisions/optimistic-concurrency.md`](decisions/optimistic-concurrency.md) — ETag / If-Match / `Version` optimistic concurrency (#253, #259, #265, #269). Mechanics: `api-conventions.md` §7a–§7c. - [`decisions/api-auth-security.md`](decisions/api-auth-security.md) — API/SPA auth & security posture (#197 bundles + #279 headers, #206, #283, #292, #295, #301, #319, #330). - [`decisions/release-ci-governance.md`](decisions/release-ci-governance.md) — release / CI / merge governance (#303 H3/H4/H5/H6/H9/H10, #311, #314, #315, #335). - [`decisions/spa-modularization.md`](decisions/spa-modularization.md) — App.tsx screen/shell extraction epic #243 (#244, #245, #247). **In this file:** - [2026-06 — REST API wraps existing MediatR handlers 1:1, no service layer](#2026-06--rest-api-wraps-existing-mediatr-handlers-11-no-service-layer) - [2026-06 — UI rebuild is a React SPA (ChicoryTV) on the REST API, not a Blazor reskin](#2026-06--ui-rebuild-is-a-react-spa-chicorytv-on-the-rest-api-not-a-blazor-reskin) - [2026-07 — Response DTOs live in `ErsatzTV.Core/Api`, file-scoped `#nullable enable`](#2026-07--response-dtos-live-in-ersatztvcoreapi-file-scoped-nullable-enable) - [2026-07 — PUT-replace list endpoints derive `Index` from array order; alternate-schedules last row = catch-all default](#2026-07--put-replace-list-endpoints-derive-index-from-array-order-alternate-schedules-last-row--catch-all-default) - [2026-07 — Templates editor in the SPA is a table, not Blazor's drag-calendar](#2026-07--templates-editor-in-the-spa-is-a-table-not-blazors-drag-calendar) - [2026-07-07 — API artwork contract: rooted URLs produced server-side](#2026-07-07--api-artwork-contract-rooted-urls-produced-server-side) - [2026-07-07 — Decode-style endpoints take a row id and look up server-side](#2026-07-07--decode-style-endpoints-take-a-row-id-and-look-up-server-side) - [2026-07-07 — Season/episode/music-video drill-in via `parentId`, not new child-listing endpoints](#2026-07-07--seasonepisodemusic-video-drill-in-via-parentid-not-new-child-listing-endpoints) - [2026-07-07 — Convention docs read at session start, updated in-PR](#2026-07-07--convention-docs-read-at-session-start-updated-in-pr) - [2026-07-09 — Playback-troubleshooting completion feedback: poll status, no push channel](#2026-07-09--playback-troubleshooting-completion-feedback-poll-status-no-push-channel) - [2026-07-09 — datetime-local instead of Chronic natural-language start parsing](#2026-07-09--datetime-local-instead-of-chronic-natural-language-start-parsing) - [2026-07-09 — SPA gates Download Media Sample while a session is active](#2026-07-09--spa-gates-download-media-sample-while-a-session-is-active) - [2026-07-09 — OpenAPI spec mirrors the runtime Newtonsoft serializer (#198)](#2026-07-09--openapi-spec-mirrors-the-runtime-newtonsoft-serializer-198) - [2026-07-09 — YAML playout validator: paste-textarea instead of a server file path](#2026-07-09--yaml-playout-validator-paste-textarea-instead-of-a-server-file-path) - [2026-07-09 — Channel numbers: prompt-driven sequential renumber instead of drag-to-reorder](#2026-07-09--channel-numbers-prompt-driven-sequential-renumber-instead-of-drag-to-reorder) - [2026-07-09 — "Table, not calendar" convention also covers the deco-templates editor](#2026-07-09--table-not-calendar-convention-also-covers-the-deco-templates-editor) - [2026-07-11 — Trash "See all" reuses library-browse paging; search stays capped per kind (#213)](#2026-07-11--trash-see-all-reuses-library-browse-paging-search-stays-capped-per-kind-213) - [2026-07-11 — Logs page-size is a client-local preference, not a server ConfigElement](#2026-07-11--logs-page-size-is-a-client-local-preference-not-a-server-configelement) - [2026-07-11 — Logs column sorting: allow-listed `sortField`/`sortDirection` on `GET /api/logs`](#2026-07-11--logs-column-sorting-allow-listed-sortfieldsortdirection-on-get-apilogs) - [2026-07-09 — Per-playout "Schedule reset" button dropped; Reset uses the server-default build mode](#2026-07-09--per-playout-schedule-reset-button-dropped-reset-uses-the-server-default-build-mode) - [2026-07-09 — Collection custom order: move up/down buttons, any-kind collections](#2026-07-09--collection-custom-order-move-updown-buttons-any-kind-collections) - [2026-07-10 — Shared "Add to…" layer lives in `web/src/media/addTo/`; select-mode is an explicit toggle](#2026-07-10--shared-add-to-layer-lives-in-websrcmediaaddto-select-mode-is-an-explicit-toggle) - [2026-07-10 — Schedule-item GET returns a flat, non-polymorphic DTO (`ScheduleItemResponseModel`)](#2026-07-10--schedule-item-get-returns-a-flat-non-polymorphic-dto-scheduleitemresponsemodel) - [2026-07-10 — Playout API mutations return 409 while the build lock is held (#215)](#2026-07-10--playout-api-mutations-return-409-while-the-build-lock-is-held-215) - [2026-07-11 — Schedules SPA editor: draft/explicit-Save over instant-persist; Copy includes multi/smart/rerun; shuffled-GET normalization preserved](#2026-07-11--schedules-spa-editor-draftexplicit-save-over-instant-persist-copy-includes-multismartrerun-shuffled-get-normalization-preserved) - [2026-07-11 — Channel editor: bare-create entry point + external-logo mutual exclusion (#212)](#2026-07-11--channel-editor-bare-create-entry-point--external-logo-mutual-exclusion-212) - [2026-07-11 — Queue state lives in the pinned Gitea tracker (#237), not in the handoff file](#2026-07-11--queue-state-lives-in-the-pinned-gitea-tracker-237-not-in-the-handoff-file) - [2026-07-11 — EntityLocker: atomic flags + single-owner release discipline, no owner tokens (#231)](#2026-07-11--entitylocker-atomic-flags--single-owner-release-discipline-no-owner-tokens-231) - [2026-07-11 — Media-source management REST write API + SPA (#202)](#2026-07-11--media-source-management-rest-write-api--spa-202) - [2026-07-11 — Legacy→SPA redirect matcher: exact map + ordered segment-template patterns (#204)](#2026-07-11--legacyspa-redirect-matcher-exact-map--ordered-segment-template-patterns-204) - [2026-07-11 — Pre-removal Blazor rollback tag `blazor-final` (#205)](#2026-07-11--pre-removal-blazor-rollback-tag-blazor-final-205) - [2026-07-11 — Async-op API contract normalization + playout build observability + F9 scan endpoints (#235)](#2026-07-11--async-op-api-contract-normalization--playout-build-observability--f9-scan-endpoints-235) - [2026-07-11 — Post-commit side effects run on `CancellationToken.None` (generalized from #251 to #254)](#2026-07-11--post-commit-side-effects-run-on-cancellationtokennone-generalized-from-251-to-254) - [2026-07-12 — External-collections scans get an authoritative status surface (#271); the SPA timeout is retired](#2026-07-12--external-collections-scans-get-an-authoritative-status-surface-271-the-spa-timeout-is-retired) - [2026-07-11 — Blazor Server UI removed (#91 phase b)](#2026-07-11--blazor-server-ui-removed-91-phase-b) - [2026-07-12 — Live-E2E is a required step for API write-path handler changes (#303)](#2026-07-12--live-e2e-is-a-required-step-for-api-write-path-handler-changes-303) - [2026-07-12 — TopBar primary-action button: wire creates, drop the rest (#238)](#2026-07-12--topbar-primary-action-button-wire-creates-drop-the-rest-238) - [2026-07-13 — API versioning: the whole `/api` surface is mounted at `/api/v1`, additive-only after freeze (#286)](#2026-07-13--api-versioning-the-whole-api-surface-is-mounted-at-apiv1-additive-only-after-freeze-286) - [2026-07-13 — Scheduling API hardening: null-name 500s, duplicate template items, unreachable 404 (#172)](#2026-07-13--scheduling-api-hardening-null-name-500s-duplicate-template-items-unreachable-404-172) - [2026-07-16 — Functional-E2E CI harness: advisory curl-contract job over an app booted from source (#299)](#2026-07-16--functional-e2e-ci-harness-advisory-curl-contract-job-over-an-app-booted-from-source-299) - [2026-07-16 — Optional advertised IPTV base URL (`iptv.base_url`) resolved centrally in the two generators (#340)](#2026-07-16--optional-advertised-iptv-base-url-iptvbase_url-resolved-centrally-in-the-two-generators-340) - [2026-07-16 — Auto-tuning enumerates via EF, persists via SmartCollection; additive coexistence (#69)](#2026-07-16--auto-tuning-enumerates-via-ef-persists-via-smartcollection-additive-coexistence-69) - [2026-07-16 — Per-playout reshuffle = scoped Reset build; seed surfaced (#71)](#2026-07-16--per-playout-reshuffle--scoped-reset-build-seed-surfaced-71) - [2026-07-17 — Clock-boundary schedule padding already exists (FillerMode.Pad); #77 verified, convenience toggle deferred](#2026-07-17--clock-boundary-schedule-padding-already-exists-fillermodepad-77-verified-convenience-toggle-deferred) - [2026-07-17 — Shuffle-source construction extracted to `ShuffleSourceBuilder`; per-family seam, not a god-factory (#380)](#2026-07-17--shuffle-source-construction-extracted-to-shufflesourcebuilder-per-family-seam-not-a-god-factory-380) - [2026-07-17 — Seasonal / date-conditional scheduling already exists (alternate schedules / playout templates); #73 closed as implemented](#2026-07-17--seasonal--date-conditional-scheduling-already-exists-alternate-schedules--playout-templates-73-closed-as-implemented) - [2026-07-17 — Auto-Tune DetailPanel member list = live search-index roll-up, not EF enumeration (#384)](#2026-07-17--auto-tune-detailpanel-member-list--live-search-index-roll-up-not-ef-enumeration-384) - [2026-07-17 — No persistent compiler servers in CI; every `services:` container gets an explicit cap; #390's small-lane move reversed (#406)](#2026-07-17--no-persistent-compiler-servers-in-ci-every-services-container-gets-an-explicit-cap-390s-small-lane-move-reversed-406) - [2026-07-17 — Channel health on the API = the raw `PlayoutCount` fact on the list DTO, not a derived status enum (#72)](#2026-07-17--channel-health-on-the-api--the-raw-playoutcount-fact-on-the-list-dto-not-a-derived-status-enum-72) - [2026-07-17 — Weighted / fair-share distribution is a new `WeightedShuffle` order; `ShuffleInOrder` is anti-clumping, not fair-share (#70)](#2026-07-17--weighted--fair-share-distribution-is-a-new-weightedshuffle-order-shuffleinorder-is-anti-clumping-not-fair-share-70) - [2026-07-17 — Auto-Tune per-channel overrides reuse the Channel Builder advanced-options DTO; weights + bug-colour logo split out to #425 (#385)](#2026-07-17--auto-tune-per-channel-overrides-reuse-the-channel-builder-advanced-options-dto-weights--bug-colour-logo-split-out-to-425-385) - [2026-07-17 — Health-check remediation is server-declared `{Kind, Target}` on an additive DTO; the SPA acts on it (#164)](#2026-07-17--health-check-remediation-is-server-declared-kind-target-on-an-additive-dto-the-spa-acts-on-it-164) - [2026-07-18 — Auto-Tune DetailPanel SPA: reusable `SlideOver` + shared advanced-options model; decorative panes dropped to match the backend (#386)](#2026-07-18--auto-tune-detailpanel-spa-reusable-slideover--shared-advanced-options-model-decorative-panes-dropped-to-match-the-backend-386) - [2026-07-18 — SmartCollection rule builder: compile-only closed subset, no stored AST, one-level nesting (#176)](#2026-07-18--smartcollection-rule-builder-compile-only-closed-subset-no-stored-ast-one-level-nesting-176) - [2026-07-18 — Auto-Tune per-source weights ride #70's MultiCollection machinery; created at tune time, not a post-hoc PUT (#425)](#2026-07-18--auto-tune-per-source-weights-ride-70s-multicollection-machinery-created-at-tune-time-not-a-post-hoc-put-425) - [2026-07-18 — Search all-items is paged to cap DoS exposure; SPA add-all pages to completeness (#293)](#2026-07-18--search-all-items-is-paged-to-cap-dos-exposure-spa-add-all-pages-to-completeness-293) - [2026-07-18 — Unsupported PlaybackOrder is loud at build time; a declared support matrix and tripwire test make new orders safe by construction (#403)](#2026-07-18--unsupported-playbackorder-is-loud-at-build-time-a-declared-support-matrix-and-tripwire-test-make-new-orders-safe-by-construction-403) - [2026-07-18 — CI build-once was measured and rejected; keep the #420 tree-skip](#2026-07-18--ci-build-once-was-measured-and-rejected-keep-the-420-tree-skip) - [2026-07-18 — Never-scanned `LastScan` surfaces as null at the API boundary, not the 0001-01-01 MinValue sentinel (#409)](#2026-07-18--never-scanned-lastscan-surfaces-as-null-at-the-api-boundary-not-the-0001-01-01-minvalue-sentinel-409) - [2026-07-19 — WeightedShuffle SPA: weights edited on the multi-collection, order offered only on classic MultiCollection schedule items; fair-share is a reset not a mode (#404)](#2026-07-19--weightedshuffle-spa-weights-edited-on-the-multi-collection-order-offered-only-on-classic-multicollection-schedule-items-fair-share-is-a-reset-not-a-mode-404) - [2026-07-19 — Health-check results are TTL-cached; `?refresh=true` forces a fresh run (#431)](#2026-07-19--health-check-results-are-ttl-cached-refreshtrue-forces-a-fresh-run-431) - [2026-07-19 — CI `test` job reports a sampled true peak-anon, not cache-inflated `memory.peak` (#412)](#2026-07-19--ci-test-job-reports-a-sampled-true-peak-anon-not-cache-inflated-memorypeak-412) --- ## 2026-06 — REST API wraps existing MediatR handlers 1:1, no service layer The REST API (#2, `docs/rest-api.md`) is thin controllers over the existing MediatR Create/Update/Delete handlers — no new service/business-logic layer was introduced, since nearly every handler already returns `Either`, which maps cleanly to HTTP status codes. Latent handler bugs (missing existence checks, `KeyNotFoundException` risk, etc.) are fixed **at the handler**, converting what would have 500'd into a proper 404/422 — not papered over in the controller. Established across the #2a–#2e gap-issue PRs. Deep FK ids nested inside item-list request bodies (e.g. a schedule item's `CollectionId`) are deliberately **not** existence-checked at that depth, to avoid N+1 validation queries — precedent set by the schedules endpoints (#172); see `docs/api-conventions.md` §3 for the up-to-date statement of this rule. ## 2026-06 — UI rebuild is a React SPA (ChicoryTV) on the REST API, not a Blazor reskin #59 committed to a full SPA rebuild rather than reskinning Blazor Server pages. Blazor removal is split into two phases under #91: **(a)** root-flip (SPA becomes `/`) + legacy-route redirects — DONE, merged via PR #148 (`ErsatzTV/LegacyUiRedirects.cs`, `feat/91-cutover` → main). **(b)** full Blazor removal — gated on every route having an SPA equivalent; tracked route-by-route in `docs/blazor-route-parity.md`. ## 2026-07 — Response DTOs live in `ErsatzTV.Core/Api`, file-scoped `#nullable enable` New REST response DTOs go in `ErsatzTV.Core/Api//*ResponseModel.cs` and mirror the shape of the corresponding Application-layer ViewModel — controllers never expose VM types directly. Because `ErsatzTV.Core.csproj` sets `disable` project-wide, any response-model file with an optional member needs its own `#nullable enable` pragma at the top (most already have one). `ErsatzTV.Application` has no nullable context at all — do not add `?` annotations to types living there; that's a Core/Api-layer-only convention. Full detail: `docs/api-conventions.md` §2. ## 2026-07 — PUT-replace list endpoints derive `Index` from array order; alternate-schedules last row = catch-all default For "replace the whole list" endpoints (PUT over a collection — schedule items, template items, etc.), the item's `Index` is derived from its position in the request array, not from a client-supplied index/order field — established by `ReplaceScheduleItemsRequest.ToCommand` (`Items.Select((item, index) => item.ToReplaceCommand(index))`). Separately, `ProgramScheduleAlternate` and `PlayoutTemplate` rows (both `IAlternateScheduleItem`) are evaluated in `Index` order, first-match-wins; the convention is to place the least-conditional (or unconditional) row **last** so it acts as the catch-all default. Established by the alternate-schedules work (PR #179, `AlternateScheduleSelector.cs`). ## 2026-07 — Templates editor in the SPA is a table, not Blazor's drag-calendar The legacy Blazor `TemplateEditor.razor` used a drag-and-drop day-grid calendar UI. The SPA equivalent (`/app/templates/{id}`, PR #173) renders the same day/block assignment as a table instead. This is an accepted, deliberate parity deviation — don't "fix" it to match Blazor's interaction model without discussing it first. ## 2026-07-07 — API artwork contract: rooted URLs produced server-side API response DTOs return artwork as rooted, directly-usable URLs (`/artwork/posters/...`, `/artwork/thumbnails/...`, `/artwork/fanart/...`), plus passthrough for absolute `http(s)://` URLs and Jellyfin/Emby proxy variants. Established by PR #181 (`ErsatzTV.Application/LibraryBrowse/Queries/GetLibraryBrowseItemsHandler.cs`, private `Artwork(...)` helper — comment: *"Returns a rooted, directly-usable artwork URL for the SPA's ``... the SPA [needs it pre-rooted]"*), then generalized into the reusable `ApiArtwork` helper (`ErsatzTV.Core/Api/ApiArtwork.cs`, PR #183). Root cause: the SPA has no ``, unlike Blazor, so relative artwork paths that worked for Blazor pages 404 in the SPA. Do **not** reuse the Application-layer Mappers used by Blazor (e.g. `MediaCards`/`Television` mappers) for new API DTOs — those still return old Blazor-convention relative paths; map from the domain/VM directly and root the path via `ApiArtwork`. ## 2026-07-07 — Decode-style endpoints take a row id and look up server-side Endpoints that decode/expand opaque stored state accept a database row id and resolve server-side, rather than accepting client-supplied serialized state to decode. Established by `GET /api/playouts/history/{id}` (`PlayoutController.GetHistoryDetails`, PR #182) — the row's raw JSON (`Key`/`Details`) is decoded server-side into `PlayoutHistoryDetailsResponseModel`, the client never round-trips the raw payload itself. ## 2026-07-07 — Season/episode/music-video drill-in via `parentId`, not new child-listing endpoints Rather than adding dedicated child-listing endpoints per media kind (e.g. "list episodes of a season"), the library-browse endpoint takes an optional `parentId` query param and the SPA drills in by re-querying with it. Established across PRs #181/#183 (library-picker season drill-in, then media-detail's season/episode/artist/music-video browsing). Avoids a combinatorial explosion of per-kind child endpoints. ## 2026-07-07 — Convention docs read at session start, updated in-PR `docs/api-conventions.md`, `docs/spa-conventions.md`, `docs/e2e-local.md`, `docs/blazor-route-parity.md`, `docs/domain-model.md`, `docs/decisions.md`, and `docs/README.md` are the standing reference set every ChicoryTV session should read before starting work, and each one carries an explicit "update this doc in the same PR" rule rather than deferring doc updates to a follow-up. These docs **replace per-session recon** — an agent reads the index (`docs/README.md`) and the relevant convention doc instead of re-deriving conventions from the code each time it starts API/SPA/E2E/parity work. A testing map and a generated-endpoint index are tracked as still-to-come under #185. Drafting this doc set also surfaced a drift in `ApiControllerSecurityTests.cs`'s hardcoded controller registry (several controllers under `ErsatzTV/Controllers/Api/` are missing from it — see `docs/api-conventions.md` §6) — tracked as a follow-up under #184 rather than fixed inline, since it's a pre-existing gap, not something this doc-drafting pass caused. ## 2026-07-09 — Playback-troubleshooting completion feedback: poll status, no push channel The SPA playback-troubleshooting screen (`PlaybackTroubleshootingScreen.tsx`, #145) reports FFmpeg completion by **polling `GET /api/troubleshoot/playback/status` every ~2s** while a session is running (plus one poll on mount so a session started elsewhere still gates Play), rather than a server push. The status endpoint returns `{ state, exitCode, speed, logs }`; the screen captures the running→completed/failed transition in local component state and surfaces a completion notice (success on exit 0, warning otherwise) — the SPA equivalent of the Blazor page's MediatR `ICourier`/`ISnackbar` `PlaybackTroubleshootingCompletedNotification`. Chosen over SignalR/SSE because the SPA has **no push channel** and troubleshooting sessions are short and user-initiated, so a lightweight poll (started on Play, stopped on settle/unmount) is simpler than standing up a new real-time transport. Speed thresholds and the "(Speed: Nx)" badge colors are copied verbatim from the Blazor `GetSpeedClass` (red <0.9, green >1.1, amber otherwise). ## 2026-07-09 — datetime-local instead of Chronic natural-language start parsing The channel-mode "Date and Time" input in the SPA playback-troubleshooting screen uses a native ``, a **deliberate deviation** from the Blazor page, which parsed a free-text field with `Chronic.Core.Parser` (natural language like "yesterday at 8pm"). The SPA has no Chronic dependency and a picker is unambiguous; the selected local datetime is sent to `playback.m3u8` as an ISO-8601 `start` param via `new Date(value).toISOString()`, which the controller binds to `DateTimeOffset?` exactly as the Blazor round-trip (`"o"`) format did. ## 2026-07-09 — SPA gates Download Media Sample while a session is active Minor intentional deviation: the SPA playback-troubleshooting screen disables **Download Media Sample** (alongside Download Results) while a troubleshooting session is starting/running; Blazor only gated Download Results. Both downloads compete with the live transcode for I/O and the sample archiver reads the same media file, so gating both during a session is strictly safer and costs nothing (sessions are short). ## 2026-07-09 — OpenAPI spec mirrors the runtime Newtonsoft serializer (#198) The generated OpenAPI document is made to follow the **runtime** JSON contract, not the reverse. Runtime `/api/*` responses are serialized by Newtonsoft via `CustomContractResolver`/`CustomNamingStrategy` (camelCase + a `FFmpegProfileId`→`ffmpegProfileId` special case + `[JsonProperty]` overrides such as `ChannelResponseModel.FFmpegProfile`→`ffmpegProfile`), while `Microsoft.AspNetCore.OpenApi` generates the spec from System.Text.Json metadata, whose camelCase drifted (`fFmpegProfileId`, `fFmpegProfile`). That drift fed the SPA the wrong key. Rather than hand-patch the spec or change the wire format (breaking clients), we added `NewtonsoftSchemaNamingTransformer` — an OpenAPI schema transformer registered on all three documents that renames each schema property through the *same* Newtonsoft contract resolver the runtime uses, so the spec matches the wire format by construction. A contract test (`OpenApiSerializerContractTests`) serializes representative DTOs through the real runtime settings and pins the spec property sets to them. Decision: **the wire format is the source of truth; the spec follows it via the real contract resolver.** This also fixed a latent SPA bug (the channel-list "FFmpeg profile" column read `fFmpegProfile` and always showed "Unassigned"). Issue #198. ## 2026-07-09 — YAML playout validator: paste-textarea instead of a server file path The legacy Blazor YAML playout validator took a **server-side file path** (read directly off the container's filesystem). The SPA's `YamlValidatorScreen.tsx` instead uses a paste `