Merge pull request 'feat(610): split the decision corpus into one YAML-frontmatter file per record' (#619) from feat/610-decisions-one-file-per-record into main
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 33s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 34s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 32s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m23s

This commit was merged in pull request #619.
This commit is contained in:
2026-07-25 18:36:08 +00:00
194 changed files with 7865 additions and 5967 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ docker build -f docker/Dockerfile -t ersatztv:dev .
| Migrate / add / redirect a route (new `web/src/screens/*.tsx`, `LegacyUiRedirects.cs`) | `docs/blazor-route-parity.md` + `docs/domain-model.md` |
| Add / change a `/api/*` endpoint | `docs/api-conventions.md` checklist, then regenerate `v1.json` + `endpoint-index.md` via `./scripts/update-openapi.sh` |
| Change a SPA screen convention | `docs/spa-conventions.md` |
| Establish / reverse a convention or decision | `docs/decisions.md` (lifecycle: add record, relocate predecessor to archive/) + the affected doc |
| Establish / reverse a convention or decision | a new `docs/decisions/records/<area>/<topic>.md` (filename = key; lifecycle: add record, `git mv` predecessor to `archive/<area>/`) + regenerate the catalog + the affected doc |
| Add / remove / retitle a doc | `docs/README.md` index |
The `docs-reminder` CI job flags a screen/route change that skips `blazor-route-parity.md`, but it's a **non-blocking** nudge — the rule is on you, not the check.
+8 -4
View File
@@ -28,7 +28,7 @@ doc below, or that changes which sections a task signal points to.**
| Adding/changing a UI-E2E browser flow | `docs/e2e-local.md` → "UI-E2E harness" + `scripts/e2e-ui.sh` |
| What does a test suite cover | `docs/testing.md` |
| Legacy Blazor route lookup | `docs/blazor-route-parity.md` (historical #91 phase (b) inventory) |
| "Why do we do X this way" / challenging a convention | **Catalog-first**: `docs/decisions/README.md` (active rows) → `docs/decisions.md` + `docs/decisions/*.md` for full rationale. `docs/decisions/archive/` only for "what did the rule used to be." |
| "Why do we do X this way" / challenging a convention | **Catalog-first**: `docs/decisions/README.md` (active rows) → follow the row's link to `docs/decisions/records/<area>/<topic>.md` for full rationale. `docs/decisions/archive/<area>/` only for "what did the rule used to be." |
## Knowledge retrieval (MemPalace + catalog + Gitea)
@@ -72,9 +72,13 @@ bounds, what's mined per issue): `docs/handoffs/chicorytv-issue-queue.md` → "K
- **`docs/blazor-route-parity.md`** — historical record of the completed #91 phase (b) cutover:
the Blazor Server UI is removed and every legacy route now 302-redirects to its SPA equivalent
(or falls through to the catch-all → `/app`). Read it for the full legacy→SPA route inventory.
- **`docs/decisions.md`** + **`docs/decisions/*.md`** — active decision records (lifecycle schema:
key/status/since/supersedes/superseded-by, plus optional `stale-after`/`Sources:` —
ersatztv#603). **Generated active view**: `docs/decisions/README.md`
- **`docs/decisions/records/<area>/<topic>.md`** — one active decision record per file, YAML
frontmatter (`key`/`title`/`status`/`since`/`supersedes`/`superseded-by`, plus optional
`stale-after`/`sources` — ersatztv#603), rationale prose in the body. The **filename is the key**,
so one-active-record-per-key is a filesystem property (ersatztv#610). `docs/decisions.md` and the
topic files remain as the lifecycle-schema narrative plus a "Records formerly in this file" index,
which is what keeps older date-based pointers resolvable. **Generated active view**:
`docs/decisions/README.md`
(catalog / task router) — start there. Superseded/retired records live in
`docs/decisions/archive/` and are read only for history, never for "what is the current rule."
- **`docs/ci-cd.md`** — build/test/release pipeline, versioning, dependency management.
+104 -3859
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
# ersatztv#610 — decision records are filed by their key's AREA, so directory names here come
# from the key vocabulary, not from us. Several collide with the root .gitignore's build-output
# rules: `release/` already did (all 9 records under records/release/ were silently dropped from
# the migration commit, caught only by the body-diff guard), and `bin/`, `obj/`, `build/`,
# `debug/`, `x64/` would do the same the day someone coins such an area.
#
# `!*/` un-ignores every directory under docs/decisions/, which fixes the CLASS rather than the
# one instance. It must negate the DIRECTORY: git never descends into an excluded directory, so
# negating only the files inside would not work.
!*/
+167 -166
View File
@@ -8,169 +8,170 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| Key | Current rule | Since | Record |
| --- | --- | --- | --- |
| `api.artwork-rooted-urls` | API response DTOs return artwork as rooted, directly-usable URLs (plus passthrough for absolute/proxy URLs), never relative Blazor-convention paths. | 2026-07-07 | [link](../decisions.md#2026-07-07--api-artwork-contract-rooted-urls-produced-server-side) |
| `api.async-op-contract` | Queue-triggering `/api/*` endpoints normalize onto one contract — 202 Accepted (queued), 404 (missing entity), 409 (lock held), 422 (domain precondition) — with Trakt as the reference implementation; playout list/detail GETs also carry an `isLocked` observability flag as the HTTP-observable substitute for a live push channel. | 2026-07-11 | [link](../decisions.md#2026-07-11--async-op-api-contract-normalization--playout-build-observability--f9-scan-endpoints-235) |
| `api.channel-health-object` | `ChannelResponseModel`/`ChannelDetailResponseModel` carry a server-derived `health` object (`ChannelHealthResponseModel { Status, Faults[], PlayoutCount, BrokenSourceItemCount }`) computed **read-time** from the built timeline (`Playout.BuildStatus` + upcoming `PlayoutItem → MediaItem.State`, `Finish >= now`), kind-agnostic across all 5 `PlayoutScheduleKind` values; `Status`/`Faults` are const-string classes (`ChannelHealthStatus`, `ChannelFault`), not C# enums, so the SPA hand-maintains the union (mirrors `ChannelPreviewAvailability`). This supersedes #72's "raw fact only, no derived enum, empty-schedule/broken-source deliberately not computed" stance now that the auto-tune taxonomy churn (#383/#384) it was waiting on has landed (see `channel.origin-marker` sibling record, #414). | 2026-07-23 | [link](../decisions.md#2026-07-23--channel-health--a-server-derived-health-object-on-the-channel-dtos-built-timeline-detection-415) |
| `api.channel-preview-capability` | Whether a channel can be previewed in the browser is declared by the server, not derived by the SPA, as an additive `Preview` field (`{Availability, ManifestUrl, UnavailableReason}`) on `ChannelResponseModel`. | 2026-07-21 | [link](../decisions.md#2026-07-21--browser-channel-preview-is-a-server-declared-per-channel-capability-60) |
| `api.decode-by-id` | Endpoints that decode/expand opaque stored state accept a database row id and resolve it server-side rather than round-tripping client-supplied serialized state. | 2026-07-07 | [link](../decisions.md#2026-07-07--decode-style-endpoints-take-a-row-id-and-look-up-server-side) |
| `api.from-lineup-clear-to-none` | `POST /api/v1/channels/from-lineup` (and the Auto-Tune per-channel `advanced`, which reuses the same DTO) distinguishes *inherit* from *clear-to-none* with a typed `clear` enum list on `advanced`. A field left null/omitted still inherits the template value (unchanged for every existing client); naming a field in `clear` forces it to none on the new channel even when the template sets one. Sending both a set value and a clear for the same field is a validation error. | 2026-07-21 | [link](../decisions.md#2026-07-21--from-lineup-advanced-overrides-express-clear-to-none-via-a-typed-clear-enum-list-135) |
| `api.healthcheck-remediation-dto` | Health-check remediation is server-declared `{Kind, Target}` metadata on an additive DTO field; the SPA renders/acts on it, it doesn't derive labels itself. | 2026-07-17 | [link](../decisions.md#2026-07-17--health-check-remediation-is-server-declared-kind-target-on-an-additive-dto-the-spa-acts-on-it-164) |
| `api.healthcheck-ttl-cache` | Health-check results are held in a 30s TTL cache inside `HealthCheckService`; a non-forced `GET /api/v1/health` returns the cached list, and `?refresh=true` (or a forced internal caller) bypasses it to run fresh. | 2026-07-19 | [link](../decisions.md#2026-07-19--health-check-results-are-ttl-cached-refreshtrue-forces-a-fresh-run-431) |
| `api.logs-sort-params` | `GET /api/logs` takes allow-listed `sortField` (`timestamp`\|`level`) and `sortDirection` (`asc`\|`desc`) query params, normalized (not rejected) on an unrecognized value. | 2026-07-11 | [link](../decisions.md#2026-07-11--logs-column-sorting-allow-listed-sortfieldsortdirection-on-get-apilogs) |
| `api.mediatr-passthrough` | The REST API is thin controllers over existing MediatR handlers, with no new service/business-logic layer. | 2026-06 | [link](../decisions.md#2026-06--rest-api-wraps-existing-mediatr-handlers-11-no-service-layer) |
| `api.openapi-mirrors-runtime` | The generated OpenAPI spec is made to match the runtime Newtonsoft wire contract (via `NewtonsoftSchemaNamingTransformer`), not the reverse. | 2026-07-09 | [link](../decisions.md#2026-07-09--openapi-spec-mirrors-the-runtime-newtonsoft-serializer-198) |
| `api.parentid-drillin` | Media drill-in (season/episode/artist/music-video) is served by an optional `parentId` query param on library-browse, not dedicated per-kind child-listing endpoints. | 2026-07-07 | [link](../decisions.md#2026-07-07--seasonepisodemusic-video-drill-in-via-parentid-not-new-child-listing-endpoints) |
| `api.playout-build-lock-409` | Every id-keyed playout/channel mutation endpoint checks `IEntityLocker.IsPlayoutLocked(id)` and returns 409 Conflict while a build is in-flight, mirroring Blazor's disabled-buttons behavior; reset-all stays 202 and silently skips locked playouts. | 2026-07-10 | [link](../decisions.md#2026-07-10--playout-api-mutations-return-409-while-the-build-lock-is-held-215) |
| `api.postcommit-cancellation-none` | Once a mutation commits, its entire compensating side effect (enqueues, publishes, reindexes, cache refresh, and any post-commit lookup gating one of those) runs on `CancellationToken.None` so a late client disconnect can't half-abort an already-committed change. | 2026-07-11 | [link](../decisions.md#2026-07-11--post-commit-side-effects-run-on-cancellationtokennone-generalized-from-251-to-254) |
| `api.put-replace-index-order` | PUT-replace-the-whole-list endpoints derive each item's `Index` from its request-array position, never a client-supplied field; alternate-schedule/playout-template rows are evaluated in `Index` order with the least-conditional row placed last as the catch-all default. | 2026-07 | [link](../decisions.md#2026-07--put-replace-list-endpoints-derive-index-from-array-order-alternate-schedules-last-row--catch-all-default) |
| `api.response-dtos` | New REST response DTOs live in `ErsatzTV.Core/Api/<Domain>/*ResponseModel.cs` with a file-scoped `#nullable enable` pragma; controllers never expose Application VM types directly. | 2026-07 | [link](../decisions.md#2026-07--response-dtos-live-in-ersatztvcoreapi-file-scoped-nullable-enable) |
| `api.schedule-item-flat-dto` | Schedule-item GET/POST/PUT use a flat, non-polymorphic `ScheduleItemResponseModel` (every subtype field promoted to a nullable top-level member) instead of the polymorphic Application VM hierarchy, with mutation field names matching `ScheduleItemRequest` 1:1 for a lossless round-trip. | 2026-07-10 | [link](../decisions.md#2026-07-10--schedule-item-get-returns-a-flat-non-polymorphic-dto-scheduleitemresponsemodel) |
| `api.scheduling-hardening` | Create/Replace handlers guard against null/whitespace `name` (`IsNullOrWhiteSpace`, not just `Length`) to prevent NRE-500s, template-item overlap validation compares by index (not record value-equality) to catch exact-duplicate items, and unreachable 404 `ProducesResponseType` attributes on create-only actions are trimmed. | 2026-07-13 | [link](../decisions.md#2026-07-13--scheduling-api-hardening-null-name-500s-duplicate-template-items-unreachable-404-172) |
| `api.search-allitems-paging` | `GET /api/v1/search/all-items` is paginated (capped page size, `Totals` field) to bound DoS exposure; the SPA add-all flow pages to completeness instead of relying on an unbounded response. | 2026-07-18 | [link](../decisions.md#2026-07-18--search-all-items-is-paged-to-cap-dos-exposure-spa-add-all-pages-to-completeness-293) |
| `api.search-field-values` | `GET /api/v1/search/fields/{name}/values?q=&limit=` returns distinct WHOLE values from the database for one of a narrow allow-list of catalog fields (not the Lucene term dictionary — analyzed `TextField`s store lowercased word tokens, e.g. "Science Fiction" → `science`/`fiction`, useless as a typeahead suggestion), 404 for an unknown field, a non-`text` field, or a `text` field with no distinct-value source; case-insensitive prefix-filtered on `q`, `limit` clamped to `[1, 50]` (default 50). | 2026-07-23 | [link](../decisions.md#2026-07-23--facet-value-typeahead-is-a-new-endpoint-allow-listed-to-text-fields-no-caching-434) |
| `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](../decisions.md#2026-07-11--trash-see-all-reuses-library-browse-paging-search-stays-capped-per-kind-213) |
| `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](../decisions.md#2026-07-13--api-versioning-the-whole-api-surface-is-mounted-at-apiv1-additive-only-after-freeze-286) |
| `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](../decisions.md#2026-07-11--pre-removal-blazor-rollback-tag-blazor-final-205) |
| `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](../decisions.md#2026-07-11--blazor-server-ui-removed-91-phase-b) |
| `channel.origin-marker` | A new `Channel.Origin` (`ChannelOrigin` enum — `Unknown`/`UserCreated`/`AutoTuned`) records how a channel row was created and is stamped exactly once at insert (`AutoTuned` in `CreateChannelFromLineupHandler`, `UserCreated` in `CreateChannelHandler`), and is never mutated on a later edit. It is surfaced as a raw `origin` field on `ChannelResponseModel`; the SPA badges only `AutoTuned`. Rows predating the column read `Unknown` — provenance is **not** back-filled. | 2026-07-23 | [link](../decisions.md#2026-07-23--channel-origin-is-immutable-creation-provenance-stamped-at-insert-not-a-health-signal-414) |
| `ci.batch-pushes-no-cancel-route` | Hold review fixes, doc corrections and format fixes locally and push **once** — a superseded run cannot be cancelled from the agent side and holds a runner slot until it finishes. | 2026-07-21 | [link](workflow-process.md#2026-07-21--batch-your-pushes-there-is-no-agent-side-cancel-route-on-gitea-1254-542) |
| `ci.build-once-rejected` | CI build-once (a shared compile artifact across jobs) was implemented, measured, and rejected for a 40-85% wall-clock regression; keep the #420 cross-run tree-identity skip instead. | 2026-07-18 | [link](../decisions.md#2026-07-18--ci-build-once-was-measured-and-rejected-keep-the-420-tree-skip) |
| `ci.cancelled-is-not-a-verdict` | Treat a `cancelled` conclusion as "no verdict" — never as pass or fail — and report FAILED and CANCELLED counts separately in any CI monitor. | 2026-07-21 | [link](workflow-process.md#2026-07-21--cancelled-is-not-failure-a-cancelled-run-is-no-verdict-542) |
| `ci.decisions-edit-trailer` | The body-diff exemption is armed by an affirmative `Decisions-Edit:` **git trailer** (`yes`/`true`/`1`, case-insensitive, read with `unfold`) on some NON-MERGE commit in the PR's merge-base range — never by a substring search over the message text. A non-affirmative value (`no`) does not arm it, the retired `[decisions-edit]` substring arms nothing (the validator emits a `::warning::` nudge when it sees one without a trailer), and a git error leaves the guard ON. | 2026-07-25 | [link](../decisions.md#2026-07-25--the-rationale-edit-marker-is-a-git-trailer-not-a-substring-anywhere-in-the-commit-range-609) |
| `ci.decisions-lifecycle-flake` | When `decisions lifecycle` is the **only** red job, do not investigate and do not create a new run to clear it — no rebase, no `--amend`, no no-op push; the operator reruns that single job from the Gitea UI. | 2026-07-21 | [link](workflow-process.md#2026-07-21--a-lone-decisions-lifecycle-red-is-a-known-infra-flake-do-nothing-542) |
| `ci.docs-only-detect-shallow-safe` | The docs-only detect script must diff against `FETCH_HEAD` (always resolves after `git fetch`, even shallow) using a two-dot tree diff — not `origin/<base>` with three-dot — because a `fetch-depth: 1` shallow clone has no remote-tracking ref and no merge-base, which silently fails the original detect into `docs_only=false` (full matrix, no functional error). A CI-behavior change must be verified by measuring the effect (job durations), not just a green check. | 2026-07-17 | [link](../decisions.md#2026-07-17--docs-only-detect-must-be-shallow-checkout-safe-fetch_head--two-dot-not-originmain--three-dot-416-follow-up) |
| `ci.docs-only-skip-steps` | A docs-only change must still run every required job (`test`, `migrations`) so their commit-status contexts always report; each heavy job runs `scripts/ci-detect-docs-only.sh` first and gates its real STEPS on `if: steps.detect.outputs.docs_only != 'true'`, never `if:`-skips the whole job (an `if:`-skipped job reports `skipped`, not `success`, which branch protection may never unblock on). Detection biases toward running more on any doubt. | 2026-07-17 | [link](../decisions.md#2026-07-17--docs-only-ci-skip-gates-steps-in-always-running-required-jobs-never-if-skips-them-416) |
| `ci.format-gate-folder-mode` | The blocking `format` CI job (and matching pre-commit hook) runs `dotnet format whitespace . --folder --include <files>` instead of loading the full MSBuild/Roslyn solution, cutting the gate from ~480s to ~0.5s with unchanged whitespace/charset coverage. | 2026-07-19 | [link](../decisions.md#2026-07-19--the-format-gate-runs-dotnet-format-whitespace----folder-not-the-full-solution-format-469) |
| `ci.functional-e2e-harness` | The `functional-e2e` CI job boots the PR's own code from source via `dotnet run` (`scripts/e2e-local.sh`) and runs deterministic assertions (`scripts/e2e-functional.sh`) as an advisory (non-blocking) job, not a `build` dependency or required check. Originally curl-only; since #445 the same job carries a second, headless-browser step for the contracts curl cannot express — see `ci.ui-e2e-harness`. | 2026-07-16 | [link](../decisions.md#2026-07-16--functional-e2e-ci-harness-advisory-curl-contract-job-over-an-app-booted-from-source-299) |
| `ci.gitea-milestone-filter-noop` | Never filter issues with the server-side `?milestones=<name>` parameter — fetch all open issues once and filter LOCALLY on each issue's `.milestone.title`. | 2026-07-21 | [link](workflow-process.md#2026-07-21--giteas-milestones-issue-filter-silently-no-ops-on-names-containing--or--542) |
| `ci.infra-shaped-red-under-load` | When a job dies inside a setup/cache step before your code compiles, check the runner host's load before diagnosing the diff, and never file a CI bug off one sample under pressure. | 2026-07-21 | [link](workflow-process.md#2026-07-21--an-infra-shaped-red-under-host-load-is-not-a-code-failure-542) |
| `ci.killed-job-triage` | Never trust a job's `conclusion` field alone — read the log tail and require an `❌ Failure - Main …` marker before treating a red as a real failure. | 2026-07-21 | [link](workflow-process.md#2026-07-21--a-killed-ci-job-reports-conclusion-failure-read-the-log-tail-before-diagnosing-the-diff-542) |
| `ci.monitor-armed-at-pr-open` | Arm a CI monitor on the PR head sha the moment the PR opens, polling the commit-status endpoint — not at the end of the work. | 2026-07-21 | [link](workflow-process.md#2026-07-21--arm-the-ci-monitor-at-pr-open-via-the-commit-status-endpoint-542) |
| `ci.no-host-health-gating` | Push when your work is validated — never SSH to bumblebee to sample load/RAM first, and never hand-schedule around other sessions' runs. | 2026-07-21 | [link](workflow-process.md#2026-07-21--do-not-gate-or-throttle-pushes-on-host-health-542) |
| `ci.peak-anon-measurement` | The `test` job's headline memory figure is a sampled high-water mark of cgroup `anon`, produced by `scripts/ci-peak-anon.sh`; `memory.peak` and the end-of-job `anon`/`file` split are kept only as a cache-inflated reference. | 2026-07-19 | [link](../decisions.md#2026-07-19--ci-test-job-reports-a-sampled-true-peak-anon-not-cache-inflated-memorypeak-412) |
| `ci.root-screenshot-guard` | The Husky `pre-commit` hook refuses a staged root-level `*.png` (belt-and-suspenders with the `.gitignore` rule); nested `*.png` real assets are unaffected. | 2026-07-12 | [link](release-ci-governance.md#2026-07-12--root-screenshot-guard-pre-commit-refuses-root-level-png-303-h3) |
| `ci.runner-placement` | No persistent Roslyn compiler server survives a CI build (`UseSharedCompilation=false` etc., runner env + Dockerfile `ENV`); every `services:` container gets its own explicit `--memory`/`--memory-swap`/`--cpus` cap (it does not inherit the job container's). | 2026-07-17 | [link](../decisions.md#2026-07-17--no-persistent-compiler-servers-in-ci-every-services-container-gets-an-explicit-cap-390s-small-lane-move-reversed-406) |
| `ci.small-lane-git-only` | `runs-on: small` is defined by what a job does (git-only), not its usual runtime; the two `docker build` jobs (docker-build.yml, ci-image.yml) move to `ubuntu-latest` because their worst-case memory, not median runtime, was pinning the small lane's per-slot cap. | 2026-07-20 | [link](../decisions.md#2026-07-20--runs-on-small-means-git-only-the-two-docker-build-jobs-move-to-ubuntu-latest-server-management639) |
| `ci.ui-e2e-harness` | The UI-interactive E2E flows run as headless Playwright specs (`web/e2e/*.spec.ts`, driven by `scripts/e2e-ui.sh`) in a **second step of the existing advisory `functional-e2e` job**, never their own job; the browser is `chromium-headless-shell` **baked into the CI toolchain image** (`docker/ci/Dockerfile`, `PLAYWRIGHT_VERSION` kept equal to `web/package.json`'s EXACT `@playwright/test` pin), never installed per run; specs are `serial` with `retries: 0` and assert only contracts the curl harness structurally cannot reach. | 2026-07-25 | [link](../decisions.md#2026-07-25--ui-e2e-headless-playwright-flows-in-the-existing-functional-e2e-job-browser-baked-into-the-ci-image-445) |
| `ci.verify-locally-ci-confirms` | Treat the local build/verify/review pass as the decision point and CI as confirmation — don't idle waiting on a run you have no reason to doubt. | 2026-07-21 | [link](workflow-process.md#2026-07-21--build-and-verify-locally-then-trust-it-ci-confirms-542) |
| `ci.web-test-per-test-timeouts` | Give heavy-render web tests an explicit per-test vitest timeout (e.g. 15s); never raise the global default to fix one slow test. | 2026-07-21 | [link](workflow-process.md#2026-07-21--heavy-render-web-tests-need-explicit-per-test-vitest-timeouts-on-the-ci-vm-542) |
| `concurrency.diff-scalar-fanout` | The frozen Block optimistic-concurrency recipe (api-conventions §7a) fans out to Collection/Playout×2/MultiCollection/RerunCollection, keeping a guard-returned `PreconditionFailedError` out of any handler's generic `catch(Exception)`→422 mapping, and preserving each aggregate's existing `SaveChangesAsync() > 0` gate semantics under the new unconditional `Version++`. | 2026-07-11 | [link](optimistic-concurrency.md#2026-07-11--253-pr3-diff--scalar-concurrency-fan-out-collection--playout2--multicollection--reruncollection) |
| `concurrency.etag-rotation-completion` | Every handler that mutates a versioned root's editor-visible config state must bump `Version` (rotating the ETag) with no per-aggregate carve-outs, short-circuiting on a genuine no-op before the bump so idempotent re-submits don't fire spurious rebuild fan-out; `SaveChangesForcingVersion` rebases the retry (stored + pending delta), never adopts the stored token verbatim. | 2026-07-12 | [link](optimistic-concurrency.md#2026-07-12--cross-editor-etag-rotation-completed-for-collectionplayout-config-siblings-269) |
| `concurrency.force-write-non-ifmatch` | Any handler that leaves a versioned root `Modified` or `Deleted` but takes no `If-Match` (deletes, item add/remove bumpers, scalar-config writers) must save through `ConcurrencyExtensions.SaveChangesForcingVersion` — force-write past a concurrent `Version` bump rather than throw an unhandled `DbUpdateConcurrencyException` (500). | 2026-07-12 | [link](optimistic-concurrency.md#2026-07-12-269--non-if-match-root-writers-force-write-past-a-concurrent-version-bump) |
| `concurrency.idempotent-concurrent-add` | A concurrent duplicate `Add*ToCollection` that loses the race on the composite-key unique constraint is treated as an idempotent no-op (skip the reindex/rebuild fan-out), not a 500 — detected via a provider-specific `TvContext.IsUniqueConstraintViolation` delegate defaulting to "no". | 2026-07-18 | [link](optimistic-concurrency.md#2026-07-18--concurrent-same-item-add-is-idempotent-not-a-500-catch-the-unique-violation-per-provider-308) |
| `concurrency.ifmatch-rfc7232` | `ConcurrencyHeaders.ParseIfMatch` is a real RFC 7232 entity-tag/list parser: a syntactically-valid tag that doesn't strong-match (weak/empty/non-canonical/out-of-range/list) returns 412, and only a genuine grammar violation returns 400. | 2026-07-12 | [link](optimistic-concurrency.md#2026-07-12--if-match-evaluates-per-rfc-7232-valid-but-non-matching--412-only-grammar-violations--400-265) |
| `concurrency.replace-all-contract` | Replace-all aggregate PUTs carry a uniform plain `int Version` concurrency token (EF `.IsConcurrencyToken()`), checked pre-save and enforced by the EF UPDATE guard, returning 412 (not 409) on a stale `If-Match`. | 2026-07-11 | [link](optimistic-concurrency.md#2026-07-11--optimistic-concurrency-contract-for-replace-all-puts-253-pr1-infra--block-reference) |
| `concurrency.schedule-item-child-identity` | `PUT /api/schedules/{id}/items` reconciles by an optional round-tripped child `Id` (null/absent/0 ⇒ new item), never by array position, so fill-group/shuffle state follows the logical item across reorders; an unknown or duplicate id is rejected 422 (checked after the §7a `CheckVersion`, so 412 precedes 422). | 2026-07-11 | [link](optimistic-concurrency.md#2026-07-11--stable-child-identity-for-schedule-item-replace-259-split-from-252253) |
| `docs.convention-docs-session-start` | Docs-first, not source-first: conventions (api-conventions, spa-conventions, e2e-local, blazor-route-parity, domain-model, decisions, README) are read from docs, not reverse-engineered from code, via `docs/README.md`'s task-signal map — only the sections it points to for the task at hand, not the whole set. Each doc is updated in the same PR that changes what it documents, replacing deferred/follow-up doc updates. | 2026-07-07 | [link](../decisions.md#2026-07-07--convention-docs-read-at-session-start-updated-in-pr) |
| `docs.decision-lifecycle` | every decision `##` record (active or archived) carries a 5-field metadata block (`key`, `status`, `since`, `supersedes`, `superseded-by`) checked by `scripts/decisions_validate.py`; a record is never deleted or line-edited to reverse a call — it is moved to `docs/decisions/archive/` with `status: superseded`/`retired` and a reciprocal `superseded-by`/`supersedes` key pair to its replacement. | 2026-07-21 | [link](../decisions.md#2026-07-21--decision-records-carry-a-lifecycle-schema-validated-by-a-script-append-only-by-diff-is-retired-521) |
| `docs.decision-optional-provenance` | Decision records gain two OPTIONAL fields — `stale-after: YYYY-MM-DD` on the metadata line and a `**Sources:**` line in the metadata block; the Open Knowledge Format (OKF) itself is NOT adopted as the record format. | 2026-07-25 | [link](../decisions.md#2026-07-25--okf-evaluated-and-rejected-as-a-replacement-two-of-its-optional-fields-adopted-603) |
| `docs.tracker-comment-retrofit` | When the knowledge exporter flags an over-cap tracker issue and excludes it from ingestion, triage its comments instead of assuming a retrofit is owed — and for each decision-shaped item check the **worked issue first**, because a tracker session comment is by construction a précis of the fuller closing record posted on the issue it narrates. Applied to #237 (111 comments) this yielded **zero** records, so server-management#642's "a fact found only in a #237 comment" retrieval row has no valid subject and its interim target (an already-migrated record) is permanent. | 2026-07-21 | [link](../decisions.md#2026-07-21--check-the-worked-issue-before-the-decision-corpus-a-closed-trackers-comments-need-no-retrofit-524) |
| `ffmpeg.external-logo-graphics-engine` | External-URL channel logos pass through to the graphics engine like any other watermark source; `WatermarkSelector` must never gate them on `File.Exists` (always false for a URL) and never route them through the ffmpeg-native overlay shortcut. | 2026-07-20 | [link](../decisions.md#2026-07-20--external-url-channel-logos-pass-through-to-the-graphics-engine-never-fileexists-gated-never-ffmpeg-native-502) |
| `ffmpeg.hls-cold-start-burst` | HLS cold-start latency is fixed with a bounded `-readrate_initial_burst` (gated on FFmpeg ≥6.1 capability detection), not by raising `work_ahead_limit`, which would remove the concurrency guarantee it exists for. | 2026-07-20 | [link](../decisions.md#2026-07-20--hls-cold-start-is-fixed-with--readrate_initial_burst-not-by-raising-the-work-ahead-limit-350) |
| `ffmpeg.qsv-decode-encode-split` | QSV decode is decoupled from QSV encode via a single `FFmpegProfile.QsvPreferNativeDecoder` bool (default ON, Linux-only), so a QSV encode profile can decode with the more tolerant native VA-API decoder instead of the QSV decoder, mirroring Jellyfin's hybrid decode/encode toggle instead of a general decode-family enum. | 2026-07-20 | [link](../decisions.md#2026-07-20-498--qsv-decode-is-split-from-qsv-encode-via-a-single-qsvprefernativedecoder-bool) |
| `ffmpeg.qsv-extra-hw-frames-floor` | a QSV upload never emits `extra_hw_frames` below `FFmpegState.MinimumQsvExtraHardwareFrames` (64); a stored `0` or negative value is treated as "no pool configured" rather than honored literally, because with no headroom any unthrottled read exhausts the pool and the transcode writes nothing at all. | 2026-07-21 | [link](../decisions.md#2026-07-21--qsv-hardware-frame-headroom-is-a-floor-not-an-operator-preference-529) |
| `ffmpeg.remote-image-fetcher-bounded` | remote graphics-engine images are fetched through `IRemoteImageFetcher` with a pooled `HttpClientFactory` client, a body-covering deadline, a wire-transfer size cap, and a decoder-enforced `DecoderOptions.MaxFrames` bound re-verified post-decode — never cached, re-fetched per element init. | 2026-07-20 | [link](../decisions.md#2026-07-20--remote-graphics-engine-images-are-fetched-through-a-bounded-pooled-iremoteimagefetcher-re-fetched-per-element-init-not-cached-511) |
| `ffmpeg.work-ahead-slot-atomic` | `workAheadSegmenterLimit` is enforced by a single compare-exchange claim on a shared `WorkAheadSlots` pool taken by the *caller* of `Transcode`, which then passes ownership in and gets the release in `Transcode`'s `finally` — never a `Volatile.Read` compare in one place and an `Interlocked.Increment` in another. | 2026-07-21 | [link](../decisions.md#2026-07-21--work-ahead-slots-are-claimed-atomically-by-the-caller-released-by-the-transcode-it-hands-them-to-536) |
| `ffmpeg.work-ahead-slot-release-never-negative` | `Release()` reads the count and compare-exchanges `current - 1` only when `current > 0`; a release against an empty pool records an unbalanced release and returns `false` **without ever writing a negative value**. It never decrements first and clamps afterward. The single caller (`HlsSessionWorker.Transcode`'s `finally`) logs a warning on the `false` return. | 2026-07-21 | [link](../decisions.md#2026-07-21--workaheadslotsrelease-clamps-before-decrementing-and-reports-unbalance-in-band-539) |
| `graphics.channel-level-attachment` | A channel can attach `GraphicsElement`s directly via a new `ChannelGraphicsElement` join table (a base layer under deco/playout-item elements), and a built-in text element (`on-now-next.yml`) is seeded once per database so the On Now/Next overlay works out of the box. | 2026-07-22 | [link](../decisions.md#2026-07-22--channel-level-graphics-element-attachment--seeded-on-nownext-text-element-74) |
| `graphics.channel-logo-caching` | An external `http(s)` channel-logo URL is fetched, decode-budget-validated, and stored in the image cache under a content-hash name at SAVE time — becoming byte-identical to an uploaded logo — so the render path never fetches a logo over HTTP; a bad URL fails the save with a 422 (BaseError → ValidationProblemDetails). | 2026-07-21 | [link](../decisions.md#2026-07-21--external-channel-logo-urls-are-downloaded-and-cached-at-save-time-the-render-path-never-fetches-a-logo-525) |
| `iptv.base-url` | An optional advertised base URL (`iptv.base_url`) is resolved centrally via a pure Core helper (`AdvertisedBaseUrl`) inside the two IPTV generation handlers (M3U + XMLTV); unset/malformed values fall back byte-identical to the request-derived host, and it's a new `iptv` settings group distinct from `ETV_BASE_URL` and out of scope for HDHomeRun. | 2026-07-16 | [link](../decisions.md#2026-07-16--optional-advertised-iptv-base-url-iptvbase_url-resolved-centrally-in-the-two-generators-340) |
| `iptv.logo-drives-bug-preset` | One uploaded channel logo drives both the listing logo and the on-screen bug via a shared, seeded `ChannelLogo`-sourced watermark preset (`Channel Bug`), not new per-channel schema. | 2026-07-20 | [link](../decisions.md#2026-07-20--one-logo-drives-the-bug-via-a-shared-channellogo-preset-not-new-schema-67) |
| `locking.entitylocker-atomic-flags` | `EntityLocker` uses `Interlocked.CompareExchange`-guarded atomic flags plus a documented single-owner-release discipline (no owner tokens/leases); `Unlock*` on an already-unlocked slot returns `false` and logs a Warning rather than throwing. | 2026-07-11 | [link](../decisions.md#2026-07-11--entitylocker-atomic-flags--single-owner-release-discipline-no-owner-tokens-231) |
| `mcp.server-foundation` | `ErsatzTV.Mcp` is a fresh stdio JSON-RPC server wrapping frozen `/api/v1` with explicit narrow per-endpoint tools, read-only-by-default enforced at runtime (`ERSATZTV_ALLOW_WRITES`), machine-key auth, and opt-in `If-Match`. | 2026-07-20 | [link](../decisions.md#2026-07-20--mcp-server-ersatztvmcp-built-fresh-over-frozen-apiv1-read--cautious-writes-58) |
| `media.lastscan-null-boundary` | A never-scanned `LastScan` surfaces as `null` at the API/MCP boundary, not the `0001-01-01` MinValue sentinel — enforced by an ongoing read-boundary coercion plus a one-time data migration cleanup. | 2026-07-18 | [link](../decisions.md#2026-07-18--never-scanned-lastscan-surfaces-as-null-at-the-api-boundary-not-the-0001-01-01-minvalue-sentinel-409) |
| `media.remote-stream-probe` | `ValidatePlayoutItemPath` probes the Plex/Jellyfin/Emby remote-stream URL via `IRemoteStreamProber` before returning it; only a redirected 404 fails closed (`PlayoutItemNotAvailableFromMediaServer`), everything else fails open, and there is no toggle. | 2026-07-19 | [link](../decisions.md#2026-07-19--media-server-remote-stream-urls-are-probed-before-use-a-redirected-404-fails-closed-everything-else-fails-open-no-toggle-473) |
| `media.remote-stream-probe-externaljson` | External-JSON playout channels' `StreamRemotely` now probes the remote-stream URL through the same `IRemoteStreamProber` seam as the generated-playout path, closing the #473 scope gap for a channel kind with no DB `PlayoutItem` rows. | 2026-07-20 | [link](../decisions.md#2026-07-20--external-json-playout-channels-now-probe-the-remote-stream-url-too-closing-the-473-scope-gap-480) |
| `media.source-mgmt-write-api` | Media-source management (local/Plex/Jellyfin/Emby) is a REST write API + SPA under `/app/libraries/*`, wrapping existing MediatR commands 1:1 with no new commands or DB migration; connection GETs never leak a stored `apiKey`, and each PUT-replace family's identity contract is documented per-family (not assumed uniform). | 2026-07-11 | [link](../decisions.md#2026-07-11--media-source-management-rest-write-api--spa-202) |
| `process.bom-format-detection-recipe` | Before any push touching `.cs`, detect BOMs with the `xxd` byte check and verify the format gate with `dotnet format --include` run under `bash -c`, never bare zsh. | 2026-07-21 | [link](workflow-process.md#2026-07-21--bomformat-pre-push-detection-use-the-xxd-loop-and-run-dotnet-format---include-under-bash--c-542) |
| `process.branch-off-feature-branch` | To fix work on an unmerged feature branch, branch off that branch and land by fast-forward push — and after creating a worktree, drive the first Edit/Read from ITS absolute paths and `git status` it before building. | 2026-07-21 | [link](workflow-process.md#2026-07-21--fix-onto-an-unmerged-feature-branch-by-branching-off-it-and-ff-pushing-542) |
| `process.build-concurrency-limits` | Run at most 3–4 concurrent dotnet/npm builds on this Mac, gate launches on FREE RAM rather than CPU load, and never set `ETV_UPDATE_GOLDENS` / `ETV_UPDATE_PLAYOUT_GOLDENS`. | 2026-07-21 | [link](workflow-process.md#2026-07-21--bound-parallel-builds-by-free-ram-never-regenerate-goldens-542) |
| `process.codex-cheap-worker-launch` | For bounded tool-bearing selector/recon work, launch a Codex worker with `codex exec -m gpt-5.4-mini -c model_reasoning_effort=low -s read-only`; `spawn_agent` buys parallelism but no cost savings. | 2026-07-21 | [link](workflow-process.md#2026-07-21--launch-codex-cheap-workers-via-codex-exec-not-spawn_agent-542) |
| `process.consistency-fix-new-code-scrutiny` | Review a "make X consistent with Y" change as new code, not as a mechanical copy — and for any timer or effect involved, ask explicitly "when does this fire?", including on mount. | 2026-07-21 | [link](workflow-process.md#2026-07-21--make-x-consistent-with-y-review-findings-are-new-code-and-get-new-code-scrutiny-542) |
| `process.enumerate-workaround-behaviors-before-deleting` | When an issue says "delete X", enumerate every behavior X provided before removing it — a workaround often serves a second purpose that outlives the first. | 2026-07-21 | [link](workflow-process.md#2026-07-21--before-deleting-a-workaround-enumerate-every-behavior-it-provided-542) |
| `process.foreign-worktree-plumbing-merge` | Never commit or merge inside a worktree another session created; land the merge with git plumbing against the branch ref instead. | 2026-07-21 | [link](workflow-process.md#2026-07-21--never-commit-or-merge-inside-a-worktree-you-did-not-create-542) |
| `process.harden-with-runtime-posture-not-clamp` | When a security fix constrains a capability the roadmap will later want, make the safe state the DEFAULT OF A SWITCH rather than a wall — and read the feature's own issue for its end-state first. | 2026-07-21 | [link](workflow-process.md#2026-07-21--harden-a-soon-to-grow-feature-with-a-runtime-posture-not-a-hardcoded-clamp-542) |
| `process.independent-review-rubric` | Run an independent review pass — preferably a different model family, otherwise a cold-context review-only agent — on any diff touching locks/concurrency, auth/security, API write-path handlers, or DB migrations, or larger than ~150 changed C# lines; skip only for a pure-SPA/docs leaf with no server-state effect, and state the skip and its reason in the PR or close comment. | 2026-07-21 | [link](workflow-process.md#2026-07-21--independent-cross-model-review-is-mandatory-on-risky-diffs-a-skip-is-a-stated-auditable-exemption-542) |
| `process.issue-qualification-audit` | Run `scripts/issue-qualification-audit.sh` at session end and label everything it flags, including issues you filed that session. | 2026-07-21 | [link](workflow-process.md#2026-07-21--every-open-issue-carries-a-priority-label--run-the-h12-audit-at-session-end-542) |
| `process.local-gate-before-push` | Run the local build/test gate and a cold-context, scoped "review only" adversarial review over the diff, fold the fixes, and only then push or open the PR. | 2026-07-21 | [link](workflow-process.md#2026-07-21--local-buildtest-gate--cold-context-review-run-before-the-push-not-after-542) |
| `process.lock-ownership-enumerate-producers` | Before trusting any "single owner / no double release / no cross-release" claim, grep the whole host project for every writer of that channel message (or acquirer of that lock) — the background scheduler/worker is the usual missing producer. | 2026-07-21 | [link](workflow-process.md#2026-07-21--a-lockchannel-no-cross-release-verdict-must-enumerate-every-producer-via-grep-542) |
| `process.one-worktree-one-committing-agent` | Never run two committing agents concurrently on one worktree — give each parallel slice its own worktree branched off the feature branch and merge back. | 2026-07-21 | [link](workflow-process.md#2026-07-21--one-worktree-one-committing-agent-542) |
| `process.parallel-session-claim` | Apply the `in-progress` label before starting an issue, and still read its dependency notes before touching shared surfaces — a claim prevents duplicate pickup, not overlapping code changes. | 2026-07-21 | [link](workflow-process.md#2026-07-21--claim-with-in-progress-before-working-claiming-is-not-collision-safety-542) |
| `process.per-agent-model-routing` | State the model tier (and effort, where the client exposes it) in the dispatch itself for every delegated agent — bounded recon → cheapest fast tier at `low`; mechanical slice against a documented contract → mid tier; judgment-heavy work → orchestrator tier; independent review → a different model family than the implementer. | 2026-07-25 | [link](workflow-process.md#2026-07-25--name-the-model-tier-for-every-dispatched-agent-a-pretooluse-gate-makes-the-silent-default-visible-583) |
| `process.pr-routine-sequence` | Worktree off origin/main → implement → regenerate API artifacts → full local tests + cold review + live-E2E ALL before the push → push, open PR, arm the CI monitor at open → fixes after the push are follow-up commits, never amend/force-push. | 2026-07-21 | [link](workflow-process.md#2026-07-21--the-pr-routine-is-a-fixed-sequence-validate-locally-then-push-then-only-follow-up-commits-542) |
| `process.review-disagreement-frontier-judge` | When independent reviews disagree on a gate PR, escalate to the frontier judge, and put the proposed fix approach in front of it — not just the disputed finding. | 2026-07-21 | [link](workflow-process.md#2026-07-21--review-disagreement-on-a-gate-pr-escalates-to-the-frontier-judge--and-the-proposed-fix-escalates-with-it-542) |
| `process.shared-tree-readonly` | Never commit in `/Users/timothy/ersatztv` and never read its `git log`/`git status`/HEAD to infer anything about `main` — work in a worktree off `origin/main`, which is the only source of truth. | 2026-07-21 | [link](workflow-process.md#2026-07-21--the-shared-tree-at-userstimothyersatztv-is-read-only-and-tells-you-nothing-about-main-542) |
| `process.subagent-drop-resume` | Treat a subagent connection drop as laptop sleep or transient network and re-resume via SendMessage — the work survives. | 2026-07-21 | [link](workflow-process.md#2026-07-21--a-dropped-subagent-connection-is-transient-resume-dont-restart-542) |
| `release.api-contract-ci-gate` | A PR touching `ErsatzTV/Controllers/Api/**` or `ErsatzTV.Core/Api/**` must ship regenerated OpenAPI artifacts (`v1.json`, `v1.d.ts`, `endpoint-index.md`) in the same diff, enforced by a blocking `api-docs` CI job that regenerates-and-diffs against a fresh build. | 2026-07-12 | [link](release-ci-governance.md#2026-07-12--blocking-ci-gate-for-api-contract-artifacts-303-h4h5) |
| `release.done-when-merge-consent` | A PR may merge only when its linked issue's `## Done-when` checklist is fully ticked and the PR's CI is green, enforced by a PreToolUse hook on the Gitea merge tool (deny/allow/ask) plus a pre-push backstop for direct pushes to main. | 2026-07-12 | [link](release-ci-governance.md#2026-07-12--merge-consent-derived-from-state-via-a--done-when-issue-checklist-303-h6) |
| `release.format-as-you-touch-rebase` | A blocking `format` CI job runs `dotnet format --verify-no-changes` scoped only to the PR's changed `.cs` files (never the legacy BOM backlog), and a PR branch must be kept current by rebasing on `origin/main` (never merging main in), enforced by `.husky/pre-push` → `prepush-rebase-check.sh`. | 2026-07-12 | [link](release-ci-governance.md#2026-07-12--formatting-as-you-touch-enforced-rebase-not-merge-for-pr-branches-311-h11--format-ci) |
| `release.live-e2e-required` | A PR that changes an API write-path handler must include a live-E2E pass (driving the real endpoint/screen and confirming the round-trip through a subsequent read), not only unit/characterization tests, and must state whether live-E2E ran or wasn't required. | 2026-07-12 | [link](../decisions.md#2026-07-12--live-e2e-is-a-required-step-for-api-write-path-handler-changes-303) |
| `release.merge-consent-autogrant` | When Done-when boxes are ticked, CI is green, and a fresh positive Review-verdict references head, the merge-consent hook emits `permissionDecision: allow` to actually suppress the redundant mechanical prompt — the derived state IS the consent, no separate conversational confirmation on that path. | 2026-07-12 | [link](release-ci-governance.md#2026-07-12--merge-consent-gate-auto-grants-when-satisfied-no-redundant-prompt-state-is-the-consent-314) |
| `release.migration-rehearsal-prodcopy` | Before promoting a migration-bearing release, rehearse the new image's migrations against a throwaway copy of the latest prod backup (`scripts/migration-smoke.sh`), gating PASS on the migrator's completion log line rather than HTTP readiness alone. | 2026-07-12 | [link](release-ci-governance.md#2026-07-12--release-path-rehearses-migrations-on-a-prod-db-copy-before-promoting-315) |
| `release.prepush-clean-worktree-guard` | A fail-open pre-push hook blocks a push when any file in the branch's diff vs `origin/main` also has uncommitted working-tree or index changes, since a stale-index commit (e.g. `git reset --soft` + `git add` over an edited-but-unstaged fix) can silently push, CI-test, and get reviewed a different tree than the one on disk. Scope is precise to pushed-diff files; escape hatch `ETV_ALLOW_DIRTY_PUSH=1`. | 2026-07-17 | [link](../decisions.md#2026-07-17--pre-push-guard-dont-push-a-file-whose-working-tree-copy-is-uncommitted-h13-416-session) |
| `release.promotion-floating-prod` | Prod tracks the floating `:prod` image reference; a tag build's immutable `:<version>` image is scanned first, then promotion happens via a separate manual `DeployStack`, with daily auto-update only as a fallback — tag with enough runway before 03:00 to avoid an unscanned promotion. | 2026-07-13 | [link](release-ci-governance.md#2026-07-13--release-promotion-floating-prod-exact-image-scan-before-manual-deploy-335) |
| `release.review-verdict-gate` | A PR may not merge until a `Review-verdict: <MERGEABLE\|APPROVED\|BLOCKED\|NOT-MERGEABLE> @ <head-sha>` comment references the PR's current head sha (short-sha prefix match, line-start marker only, negative wins over positive on the same head); folds into the H6 merge-consent hook as condition (c). | 2026-07-12 | [link](release-ci-governance.md#2026-07-12--review-verdict-merge-gate-latest-commit-must-be-reviewed-303-h10) |
| `rulebuilder.relative-date-macros` | The visual rule builder's `inLast`/`notInLast` date operators compile to/parse from the pre-existing `CustomMultiFieldQueryParser` macros `released_inthelast`/`released_notinthelast` and `added_inthelast`/`added_notinthelast`, value form `"<n> day\|week\|month\|year"`; there is no backend change. | 2026-07-23 | [link](../decisions.md#2026-07-23--relative-date-rule-builder-operators-are-a-frontend-only-mapping-onto-existing-lucene-macros-435) |
| `scan.collections-scan-status` | `GET /api/v1/media-sources/collections-scan-status` reports a family-global (not per-source), boolean-only active-scan set read from `IEntityLocker`; the SPA reconciles authoritatively against it (with a grace-tick helper) instead of a fixed client-side timeout. | 2026-07-12 | [link](../decisions.md#2026-07-12--external-collections-scans-get-an-authoritative-status-surface-271-the-spa-timeout-is-retired) |
| `scan.getoraddfolder-db-lookup` | `ILibraryRepository.GetOrAddFolder` resolves the existing folder via a DB query on `(LibraryPathId, Path)`, not the caller's `LibraryPath.LibraryFolders` in-memory navigation, since that navigation is only eager-loaded on the local scan path and is null on remote (Jellyfin) callers. | 2026-07-20 | [link](../decisions.md#2026-07-20--ilibraryrepositorygetoraddfolder-resolves-the-folder-from-the-db-not-the-callers-librarypathlibraryfolders-navigation-488) |
| `scan.jellyfin-mixed-content-library` | A Jellyfin library whose collection type is `mixed` (or absent) maps to one ErsatzTV library of `LibraryMediaKind.Mixed`, scanned by running the movie/television/music-video scanners in sequence against that single library — a library is a place, not a media kind. | 2026-07-20 | [link](../decisions.md#2026-07-20-489--jellyfin-mixed-content-libraries-map-to-one-library-holding-many-kinds) |
| `scan.musicvideo-server-identity` | Jellyfin music videos carry a per-library server identity (`JellyfinMusicVideo : MusicVideo` with `ItemId`/`Etag`, TPT table + ItemId index), so `JellyfinMusicVideoLibraryScanner` folds onto a shared `MediaServerMusicVideoLibraryScanner` base that diffs the **server item id** and soft-trashes (`FlagFileNotFound`) instead of diffing local paths and hard-deleting. Rows predating the identity are **adopted in place** — the identity row is inserted against the same `MediaItem` id, scoped to the scanned library's own `LibraryPath` — never deleted and re-added. | 2026-07-25 | [link](../decisions.md#2026-07-25--music-videos-carry-a-per-library-server-identity-reconciliation-is-an-itemid-diff--soft-trash-496) |
| `scan.projection-failure-sweep-guard` | `MediaServerReconciliationGuard` takes a per-enumeration projection-failure count and refuses the file-not-found sweep (logged loudly) whenever it is non-zero against a non-empty existing set — at all six sweeps, including the nested per-show season and per-season episode ones #477 left unguarded (via `ShouldFlagMissingDescendants`, which applies the failure refusal but not #477's empty-fetch branch). Deliberate guard-clause skips (STRM, virtual, unsupported type) are explicitly **not** failures and never suppress a sweep. The missing-fraction / ratio threshold floated by #477 is **rejected**, not deferred. | 2026-07-25 | [link](../decisions.md#2026-07-25--a-media-server-sweep-also-refuses-when-the-api-client-silently-dropped-items-whose-projection-threw-the-ratio-threshold-is-rejected-484) |
| `scan.zero-item-fetch-guard` | A media-server library sweep refuses to flag missing items when a successful fetch returns zero incoming items against a non-empty existing set (`MediaServerReconciliationGuard.ShouldFlagMissing`), rather than treating an ambiguous empty result as a full-library deletion. | 2026-07-19 | [link](../decisions.md#2026-07-19--a-media-server-library-sweep-refuses-to-flag-when-a-successful-fetch-returns-zero-items-rather-than-nuking-the-whole-library-477) |
| `sched.auto-tune-foundation` | Auto-tune preview enumeration uses EF distinct+count queries for exact counts, while each created channel is persisted as a live SmartCollection; coexistence with existing channels/numbers is additive-only, never mutating. | 2026-07-16 | [link](../decisions.md#2026-07-16--auto-tuning-enumerates-via-ef-persists-via-smartcollection-additive-coexistence-69) |
| `sched.autotune-detailpanel-members` | The Auto-Tune DetailPanel's per-channel content-source list is a live `ISearchIndex.Search` roll-up through the server-owned `AutoTuneAxisMap.GenerateQuery`, not an EF distinct+count query, so the preview matches exactly what the built channel's SmartCollection will contain. | 2026-07-17 | [link](../decisions.md#2026-07-17--auto-tune-detailpanel-member-list--live-search-index-roll-up-not-ef-enumeration-384) |
| `sched.autotune-per-channel-overrides` | Auto-Tune per-channel overrides reuse the Channel Builder's advanced-options DTO verbatim; per-source weights and bug-colour logo are deferred to #425. | 2026-07-17 | [link](../decisions.md#2026-07-17--auto-tune-per-channel-overrides-reuse-the-channel-builder-advanced-options-dto-weights--bug-colour-logo-split-out-to-425-385) |
| `sched.autotune-per-source-weights` | Auto-Tune per-source rotation weights and query corrections are supplied at bulk-create time via #70's MultiCollection/SmartCollection machinery, not a post-hoc PUT. | 2026-07-18 | [link](../decisions.md#2026-07-18--auto-tune-per-source-weights-ride-70s-multicollection-machinery-created-at-tune-time-not-a-post-hoc-put-425) |
| `sched.clock-padding-existing` | Clock-boundary padding already exists via `FillerPreset`'s `FillerMode.Pad` (Classic) and `pad_to_next`/`pad_until` (Sequential/YAML); #77 is closed as verified+documented, not built new, with a one-click per-channel toggle deferred behind the #388 design-system epic. | 2026-07-17 | [link](../decisions.md#2026-07-17--clock-boundary-schedule-padding-already-exists-fillermodepad-77-verified-convenience-toggle-deferred) |
| `sched.clock-padding-schedule-toggle` | A `ProgramSchedule.PadToNearestMinute` (nullable int; null = off) makes the Classic builder pad every content item up to the next N-minute clock boundary without a hand-wired Pad `FillerPreset`, by reusing the existing per-content-item Pad path in `PlayoutModeSchedulerBase.AddFiller`. It extends — does not supersede — `sched.clock-padding-existing` (#77/#388). | 2026-07-22 | [link](../decisions.md#2026-07-22--per-schedule-clock-boundary-padding-is-a-synthetic-content-less-pad-over-the-existing-per-episode-machinery-392) |
| `sched.playbackorder-support-matrix` | Every build-time dispatch site logs a loud (non-fatal) warning on an unsupported `PlaybackOrder`, and a declared `PlaybackOrderSupport` matrix + partition tripwire test makes adding a new order safe by construction. | 2026-07-18 | [link](../decisions.md#2026-07-18--unsupported-playbackorder-is-loud-at-build-time-a-declared-support-matrix-and-tripwire-test-make-new-orders-safe-by-construction-403) |
| `sched.reshuffle-scoped-reset` | `POST /api/v1/playouts/{id}/reshuffle` runs `ErasePlayoutHistory` (reseeds `Playout.Seed` + clears anchors/rerun-history) then enqueues a scoped `Reset` build, so reshuffle always reseeds — even for the non-Classic kinds `Reset` alone wouldn't reseed; `Playout.Seed` is surfaced on list/detail DTOs as visible confirmation. | 2026-07-16 | [link](../decisions.md#2026-07-16--per-playout-reshuffle--scoped-reset-build-seed-surfaced-71) |
| `sched.seasonal-scheduling-existing` | Seasonal/date-conditional scheduling already ships first-class via `IAlternateScheduleItem` (Classic `ProgramScheduleAlternate`, Block `PlayoutTemplate`) evaluated by `AlternateScheduleSelector.GetScheduleForDate` (first match in `Index` order, catch-all last); #73 is closed as already-implemented with a docs-only "seasonal/holiday" recipe added, not new code. | 2026-07-17 | [link](../decisions.md#2026-07-17--seasonal--date-conditional-scheduling-already-exists-alternate-schedules--playout-templates-73-closed-as-implemented) |
| `sched.shuffle-source-builder` | Shuffle-source construction moves to a static, DI-free `ShuffleSourceBuilder` (a shared seam, not a service) so Classic and Playlist stop cross-engine reaching into `PlayoutBuilder` statics; a unified Classic+Playlist enumerator factory is explicitly rejected as a god-factory. Block/Scripted/YAML duplication is left alone, deferred to a follow-up gated on #381. | 2026-07-17 | [link](../decisions.md#2026-07-17--shuffle-source-construction-extracted-to-shufflesourcebuilder-per-family-seam-not-a-god-factory-380) |
| `sched.weighted-shuffle` | Fair-share/weighted airtime distribution ships as one new `PlaybackOrder.WeightedShuffle = 9` order (equal weights = fair-share), not a retrofit of `ShuffleInOrder` (which only anti-clumps, since its padding spacers emit nothing) and not a separate orthogonal "distribution" setting; weights live on `MultiCollectionItem`/`MultiCollectionSmartItem` (DB default 1, dual-provider migration), bounded at write (1..1000) and clamped again in the enumerator, and the write path rejects `WeightedShuffle` at every dispatch site that doesn't handle it rather than let it silently degrade to unweighted random. | 2026-07-17 | [link](../decisions.md#2026-07-17--weighted--fair-share-distribution-is-a-new-weightedshuffle-order-shuffleinorder-is-anti-clumping-not-fair-share-70) |
| `sched.weightedshuffle-editor` | WeightedShuffle per-source weights are edited on the multi-collection editor (property of the MultiCollection), while the WeightedShuffle order itself is offered only on classic MultiCollection schedule items; fair-share is a "reset weights to 1" action, not a stored mode. | 2026-07-19 | [link](../decisions.md#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) |
| `scheduling.ondemand-guide-refresh-on-thaw` | When `PlayoutTimeShifter.TimeShift` slides an on-demand playout's materialized timeline forward on tune-in, it reports the channel numbers whose cached guide is now stale — the shifted channel **plus any channels that mirror it** — and `TimeShiftOnDemandPlayoutHandler` enqueues a `RefreshChannelData` for each, so every affected cached XMLTV fragment is regenerated from the just-shifted `PlayoutItem` rows. The guide and playback both read the same stored `PlayoutItem.Start/Finish`, but the guide is served from a **cached** projection — rewriting the rows without rebuilding the cache would leave the guide advertising a stale timeline. | 2026-07-21 | [link](../decisions.md#2026-07-21--an-on-demand-time-shift-rebuilds-the-channels-cached-xmltv-so-the-guide-cant-lag-playback-68) |
| `security.artwork-content-type-sniff` | Artwork content type is always derived from the stored bytes (never the client-declared value or a `?contentType=` query param) at both upload and serve, clamped to an image allow-list, closing the unauthenticated stored-XSS chain; Kestrel `MaxRequestBodySize` bounds upload DoS. | 2026-07-12 | [link](api-auth-security.md#2026-07-12--artwork-content-type-is-sniffed-never-reflected-283-s4s9-stored-xss) |
| `security.baseline-response-headers` | `SecurityHeadersMiddleware`, registered first in the pipeline, sets `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, and `Referrer-Policy: strict-origin-when-cross-origin` on every response (CSP/HSTS deliberately deferred); API-key comparison is constant-time and playout pagination is clamped. | 2026-07-11 | [link](api-auth-security.md#2026-07-11--baseline-security-response-headers--phase-0-api-hardening-197-pr-279) |
| `security.blazor-removal-auth-posture` | Removing the Blazor UI's OIDC-challenged surface exposes nothing a user couldn't already reach via the already-open `/app` SPA (open since phase (a)); real SPA/API authentication is deliberately deferred to #197, and the removal PR must preserve `ConditionalIptvAuthorizeFilter`, `ApiKeyAuthorizationFilter`, and `JwtHelper` access_token support. | 2026-07-11 | [link](api-auth-security.md#2026-07-11--blazor-removal-auth-posture-no-new-exposure-beyond-phase-a-real-auth-deferred-to-197-206) |
| `security.contract-freeze-honesty` | The OpenAPI doc's declared security/401 scheme is generated from the same `ApiKeyAuthorizationFilter.EndpointRequiresKey` predicate the runtime enforces (so declared auth can't drift from enforced auth), every `/api/*` action returns a ResponseModel (no raw Application VMs), and Channel REST resources are keyed by immutable `Id`, never mutable `Number`. | 2026-07-12 | [link](api-auth-security.md#2026-07-12-197-bundle-c--contract-freeze-honesty) |
| `security.corp-same-origin` | `SecurityHeadersMiddleware` sends `Cross-Origin-Resource-Policy: same-origin` on every response including `/docs`/`/openapi`, blocking cross-origin `no-cors` embedding without affecting allowed CORS-mode fetches or server-side Jellyfin `/iptv/*` requests. | 2026-07-13 | [link](api-auth-security.md#2026-07-13--cross-origin-resource-policy-same-origin-on-every-response-330) |
| `security.csp-permissions-policy` | `SecurityHeadersMiddleware` sends an enforcing (not report-only) `Content-Security-Policy` (no `unsafe-inline`/`unsafe-eval`; the one inline theme-bootstrap script allow-listed by hash) and a deny-all `Permissions-Policy` on the SPA/`/api`/`/artwork`/`/iptv`; `/docs` and `/openapi` keep only the baseline headers, excluded from CSP because Scalar needs inline bootstrap. | 2026-07-12 | [link](api-auth-security.md#2026-07-12--enforcing-csp--permissions-policy-on-the-host-319-zap-baseline) |
| `security.fail-closed-api-auth` | Every mutating `/api` request requires `X-Api-Key` (no open mode); reads are gated by `Api:RequireKeyForReads` (default true) OR `[RequiresApiKey]` on sensitive controllers; CORS is an exact-origin allowlist (`ApiCors`); `ForwardedHeaders` trust stays configurable but defaults to trust-all-with-warning. | 2026-07-12 | [link](api-auth-security.md#2026-07-12--fail-closed-api-auth--sensitive-read-tier--corsforwardedheaders-lockdown-197-bundle-a-pr-292) |
| `security.iptv-access-token-transport` | The `/iptv` `?access_token=` value is percent-encoded (`Uri.EscapeDataString`) everywhere it is interpolated into an M3U/HLS/XMLTV URL (XMLTV additionally XML-escapes the encoded value), so a structural character can't malform the manifest or guide; Serilog logs a scrubbed request path (`access_token` → `***` via `IncludeQueryInRequestPath = false` + a `RequestPathScrubbed` enricher), so a 5xx/Debug `/iptv` request never writes the token; and every dynamic token-bearing `/iptv` manifest (`channels.m3u`, `xmltv.xml`, the HLS multi-variant/media playlists) returns `Cache-Control: private, no-store`. | 2026-07-23 | [link](api-auth-security.md#2026-07-23--access_token-transport-hardening-percent-encode-in-m3uhls-redact-from-logs-no-store-on-tokened-manifests-421-559) |
| `security.iptv-browser-token` | Under a JWT-enabled deployment (`JWT:IssuerSigningKey` set), the browser SPA obtains a short-lived, globally-scoped `/iptv/*` access token from an authenticated `GET /api/v1/auth/iptv-token` and appends it as `?access_token=`; the endpoint answers 204 when JWT is disabled (nothing to mint). Lifetime defaults to 60 min, configurable via `JWT:BrowserTokenLifetimeMinutes`. | 2026-07-22 | [link](api-auth-security.md#2026-07-22--short-lived-browser-iptv-token-so-the-spa-reaches-iptv-under-jwt-auth-552) |
| `security.session-auth-dual-credential` | `ApiAuthorizationFilter` accepts a request when a valid `X-Api-Key` matches OR the principal is an authenticated session (cookie `ctv-session`, `HttpOnly`/`SameSite=Lax`); session-authenticated mutations require the presence-only `X-CSRF` header or are rejected 403. This narrows the OIDC-inert sub-claim of `security.blazor-removal-auth-posture` (#206) — the rest of that record's auth-surface enumeration still holds. | 2026-07-12 | [link](api-auth-security.md#2026-07-12--browser-spa-session-auth-api-accepts-session-or-machine-key-295-pr1-server-only) |
| `security.session-cutover-postify` | The browser SPA authenticates cookie-only (no more `X-Api-Key` from `web/`); the machine key is repurposed to external/MCP-only via `GET /api/auth/machine-key`; every side-effecting GET/HEAD under `/api` is converted to POST so the existing CSRF gate covers it (standing rule: never add a side-effecting GET/HEAD under `/api`). | 2026-07-12 | [link](api-auth-security.md#2026-07-12--295-pr2-spa-session-cutover--301-side-effecting-get-post-ification) |
| `session.shared-checkout-refresh` | Session end runs `scripts/refresh-shared-checkout.sh`, which fast-forwards `/Users/timothy/ersatztv` to `origin/main` (and reinstalls `web/node_modules` when the lockfile moved), refusing to touch anything unless that tree is on a clean, non-ahead `main`. | 2026-07-21 | [link](../decisions.md#2026-07-21--session-end-fast-forwards-the-shared-checkout-a-stale-tree-serves-stale-files-541) |
| `spa.add-to-layer` | All add-to-collection/playlist/schedule affordances share one component layer at `web/src/media/addTo/`; multi-select is an explicit screen-level toggle, and the per-card menu offers schedule only for the server-validated kinds. | 2026-07-10 | [link](../decisions.md#2026-07-10--shared-add-to-layer-lives-in-websrcmediaaddto-select-mode-is-an-explicit-toggle) |
| `spa.app-shell-extraction` | `App.tsx` is only the composition root over `web/src/app/routes.tsx` (stable route-object identity), `app/AppShell.tsx` (shell chrome), and `app/ScreenContent.tsx` (exhaustive screen dispatch); primary actions are one explicit `PrimaryActionProvider` registration per screen, replacing the old global `ctv:primary-action` window event. | 2026-07-15 | [link](spa-modularization.md#2026-07-15--app-shellrouting-extraction--explicit-primary-action-ownership-247) |
| `spa.autotune-detailpanel-slideover` | The Auto-Tune DetailPanel SPA is a reusable `SlideOver` primitive sharing `useOverlayBehavior` with `Dialog`, plus a shared advanced-options model extracted from ChannelBuilder; decorative panes without backend support are dropped. | 2026-07-18 | [link](../decisions.md#2026-07-18--auto-tune-detailpanel-spa-reusable-slideover--shared-advanced-options-model-decorative-panes-dropped-to-match-the-backend-386) |
| `spa.channel-editor-create-logo` | Bare-channel create is a "New blank channel" action on the channels list (reusing Blazor's add-mode defaults) that navigates into the full editor, and an external logo URL always wins over an uploaded logo, matching `ChannelEditViewModel` precedence. | 2026-07-11 | [link](../decisions.md#2026-07-11--channel-editor-bare-create-entry-point--external-logo-mutual-exclusion-212) |
| `spa.channel-renumber-prompt` | Channel renumbering uses a sequential `prompt()`-driven "Renumber" action instead of drag-to-reorder. | 2026-07-09 | [link](../decisions.md#2026-07-09--channel-numbers-prompt-driven-sequential-renumber-instead-of-drag-to-reorder) |
| `spa.channels-screen-extraction` | The Channels domain is a single-file zero-prop screen (`web/src/screens/ChannelsScreen.tsx`) with a colocated test file and no sibling helper directory, since its pure logic is too small (~30 lines) to justify a separate business-rule layer like Schedules' `itemRules.ts`. | 2026-07-11 | [link](spa-modularization.md#2026-07-11--channels-screen-extraction-244-single-file-screen-no-sibling-helper-dir-epic-243-phase-1) |
| `spa.collection-custom-order-ui` | Collection custom ordering uses per-row Move up/Move down buttons (not drag) and is offered for any manual collection with custom order enabled, not just movies-only. | 2026-07-09 | [link](../decisions.md#2026-07-09--collection-custom-order-move-updown-buttons-any-kind-collections) |
| `spa.datetime-local-input` | The channel-mode date/time input uses a native `<input type="datetime-local">` instead of free-text Chronic natural-language parsing. | 2026-07-09 | [link](../decisions.md#2026-07-09--datetime-local-instead-of-chronic-natural-language-start-parsing) |
| `spa.deco-templates-table` | The deco-templates editor also renders its day/deco assignment as a table, extending (not replacing) the templates-editor-table convention. | 2026-07-09 | [link](../decisions.md#2026-07-09--table-not-calendar-convention-also-covers-the-deco-templates-editor) |
| `spa.download-sample-gate` | The SPA disables both Download Media Sample and Download Results while a troubleshooting session is starting/running (Blazor only gated Download Results). | 2026-07-09 | [link](../decisions.md#2026-07-09--spa-gates-download-media-sample-while-a-session-is-active) |
| `spa.legacy-redirect-matcher` | `LegacyUiRedirects.TryGetRedirect` is a two-tier matcher — an exact `OrdinalIgnoreCase` `Map` (Tier 1) then an ordered segment-template pattern list (Tier 2, first-match-wins) — collision-free by construction, with a guard invariant that no rule may prefix-match `/api`, `/artwork`, `/docs`, `/openapi`, `/iptv`, `/app`, or `/media/sources`. | 2026-07-11 | [link](../decisions.md#2026-07-11--legacyspa-redirect-matcher-exact-map--ordered-segment-template-patterns-204) |
| `spa.logs-page-size-local` | The Logs page rows-per-page preference is stored in `window.localStorage` (`ctv-logs-page-size`), not a server `ConfigElement`. | 2026-07-11 | [link](../decisions.md#2026-07-11--logs-page-size-is-a-client-local-preference-not-a-server-configelement) |
| `spa.playback-troubleshoot-poll` | The playback-troubleshooting screen reports FFmpeg completion by polling `GET /api/troubleshoot/playback/status` (~2s) rather than a server push channel. | 2026-07-09 | [link](../decisions.md#2026-07-09--playback-troubleshooting-completion-feedback-poll-status-no-push-channel) |
| `spa.playout-reset-button` | The SPA keeps a single Reset action (server picks the default build mode) and drops Blazor's separate "Schedule reset" button since its capability already exists via the playout's Edit-details flow. | 2026-07-09 | [link](../decisions.md#2026-07-09--per-playout-schedule-reset-button-dropped-reset-uses-the-server-default-build-mode) |
| `spa.playouts-screen-extraction` | The Playouts domain (including its unguarded `PlayoutsRouteScreen` route wrapper with local pathname/popstate state) moved as one unit into `web/src/screens/PlayoutsScreen.tsx`, keeping its screen-specific sub-path route ownership colocated with the base screen; a pure structural move with no API/route/CSS/behavior change. | 2026-07-14 | [link](spa-modularization.md#2026-07-14--playouts-screen-extraction-245-screen-owned-route-wrapper-epic-243-phase-2) |
| `spa.rulebuilder-nesting` | The visual rule builder's `Group` nests recursively to a single shared cap, `MAX_GROUP_DEPTH` (`types.ts`, currently 5, root group = depth 0) — read by the UI's "Add group" gate, `parse.ts` and the round-trip property-test generator alike; everything else about the builder is unchanged from #176 (compile-only closed Lucene subset over the stored query string, no stored rule AST, field vocabulary from `GET /api/v1/search/fields`). | 2026-07-25 | [link](../decisions.md#2026-07-25--rule-builder-group-nesting-is-bounded-arbitrary-depth-max_group_depth-not-one-level-436) |
| `spa.schedules-editor-draft-save` | The schedules SPA editor mutates a local draft and flushes one explicit Save (`PUT /api/schedules/{id}/items`) instead of instant-persisting each action; Copy deep-copies all source references (fixing a Blazor omission); the shuffled-schedule GET's `EnforceProperties` lossy normalization is preserved and mirrored in the SPA's option lists. | 2026-07-11 | [link](../decisions.md#2026-07-11--schedules-spa-editor-draftexplicit-save-over-instant-persist-copy-includes-multismartrerun-shuffled-get-normalization-preserved) |
| `spa.sidebar-collapsible-accordions` | The shell sidebar's collapse + nav-group-accordion state persists under two hyphenated `ctv-sidebar-*` localStorage keys (matching the repo's `ctv-` convention, not the prototype's dotted names); labeled groups default-collapsed. | 2026-07-18 | [link](../decisions.md#2026-07-18--collapsible-sidebar--nav-group-accordions-two-ctv-sidebar--localstorage-keys-labeled-groups-default-collapsed-396) |
| `spa.spa-rebuild-decision` | The UI is a full React SPA (ChicoryTV) rebuild over the REST API, not a Blazor Server reskin. | 2026-06 | [link](../decisions.md#2026-06--ui-rebuild-is-a-react-spa-chicorytv-on-the-rest-api-not-a-blazor-reskin) |
| `spa.templates-editor-table` | The SPA templates editor renders day/block assignment as a table, not Blazor's drag-and-drop calendar grid — an accepted, deliberate parity deviation. | 2026-07 | [link](../decisions.md#2026-07--templates-editor-in-the-spa-is-a-table-not-blazors-drag-calendar) |
| `spa.topbar-primary-action` | The TopBar's primary-action "+" button renders only when the active route declares a non-empty `primaryAction`, is wired (via a shared `usePrimaryAction` hook) only on single-unambiguous-create-flow list screens, and is dropped everywhere else rather than left as a dead/no-op button. | 2026-07-12 | [link](../decisions.md#2026-07-12--topbar-primary-action-button-wire-creates-drop-the-rest-238) |
| `spa.yaml-validator-textarea` | The YAML playout validator takes pasted YAML via a `<textarea>`, not a server-side file path, since the SPA has no filesystem access. | 2026-07-09 | [link](../decisions.md#2026-07-09--yaml-playout-validator-paste-textarea-instead-of-a-server-file-path) |
| `startup.parallel-orientation` | A fresh session runs two concurrent tracks at startup — Orientation (`AGENTS.md`/`CLAUDE.md` → `docs/README.md` task-signal map → the active decisions catalog `docs/decisions/README.md`) and, only when no issue is named, Selection (`scripts/select-queue.sh N`, deterministic live-Gitea ranking). A named issue skips Selection entirely. ersatztv#237, the closed pickup tracker this replaces, is reduced to a single archival breadcrumb and MUST NOT be read for live state. | 2026-07-21 | [link](../decisions.md#2026-07-21--parallel-orientation--selection-is-the-startup-protocol-237-retired-520) |
| `testing.e2e-cleanup-scope-by-pid` | An E2E harness or agent may only kill processes whose PIDs it captured at launch — capture the PID; whoever owns the lifecycle releases it from a `trap ... EXIT INT TERM`. Never `pkill -f "dotnet ErsatzTV.dll"` (or any pattern that can match a process this run did not start). A foreign listener is reported, not reaped. | 2026-07-25 | [link](workflow-process.md#2026-07-25--e2e-cleanup-kills-only-the-pids-it-started-never-a-pkill--f-pattern-586) |
| `testing.e2e-local-fresh-config-dir` | Always point `scripts/e2e-local.sh` at a fresh config dir — leftover channels/schedules/DB rows bleed state between runs and corrupt assertions. (The *readiness-probe hang* this record was originally written about was fixed in #533; the fresh-dir rule stands on state-bleed grounds alone.) | 2026-07-21 | [link](workflow-process.md#2026-07-21--run-scriptse2e-localsh-against-a-fresh-config-dir-a-reused-one-hangs-the-readiness-probe-542) |
| `testing.live-e2e-prepush-timing` | Run live-E2E via `scripts/e2e-local.sh` before pushing a write-path or UI change, and exercise download endpoints with curl, never a browser tab. | 2026-07-21 | [link](workflow-process.md#2026-07-21--live-e2e-runs-before-the-push-and-downloads-are-curled-not-browsed-542) |
| `testing.playwright-mcp-download-and-recovery` | In Playwright-MCP E2E, fetch file-download endpoints with curl — never a browser tab or `window.open` — and if browser tools stall repeatedly, `pkill -f ms-playwright-mcp` and drive a fresh session. | 2026-07-21 | [link](workflow-process.md#2026-07-21--playwright-mcp-curl-download-endpoints-never-open-a-tab-or-windowopen-542) |
| `testing.scripted-playout-golden-deferred` | The `PlayoutBuildGoldenTests` in-memory golden net covers Sequential (YAML) as of #381. Scripted's *end-to-end pipeline* is excluded — `ScriptedPlayoutBuilder` runs a user-authored external program that drives the engine over HTTP loopback, which the in-memory harness can't pin — so that full-pipeline (integration) harness is deferred to #563. But the scheduling *behavior* those scripts drive lives entirely in the in-process `SchedulingEngine` (the `ScriptedScheduleController` is a 1:1 pass-through to it), which IS directly unit/golden-testable; the earlier "Scripted is un-golden-able by construction" framing overstated the constraint by conflating transport with engine. #395 extracts that shared switch to `ContentEnumeratorBuilder` and adds a direct regression net (`ContentEnumeratorBuilderTests`) over it. | 2026-07-22 | [link](../decisions.md#2026-07-22--sequential-yaml-playout-gets-a-golden-scripted-is-excluded-from-the-golden-net-by-construction-381) |
| `testing.troubleshoot-path-cannot-test-branding` | Verify logo/watermark/bug changes through a real channel playout — a green troubleshoot run proves nothing about branding. | 2026-07-21 | [link](workflow-process.md#2026-07-21--channel-branding-is-not-testable-through-the-troubleshooting-playback-api-542) |
| `api.artwork-rooted-urls` | API response DTOs return artwork as rooted, directly-usable URLs (plus passthrough for absolute/proxy URLs), never relative Blazor-convention paths. | 2026-07-07 | [link](records/api/artwork-rooted-urls.md) |
| `api.async-op-contract` | Queue-triggering `/api/*` endpoints normalize onto one contract — 202 Accepted (queued), 404 (missing entity), 409 (lock held), 422 (domain precondition) — with Trakt as the reference implementation; playout list/detail GETs also carry an `isLocked` observability flag as the HTTP-observable substitute for a live push channel. | 2026-07-11 | [link](records/api/async-op-contract.md) |
| `api.channel-health-object` | `ChannelResponseModel`/`ChannelDetailResponseModel` carry a server-derived `health` object (`ChannelHealthResponseModel { Status, Faults[], PlayoutCount, BrokenSourceItemCount }`) computed **read-time** from the built timeline (`Playout.BuildStatus` + upcoming `PlayoutItem → MediaItem.State`, `Finish >= now`), kind-agnostic across all 5 `PlayoutScheduleKind` values; `Status`/`Faults` are const-string classes (`ChannelHealthStatus`, `ChannelFault`), not C# enums, so the SPA hand-maintains the union (mirrors `ChannelPreviewAvailability`). This supersedes #72's "raw fact only, no derived enum, empty-schedule/broken-source deliberately not computed" stance now that the auto-tune taxonomy churn (#383/#384) it was waiting on has landed (see `channel.origin-marker` sibling record, #414). | 2026-07-23 | [link](records/api/channel-health-object.md) |
| `api.channel-preview-capability` | Whether a channel can be previewed in the browser is declared by the server, not derived by the SPA, as an additive `Preview` field (`{Availability, ManifestUrl, UnavailableReason}`) on `ChannelResponseModel`. | 2026-07-21 | [link](records/api/channel-preview-capability.md) |
| `api.decode-by-id` | Endpoints that decode/expand opaque stored state accept a database row id and resolve it server-side rather than round-tripping client-supplied serialized state. | 2026-07-07 | [link](records/api/decode-by-id.md) |
| `api.from-lineup-clear-to-none` | `POST /api/v1/channels/from-lineup` (and the Auto-Tune per-channel `advanced`, which reuses the same DTO) distinguishes *inherit* from *clear-to-none* with a typed `clear` enum list on `advanced`. A field left null/omitted still inherits the template value (unchanged for every existing client); naming a field in `clear` forces it to none on the new channel even when the template sets one. Sending both a set value and a clear for the same field is a validation error. | 2026-07-21 | [link](records/api/from-lineup-clear-to-none.md) |
| `api.healthcheck-remediation-dto` | Health-check remediation is server-declared `{Kind, Target}` metadata on an additive DTO field; the SPA renders/acts on it, it doesn't derive labels itself. | 2026-07-17 | [link](records/api/healthcheck-remediation-dto.md) |
| `api.healthcheck-ttl-cache` | Health-check results are held in a 30s TTL cache inside `HealthCheckService`; a non-forced `GET /api/v1/health` returns the cached list, and `?refresh=true` (or a forced internal caller) bypasses it to run fresh. | 2026-07-19 | [link](records/api/healthcheck-ttl-cache.md) |
| `api.logs-sort-params` | `GET /api/logs` takes allow-listed `sortField` (`timestamp`\|`level`) and `sortDirection` (`asc`\|`desc`) query params, normalized (not rejected) on an unrecognized value. | 2026-07-11 | [link](records/api/logs-sort-params.md) |
| `api.mediatr-passthrough` | The REST API is thin controllers over existing MediatR handlers, with no new service/business-logic layer. | 2026-06 | [link](records/api/mediatr-passthrough.md) |
| `api.openapi-mirrors-runtime` | The generated OpenAPI spec is made to match the runtime Newtonsoft wire contract (via `NewtonsoftSchemaNamingTransformer`), not the reverse. | 2026-07-09 | [link](records/api/openapi-mirrors-runtime.md) |
| `api.parentid-drillin` | Media drill-in (season/episode/artist/music-video) is served by an optional `parentId` query param on library-browse, not dedicated per-kind child-listing endpoints. | 2026-07-07 | [link](records/api/parentid-drillin.md) |
| `api.playout-build-lock-409` | Every id-keyed playout/channel mutation endpoint checks `IEntityLocker.IsPlayoutLocked(id)` and returns 409 Conflict while a build is in-flight, mirroring Blazor's disabled-buttons behavior; reset-all stays 202 and silently skips locked playouts. | 2026-07-10 | [link](records/api/playout-build-lock-409.md) |
| `api.postcommit-cancellation-none` | Once a mutation commits, its entire compensating side effect (enqueues, publishes, reindexes, cache refresh, and any post-commit lookup gating one of those) runs on `CancellationToken.None` so a late client disconnect can't half-abort an already-committed change. | 2026-07-11 | [link](records/api/postcommit-cancellation-none.md) |
| `api.put-replace-index-order` | PUT-replace-the-whole-list endpoints derive each item's `Index` from its request-array position, never a client-supplied field; alternate-schedule/playout-template rows are evaluated in `Index` order with the least-conditional row placed last as the catch-all default. | 2026-07 | [link](records/api/put-replace-index-order.md) |
| `api.response-dtos` | New REST response DTOs live in `ErsatzTV.Core/Api/<Domain>/*ResponseModel.cs` with a file-scoped `#nullable enable` pragma; controllers never expose Application VM types directly. | 2026-07 | [link](records/api/response-dtos.md) |
| `api.schedule-item-flat-dto` | Schedule-item GET/POST/PUT use a flat, non-polymorphic `ScheduleItemResponseModel` (every subtype field promoted to a nullable top-level member) instead of the polymorphic Application VM hierarchy, with mutation field names matching `ScheduleItemRequest` 1:1 for a lossless round-trip. | 2026-07-10 | [link](records/api/schedule-item-flat-dto.md) |
| `api.scheduling-hardening` | Create/Replace handlers guard against null/whitespace `name` (`IsNullOrWhiteSpace`, not just `Length`) to prevent NRE-500s, template-item overlap validation compares by index (not record value-equality) to catch exact-duplicate items, and unreachable 404 `ProducesResponseType` attributes on create-only actions are trimmed. | 2026-07-13 | [link](records/api/scheduling-hardening.md) |
| `api.search-allitems-paging` | `GET /api/v1/search/all-items` is paginated (capped page size, `Totals` field) to bound DoS exposure; the SPA add-all flow pages to completeness instead of relying on an unbounded response. | 2026-07-18 | [link](records/api/search-allitems-paging.md) |
| `api.search-field-values` | `GET /api/v1/search/fields/{name}/values?q=&limit=` returns distinct WHOLE values from the database for one of a narrow allow-list of catalog fields (not the Lucene term dictionary — analyzed `TextField`s store lowercased word tokens, e.g. "Science Fiction" → `science`/`fiction`, useless as a typeahead suggestion), 404 for an unknown field, a non-`text` field, or a `text` field with no distinct-value source; case-insensitive prefix-filtered on `q`, `limit` clamped to `[1, 50]` (default 50). | 2026-07-23 | [link](records/api/search-field-values.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.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) |
| `channel.origin-marker` | A new `Channel.Origin` (`ChannelOrigin` enum — `Unknown`/`UserCreated`/`AutoTuned`) records how a channel row was created and is stamped exactly once at insert (`AutoTuned` in `CreateChannelFromLineupHandler`, `UserCreated` in `CreateChannelHandler`), and is never mutated on a later edit. It is surfaced as a raw `origin` field on `ChannelResponseModel`; the SPA badges only `AutoTuned`. Rows predating the column read `Unknown` — provenance is **not** back-filled. | 2026-07-23 | [link](records/channel/origin-marker.md) |
| `ci.batch-pushes-no-cancel-route` | Hold review fixes, doc corrections and format fixes locally and push **once** — a superseded run cannot be cancelled from the agent side and holds a runner slot until it finishes. | 2026-07-21 | [link](records/ci/batch-pushes-no-cancel-route.md) |
| `ci.build-once-rejected` | CI build-once (a shared compile artifact across jobs) was implemented, measured, and rejected for a 40-85% wall-clock regression; keep the #420 cross-run tree-identity skip instead. | 2026-07-18 | [link](records/ci/build-once-rejected.md) |
| `ci.cancelled-is-not-a-verdict` | Treat a `cancelled` conclusion as "no verdict" — never as pass or fail — and report FAILED and CANCELLED counts separately in any CI monitor. | 2026-07-21 | [link](records/ci/cancelled-is-not-a-verdict.md) |
| `ci.decisions-edit-trailer` | The body-diff exemption is armed by an affirmative `Decisions-Edit:` **git trailer** (`yes`/`true`/`1`, case-insensitive, read with `unfold`) on some NON-MERGE commit in the PR's merge-base range — never by a substring search over the message text. A non-affirmative value (`no`) does not arm it, the retired `[decisions-edit]` substring arms nothing (the validator emits a `::warning::` nudge when it sees one without a trailer), and a git error leaves the guard ON. | 2026-07-25 | [link](records/ci/decisions-edit-trailer.md) |
| `ci.decisions-lifecycle-flake` | When `decisions lifecycle` is the **only** red job, do not investigate and do not create a new run to clear it — no rebase, no `--amend`, no no-op push; the operator reruns that single job from the Gitea UI. | 2026-07-21 | [link](records/ci/decisions-lifecycle-flake.md) |
| `ci.docs-only-detect-shallow-safe` | The docs-only detect script must diff against `FETCH_HEAD` (always resolves after `git fetch`, even shallow) using a two-dot tree diff — not `origin/<base>` with three-dot — because a `fetch-depth: 1` shallow clone has no remote-tracking ref and no merge-base, which silently fails the original detect into `docs_only=false` (full matrix, no functional error). A CI-behavior change must be verified by measuring the effect (job durations), not just a green check. | 2026-07-17 | [link](records/ci/docs-only-detect-shallow-safe.md) |
| `ci.docs-only-skip-steps` | A docs-only change must still run every required job (`test`, `migrations`) so their commit-status contexts always report; each heavy job runs `scripts/ci-detect-docs-only.sh` first and gates its real STEPS on `if: steps.detect.outputs.docs_only != 'true'`, never `if:`-skips the whole job (an `if:`-skipped job reports `skipped`, not `success`, which branch protection may never unblock on). Detection biases toward running more on any doubt. | 2026-07-17 | [link](records/ci/docs-only-skip-steps.md) |
| `ci.format-gate-folder-mode` | The blocking `format` CI job (and matching pre-commit hook) runs `dotnet format whitespace . --folder --include <files>` instead of loading the full MSBuild/Roslyn solution, cutting the gate from ~480s to ~0.5s with unchanged whitespace/charset coverage. | 2026-07-19 | [link](records/ci/format-gate-folder-mode.md) |
| `ci.functional-e2e-harness` | The `functional-e2e` CI job boots the PR's own code from source via `dotnet run` (`scripts/e2e-local.sh`) and runs deterministic assertions (`scripts/e2e-functional.sh`) as an advisory (non-blocking) job, not a `build` dependency or required check. Originally curl-only; since #445 the same job carries a second, headless-browser step for the contracts curl cannot express — see `ci.ui-e2e-harness`. | 2026-07-16 | [link](records/ci/functional-e2e-harness.md) |
| `ci.gitea-milestone-filter-noop` | Never filter issues with the server-side `?milestones=<name>` parameter — fetch all open issues once and filter LOCALLY on each issue's `.milestone.title`. | 2026-07-21 | [link](records/ci/gitea-milestone-filter-noop.md) |
| `ci.infra-shaped-red-under-load` | When a job dies inside a setup/cache step before your code compiles, check the runner host's load before diagnosing the diff, and never file a CI bug off one sample under pressure. | 2026-07-21 | [link](records/ci/infra-shaped-red-under-load.md) |
| `ci.killed-job-triage` | Never trust a job's `conclusion` field alone — read the log tail and require an `❌ Failure - Main …` marker before treating a red as a real failure. | 2026-07-21 | [link](records/ci/killed-job-triage.md) |
| `ci.monitor-armed-at-pr-open` | Arm a CI monitor on the PR head sha the moment the PR opens, polling the commit-status endpoint — not at the end of the work. | 2026-07-21 | [link](records/ci/monitor-armed-at-pr-open.md) |
| `ci.no-host-health-gating` | Push when your work is validated — never SSH to bumblebee to sample load/RAM first, and never hand-schedule around other sessions' runs. | 2026-07-21 | [link](records/ci/no-host-health-gating.md) |
| `ci.peak-anon-measurement` | The `test` job's headline memory figure is a sampled high-water mark of cgroup `anon`, produced by `scripts/ci-peak-anon.sh`; `memory.peak` and the end-of-job `anon`/`file` split are kept only as a cache-inflated reference. | 2026-07-19 | [link](records/ci/peak-anon-measurement.md) |
| `ci.root-screenshot-guard` | The Husky `pre-commit` hook refuses a staged root-level `*.png` (belt-and-suspenders with the `.gitignore` rule); nested `*.png` real assets are unaffected. | 2026-07-12 | [link](records/ci/root-screenshot-guard.md) |
| `ci.runner-placement` | No persistent Roslyn compiler server survives a CI build (`UseSharedCompilation=false` etc., runner env + Dockerfile `ENV`); every `services:` container gets its own explicit `--memory`/`--memory-swap`/`--cpus` cap (it does not inherit the job container's). | 2026-07-17 | [link](records/ci/runner-placement.md) |
| `ci.small-lane-git-only` | `runs-on: small` is defined by what a job does (git-only), not its usual runtime; the two `docker build` jobs (docker-build.yml, ci-image.yml) move to `ubuntu-latest` because their worst-case memory, not median runtime, was pinning the small lane's per-slot cap. | 2026-07-20 | [link](records/ci/small-lane-git-only.md) |
| `ci.ui-e2e-harness` | The UI-interactive E2E flows run as headless Playwright specs (`web/e2e/*.spec.ts`, driven by `scripts/e2e-ui.sh`) in a **second step of the existing advisory `functional-e2e` job**, never their own job; the browser is `chromium-headless-shell` **baked into the CI toolchain image** (`docker/ci/Dockerfile`, `PLAYWRIGHT_VERSION` kept equal to `web/package.json`'s EXACT `@playwright/test` pin), never installed per run; specs are `serial` with `retries: 0` and assert only contracts the curl harness structurally cannot reach. | 2026-07-25 | [link](records/ci/ui-e2e-harness.md) |
| `ci.verify-locally-ci-confirms` | Treat the local build/verify/review pass as the decision point and CI as confirmation — don't idle waiting on a run you have no reason to doubt. | 2026-07-21 | [link](records/ci/verify-locally-ci-confirms.md) |
| `ci.web-test-per-test-timeouts` | Give heavy-render web tests an explicit per-test vitest timeout (e.g. 15s); never raise the global default to fix one slow test. | 2026-07-21 | [link](records/ci/web-test-per-test-timeouts.md) |
| `concurrency.diff-scalar-fanout` | The frozen Block optimistic-concurrency recipe (api-conventions §7a) fans out to Collection/Playout×2/MultiCollection/RerunCollection, keeping a guard-returned `PreconditionFailedError` out of any handler's generic `catch(Exception)`→422 mapping, and preserving each aggregate's existing `SaveChangesAsync() > 0` gate semantics under the new unconditional `Version++`. | 2026-07-11 | [link](records/concurrency/diff-scalar-fanout.md) |
| `concurrency.etag-rotation-completion` | Every handler that mutates a versioned root's editor-visible config state must bump `Version` (rotating the ETag) with no per-aggregate carve-outs, short-circuiting on a genuine no-op before the bump so idempotent re-submits don't fire spurious rebuild fan-out; `SaveChangesForcingVersion` rebases the retry (stored + pending delta), never adopts the stored token verbatim. | 2026-07-12 | [link](records/concurrency/etag-rotation-completion.md) |
| `concurrency.force-write-non-ifmatch` | Any handler that leaves a versioned root `Modified` or `Deleted` but takes no `If-Match` (deletes, item add/remove bumpers, scalar-config writers) must save through `ConcurrencyExtensions.SaveChangesForcingVersion` — force-write past a concurrent `Version` bump rather than throw an unhandled `DbUpdateConcurrencyException` (500). | 2026-07-12 | [link](records/concurrency/force-write-non-ifmatch.md) |
| `concurrency.idempotent-concurrent-add` | A concurrent duplicate `Add*ToCollection` that loses the race on the composite-key unique constraint is treated as an idempotent no-op (skip the reindex/rebuild fan-out), not a 500 — detected via a provider-specific `TvContext.IsUniqueConstraintViolation` delegate defaulting to "no". | 2026-07-18 | [link](records/concurrency/idempotent-concurrent-add.md) |
| `concurrency.ifmatch-rfc7232` | `ConcurrencyHeaders.ParseIfMatch` is a real RFC 7232 entity-tag/list parser: a syntactically-valid tag that doesn't strong-match (weak/empty/non-canonical/out-of-range/list) returns 412, and only a genuine grammar violation returns 400. | 2026-07-12 | [link](records/concurrency/ifmatch-rfc7232.md) |
| `concurrency.replace-all-contract` | Replace-all aggregate PUTs carry a uniform plain `int Version` concurrency token (EF `.IsConcurrencyToken()`), checked pre-save and enforced by the EF UPDATE guard, returning 412 (not 409) on a stale `If-Match`. | 2026-07-11 | [link](records/concurrency/replace-all-contract.md) |
| `concurrency.schedule-item-child-identity` | `PUT /api/schedules/{id}/items` reconciles by an optional round-tripped child `Id` (null/absent/0 ⇒ new item), never by array position, so fill-group/shuffle state follows the logical item across reorders; an unknown or duplicate id is rejected 422 (checked after the §7a `CheckVersion`, so 412 precedes 422). | 2026-07-11 | [link](records/concurrency/schedule-item-child-identity.md) |
| `docs.convention-docs-session-start` | Docs-first, not source-first: conventions (api-conventions, spa-conventions, e2e-local, blazor-route-parity, domain-model, decisions, README) are read from docs, not reverse-engineered from code, via `docs/README.md`'s task-signal map — only the sections it points to for the task at hand, not the whole set. Each doc is updated in the same PR that changes what it documents, replacing deferred/follow-up doc updates. | 2026-07-07 | [link](records/docs/convention-docs-session-start.md) |
| `docs.decision-lifecycle` | every decision `##` record (active or archived) carries a 5-field metadata block (`key`, `status`, `since`, `supersedes`, `superseded-by`) checked by `scripts/decisions_validate.py`; a record is never deleted or line-edited to reverse a call — it is moved to `docs/decisions/archive/` with `status: superseded`/`retired` and a reciprocal `superseded-by`/`supersedes` key pair to its replacement. | 2026-07-21 | [link](records/docs/decision-lifecycle.md) |
| `docs.decision-one-file-per-record` | Each decision record is its own file at `docs/decisions/records/<area>/<topic>.md` (archived ones at `docs/decisions/archive/<area>/<topic>.md`) with YAML frontmatter; the filename IS the key, so one-active-record-per-key is a filesystem property rather than a validator check, and supersession is a `git mv`. | 2026-07-25 | [link](records/docs/decision-one-file-per-record.md) |
| `docs.decision-optional-provenance` | Decision records gain two OPTIONAL fields — `stale-after: YYYY-MM-DD` on the metadata line and a `**Sources:**` line in the metadata block; the Open Knowledge Format (OKF) itself is NOT adopted as the record format. | 2026-07-25 | [link](records/docs/decision-optional-provenance.md) |
| `docs.tracker-comment-retrofit` | When the knowledge exporter flags an over-cap tracker issue and excludes it from ingestion, triage its comments instead of assuming a retrofit is owed — and for each decision-shaped item check the **worked issue first**, because a tracker session comment is by construction a précis of the fuller closing record posted on the issue it narrates. Applied to #237 (111 comments) this yielded **zero** records, so server-management#642's "a fact found only in a #237 comment" retrieval row has no valid subject and its interim target (an already-migrated record) is permanent. | 2026-07-21 | [link](records/docs/tracker-comment-retrofit.md) |
| `ffmpeg.external-logo-graphics-engine` | External-URL channel logos pass through to the graphics engine like any other watermark source; `WatermarkSelector` must never gate them on `File.Exists` (always false for a URL) and never route them through the ffmpeg-native overlay shortcut. | 2026-07-20 | [link](records/ffmpeg/external-logo-graphics-engine.md) |
| `ffmpeg.hls-cold-start-burst` | HLS cold-start latency is fixed with a bounded `-readrate_initial_burst` (gated on FFmpeg ≥6.1 capability detection), not by raising `work_ahead_limit`, which would remove the concurrency guarantee it exists for. | 2026-07-20 | [link](records/ffmpeg/hls-cold-start-burst.md) |
| `ffmpeg.qsv-decode-encode-split` | QSV decode is decoupled from QSV encode via a single `FFmpegProfile.QsvPreferNativeDecoder` bool (default ON, Linux-only), so a QSV encode profile can decode with the more tolerant native VA-API decoder instead of the QSV decoder, mirroring Jellyfin's hybrid decode/encode toggle instead of a general decode-family enum. | 2026-07-20 | [link](records/ffmpeg/qsv-decode-encode-split.md) |
| `ffmpeg.qsv-extra-hw-frames-floor` | a QSV upload never emits `extra_hw_frames` below `FFmpegState.MinimumQsvExtraHardwareFrames` (64); a stored `0` or negative value is treated as "no pool configured" rather than honored literally, because with no headroom any unthrottled read exhausts the pool and the transcode writes nothing at all. | 2026-07-21 | [link](records/ffmpeg/qsv-extra-hw-frames-floor.md) |
| `ffmpeg.remote-image-fetcher-bounded` | remote graphics-engine images are fetched through `IRemoteImageFetcher` with a pooled `HttpClientFactory` client, a body-covering deadline, a wire-transfer size cap, and a decoder-enforced `DecoderOptions.MaxFrames` bound re-verified post-decode — never cached, re-fetched per element init. | 2026-07-20 | [link](records/ffmpeg/remote-image-fetcher-bounded.md) |
| `ffmpeg.work-ahead-slot-atomic` | `workAheadSegmenterLimit` is enforced by a single compare-exchange claim on a shared `WorkAheadSlots` pool taken by the *caller* of `Transcode`, which then passes ownership in and gets the release in `Transcode`'s `finally` — never a `Volatile.Read` compare in one place and an `Interlocked.Increment` in another. | 2026-07-21 | [link](records/ffmpeg/work-ahead-slot-atomic.md) |
| `ffmpeg.work-ahead-slot-release-never-negative` | `Release()` reads the count and compare-exchanges `current - 1` only when `current > 0`; a release against an empty pool records an unbalanced release and returns `false` **without ever writing a negative value**. It never decrements first and clamps afterward. The single caller (`HlsSessionWorker.Transcode`'s `finally`) logs a warning on the `false` return. | 2026-07-21 | [link](records/ffmpeg/work-ahead-slot-release-never-negative.md) |
| `graphics.channel-level-attachment` | A channel can attach `GraphicsElement`s directly via a new `ChannelGraphicsElement` join table (a base layer under deco/playout-item elements), and a built-in text element (`on-now-next.yml`) is seeded once per database so the On Now/Next overlay works out of the box. | 2026-07-22 | [link](records/graphics/channel-level-attachment.md) |
| `graphics.channel-logo-caching` | An external `http(s)` channel-logo URL is fetched, decode-budget-validated, and stored in the image cache under a content-hash name at SAVE time — becoming byte-identical to an uploaded logo — so the render path never fetches a logo over HTTP; a bad URL fails the save with a 422 (BaseError → ValidationProblemDetails). | 2026-07-21 | [link](records/graphics/channel-logo-caching.md) |
| `iptv.base-url` | An optional advertised base URL (`iptv.base_url`) is resolved centrally via a pure Core helper (`AdvertisedBaseUrl`) inside the two IPTV generation handlers (M3U + XMLTV); unset/malformed values fall back byte-identical to the request-derived host, and it's a new `iptv` settings group distinct from `ETV_BASE_URL` and out of scope for HDHomeRun. | 2026-07-16 | [link](records/iptv/base-url.md) |
| `iptv.logo-drives-bug-preset` | One uploaded channel logo drives both the listing logo and the on-screen bug via a shared, seeded `ChannelLogo`-sourced watermark preset (`Channel Bug`), not new per-channel schema. | 2026-07-20 | [link](records/iptv/logo-drives-bug-preset.md) |
| `locking.entitylocker-atomic-flags` | `EntityLocker` uses `Interlocked.CompareExchange`-guarded atomic flags plus a documented single-owner-release discipline (no owner tokens/leases); `Unlock*` on an already-unlocked slot returns `false` and logs a Warning rather than throwing. | 2026-07-11 | [link](records/locking/entitylocker-atomic-flags.md) |
| `mcp.server-foundation` | `ErsatzTV.Mcp` is a fresh stdio JSON-RPC server wrapping frozen `/api/v1` with explicit narrow per-endpoint tools, read-only-by-default enforced at runtime (`ERSATZTV_ALLOW_WRITES`), machine-key auth, and opt-in `If-Match`. | 2026-07-20 | [link](records/mcp/server-foundation.md) |
| `media.lastscan-null-boundary` | A never-scanned `LastScan` surfaces as `null` at the API/MCP boundary, not the `0001-01-01` MinValue sentinel — enforced by an ongoing read-boundary coercion plus a one-time data migration cleanup. | 2026-07-18 | [link](records/media/lastscan-null-boundary.md) |
| `media.remote-stream-probe` | `ValidatePlayoutItemPath` probes the Plex/Jellyfin/Emby remote-stream URL via `IRemoteStreamProber` before returning it; only a redirected 404 fails closed (`PlayoutItemNotAvailableFromMediaServer`), everything else fails open, and there is no toggle. | 2026-07-19 | [link](records/media/remote-stream-probe.md) |
| `media.remote-stream-probe-externaljson` | External-JSON playout channels' `StreamRemotely` now probes the remote-stream URL through the same `IRemoteStreamProber` seam as the generated-playout path, closing the #473 scope gap for a channel kind with no DB `PlayoutItem` rows. | 2026-07-20 | [link](records/media/remote-stream-probe-externaljson.md) |
| `media.source-mgmt-write-api` | Media-source management (local/Plex/Jellyfin/Emby) is a REST write API + SPA under `/app/libraries/*`, wrapping existing MediatR commands 1:1 with no new commands or DB migration; connection GETs never leak a stored `apiKey`, and each PUT-replace family's identity contract is documented per-family (not assumed uniform). | 2026-07-11 | [link](records/media/source-mgmt-write-api.md) |
| `process.bom-format-detection-recipe` | Before any push touching `.cs`, detect BOMs with the `xxd` byte check and verify the format gate with `dotnet format --include` run under `bash -c`, never bare zsh. | 2026-07-21 | [link](records/process/bom-format-detection-recipe.md) |
| `process.branch-off-feature-branch` | To fix work on an unmerged feature branch, branch off that branch and land by fast-forward push — and after creating a worktree, drive the first Edit/Read from ITS absolute paths and `git status` it before building. | 2026-07-21 | [link](records/process/branch-off-feature-branch.md) |
| `process.build-concurrency-limits` | Run at most 3–4 concurrent dotnet/npm builds on this Mac, gate launches on FREE RAM rather than CPU load, and never set `ETV_UPDATE_GOLDENS` / `ETV_UPDATE_PLAYOUT_GOLDENS`. | 2026-07-21 | [link](records/process/build-concurrency-limits.md) |
| `process.codex-cheap-worker-launch` | For bounded tool-bearing selector/recon work, launch a Codex worker with `codex exec -m gpt-5.4-mini -c model_reasoning_effort=low -s read-only`; `spawn_agent` buys parallelism but no cost savings. | 2026-07-21 | [link](records/process/codex-cheap-worker-launch.md) |
| `process.consistency-fix-new-code-scrutiny` | Review a "make X consistent with Y" change as new code, not as a mechanical copy — and for any timer or effect involved, ask explicitly "when does this fire?", including on mount. | 2026-07-21 | [link](records/process/consistency-fix-new-code-scrutiny.md) |
| `process.enumerate-workaround-behaviors-before-deleting` | When an issue says "delete X", enumerate every behavior X provided before removing it — a workaround often serves a second purpose that outlives the first. | 2026-07-21 | [link](records/process/enumerate-workaround-behaviors-before-deleting.md) |
| `process.foreign-worktree-plumbing-merge` | Never commit or merge inside a worktree another session created; land the merge with git plumbing against the branch ref instead. | 2026-07-21 | [link](records/process/foreign-worktree-plumbing-merge.md) |
| `process.harden-with-runtime-posture-not-clamp` | When a security fix constrains a capability the roadmap will later want, make the safe state the DEFAULT OF A SWITCH rather than a wall — and read the feature's own issue for its end-state first. | 2026-07-21 | [link](records/process/harden-with-runtime-posture-not-clamp.md) |
| `process.independent-review-rubric` | Run an independent review pass — preferably a different model family, otherwise a cold-context review-only agent — on any diff touching locks/concurrency, auth/security, API write-path handlers, or DB migrations, or larger than ~150 changed C# lines; skip only for a pure-SPA/docs leaf with no server-state effect, and state the skip and its reason in the PR or close comment. | 2026-07-21 | [link](records/process/independent-review-rubric.md) |
| `process.issue-qualification-audit` | Run `scripts/issue-qualification-audit.sh` at session end and label everything it flags, including issues you filed that session. | 2026-07-21 | [link](records/process/issue-qualification-audit.md) |
| `process.local-gate-before-push` | Run the local build/test gate and a cold-context, scoped "review only" adversarial review over the diff, fold the fixes, and only then push or open the PR. | 2026-07-21 | [link](records/process/local-gate-before-push.md) |
| `process.lock-ownership-enumerate-producers` | Before trusting any "single owner / no double release / no cross-release" claim, grep the whole host project for every writer of that channel message (or acquirer of that lock) — the background scheduler/worker is the usual missing producer. | 2026-07-21 | [link](records/process/lock-ownership-enumerate-producers.md) |
| `process.one-worktree-one-committing-agent` | Never run two committing agents concurrently on one worktree — give each parallel slice its own worktree branched off the feature branch and merge back. | 2026-07-21 | [link](records/process/one-worktree-one-committing-agent.md) |
| `process.parallel-session-claim` | Apply the `in-progress` label before starting an issue, and still read its dependency notes before touching shared surfaces — a claim prevents duplicate pickup, not overlapping code changes. | 2026-07-21 | [link](records/process/parallel-session-claim.md) |
| `process.per-agent-model-routing` | State the model tier (and effort, where the client exposes it) in the dispatch itself for every delegated agent — bounded recon → cheapest fast tier at `low`; mechanical slice against a documented contract → mid tier; judgment-heavy work → orchestrator tier; independent review → a different model family than the implementer. | 2026-07-25 | [link](records/process/per-agent-model-routing.md) |
| `process.pr-routine-sequence` | Worktree off origin/main → implement → regenerate API artifacts → full local tests + cold review + live-E2E ALL before the push → push, open PR, arm the CI monitor at open → fixes after the push are follow-up commits, never amend/force-push. | 2026-07-21 | [link](records/process/pr-routine-sequence.md) |
| `process.review-disagreement-frontier-judge` | When independent reviews disagree on a gate PR, escalate to the frontier judge, and put the proposed fix approach in front of it — not just the disputed finding. | 2026-07-21 | [link](records/process/review-disagreement-frontier-judge.md) |
| `process.shared-tree-readonly` | Never commit in `/Users/timothy/ersatztv` and never read its `git log`/`git status`/HEAD to infer anything about `main` — work in a worktree off `origin/main`, which is the only source of truth. | 2026-07-21 | [link](records/process/shared-tree-readonly.md) |
| `process.subagent-drop-resume` | Treat a subagent connection drop as laptop sleep or transient network and re-resume via SendMessage — the work survives. | 2026-07-21 | [link](records/process/subagent-drop-resume.md) |
| `release.api-contract-ci-gate` | A PR touching `ErsatzTV/Controllers/Api/**` or `ErsatzTV.Core/Api/**` must ship regenerated OpenAPI artifacts (`v1.json`, `v1.d.ts`, `endpoint-index.md`) in the same diff, enforced by a blocking `api-docs` CI job that regenerates-and-diffs against a fresh build. | 2026-07-12 | [link](records/release/api-contract-ci-gate.md) |
| `release.done-when-merge-consent` | A PR may merge only when its linked issue's `## Done-when` checklist is fully ticked and the PR's CI is green, enforced by a PreToolUse hook on the Gitea merge tool (deny/allow/ask) plus a pre-push backstop for direct pushes to main. | 2026-07-12 | [link](records/release/done-when-merge-consent.md) |
| `release.format-as-you-touch-rebase` | A blocking `format` CI job runs `dotnet format --verify-no-changes` scoped only to the PR's changed `.cs` files (never the legacy BOM backlog), and a PR branch must be kept current by rebasing on `origin/main` (never merging main in), enforced by `.husky/pre-push` → `prepush-rebase-check.sh`. | 2026-07-12 | [link](records/release/format-as-you-touch-rebase.md) |
| `release.live-e2e-required` | A PR that changes an API write-path handler must include a live-E2E pass (driving the real endpoint/screen and confirming the round-trip through a subsequent read), not only unit/characterization tests, and must state whether live-E2E ran or wasn't required. | 2026-07-12 | [link](records/release/live-e2e-required.md) |
| `release.merge-consent-autogrant` | When Done-when boxes are ticked, CI is green, and a fresh positive Review-verdict references head, the merge-consent hook emits `permissionDecision: allow` to actually suppress the redundant mechanical prompt — the derived state IS the consent, no separate conversational confirmation on that path. | 2026-07-12 | [link](records/release/merge-consent-autogrant.md) |
| `release.migration-rehearsal-prodcopy` | Before promoting a migration-bearing release, rehearse the new image's migrations against a throwaway copy of the latest prod backup (`scripts/migration-smoke.sh`), gating PASS on the migrator's completion log line rather than HTTP readiness alone. | 2026-07-12 | [link](records/release/migration-rehearsal-prodcopy.md) |
| `release.prepush-clean-worktree-guard` | A fail-open pre-push hook blocks a push when any file in the branch's diff vs `origin/main` also has uncommitted working-tree or index changes, since a stale-index commit (e.g. `git reset --soft` + `git add` over an edited-but-unstaged fix) can silently push, CI-test, and get reviewed a different tree than the one on disk. Scope is precise to pushed-diff files; escape hatch `ETV_ALLOW_DIRTY_PUSH=1`. | 2026-07-17 | [link](records/release/prepush-clean-worktree-guard.md) |
| `release.promotion-floating-prod` | Prod tracks the floating `:prod` image reference; a tag build's immutable `:<version>` image is scanned first, then promotion happens via a separate manual `DeployStack`, with daily auto-update only as a fallback — tag with enough runway before 03:00 to avoid an unscanned promotion. | 2026-07-13 | [link](records/release/promotion-floating-prod.md) |
| `release.review-verdict-gate` | A PR may not merge until a `Review-verdict: <MERGEABLE\|APPROVED\|BLOCKED\|NOT-MERGEABLE> @ <head-sha>` comment references the PR's current head sha (short-sha prefix match, line-start marker only, negative wins over positive on the same head); folds into the H6 merge-consent hook as condition (c). | 2026-07-12 | [link](records/release/review-verdict-gate.md) |
| `rulebuilder.relative-date-macros` | The visual rule builder's `inLast`/`notInLast` date operators compile to/parse from the pre-existing `CustomMultiFieldQueryParser` macros `released_inthelast`/`released_notinthelast` and `added_inthelast`/`added_notinthelast`, value form `"<n> day\|week\|month\|year"`; there is no backend change. | 2026-07-23 | [link](records/rulebuilder/relative-date-macros.md) |
| `scan.collections-scan-status` | `GET /api/v1/media-sources/collections-scan-status` reports a family-global (not per-source), boolean-only active-scan set read from `IEntityLocker`; the SPA reconciles authoritatively against it (with a grace-tick helper) instead of a fixed client-side timeout. | 2026-07-12 | [link](records/scan/collections-scan-status.md) |
| `scan.getoraddfolder-db-lookup` | `ILibraryRepository.GetOrAddFolder` resolves the existing folder via a DB query on `(LibraryPathId, Path)`, not the caller's `LibraryPath.LibraryFolders` in-memory navigation, since that navigation is only eager-loaded on the local scan path and is null on remote (Jellyfin) callers. | 2026-07-20 | [link](records/scan/getoraddfolder-db-lookup.md) |
| `scan.jellyfin-mixed-content-library` | A Jellyfin library whose collection type is `mixed` (or absent) maps to one ErsatzTV library of `LibraryMediaKind.Mixed`, scanned by running the movie/television/music-video scanners in sequence against that single library — a library is a place, not a media kind. | 2026-07-20 | [link](records/scan/jellyfin-mixed-content-library.md) |
| `scan.musicvideo-server-identity` | Jellyfin music videos carry a per-library server identity (`JellyfinMusicVideo : MusicVideo` with `ItemId`/`Etag`, TPT table + ItemId index), so `JellyfinMusicVideoLibraryScanner` folds onto a shared `MediaServerMusicVideoLibraryScanner` base that diffs the **server item id** and soft-trashes (`FlagFileNotFound`) instead of diffing local paths and hard-deleting. Rows predating the identity are **adopted in place** — the identity row is inserted against the same `MediaItem` id, scoped to the scanned library's own `LibraryPath` — never deleted and re-added. | 2026-07-25 | [link](records/scan/musicvideo-server-identity.md) |
| `scan.projection-failure-sweep-guard` | `MediaServerReconciliationGuard` takes a per-enumeration projection-failure count and refuses the file-not-found sweep (logged loudly) whenever it is non-zero against a non-empty existing set — at all six sweeps, including the nested per-show season and per-season episode ones #477 left unguarded (via `ShouldFlagMissingDescendants`, which applies the failure refusal but not #477's empty-fetch branch). Deliberate guard-clause skips (STRM, virtual, unsupported type) are explicitly **not** failures and never suppress a sweep. The missing-fraction / ratio threshold floated by #477 is **rejected**, not deferred. | 2026-07-25 | [link](records/scan/projection-failure-sweep-guard.md) |
| `scan.zero-item-fetch-guard` | A media-server library sweep refuses to flag missing items when a successful fetch returns zero incoming items against a non-empty existing set (`MediaServerReconciliationGuard.ShouldFlagMissing`), rather than treating an ambiguous empty result as a full-library deletion. | 2026-07-19 | [link](records/scan/zero-item-fetch-guard.md) |
| `sched.auto-tune-foundation` | Auto-tune preview enumeration uses EF distinct+count queries for exact counts, while each created channel is persisted as a live SmartCollection; coexistence with existing channels/numbers is additive-only, never mutating. | 2026-07-16 | [link](records/sched/auto-tune-foundation.md) |
| `sched.autotune-detailpanel-members` | The Auto-Tune DetailPanel's per-channel content-source list is a live `ISearchIndex.Search` roll-up through the server-owned `AutoTuneAxisMap.GenerateQuery`, not an EF distinct+count query, so the preview matches exactly what the built channel's SmartCollection will contain. | 2026-07-17 | [link](records/sched/autotune-detailpanel-members.md) |
| `sched.autotune-per-channel-overrides` | Auto-Tune per-channel overrides reuse the Channel Builder's advanced-options DTO verbatim; per-source weights and bug-colour logo are deferred to #425. | 2026-07-17 | [link](records/sched/autotune-per-channel-overrides.md) |
| `sched.autotune-per-source-weights` | Auto-Tune per-source rotation weights and query corrections are supplied at bulk-create time via #70's MultiCollection/SmartCollection machinery, not a post-hoc PUT. | 2026-07-18 | [link](records/sched/autotune-per-source-weights.md) |
| `sched.clock-padding-existing` | Clock-boundary padding already exists via `FillerPreset`'s `FillerMode.Pad` (Classic) and `pad_to_next`/`pad_until` (Sequential/YAML); #77 is closed as verified+documented, not built new, with a one-click per-channel toggle deferred behind the #388 design-system epic. | 2026-07-17 | [link](records/sched/clock-padding-existing.md) |
| `sched.clock-padding-schedule-toggle` | A `ProgramSchedule.PadToNearestMinute` (nullable int; null = off) makes the Classic builder pad every content item up to the next N-minute clock boundary without a hand-wired Pad `FillerPreset`, by reusing the existing per-content-item Pad path in `PlayoutModeSchedulerBase.AddFiller`. It extends — does not supersede — `sched.clock-padding-existing` (#77/#388). | 2026-07-22 | [link](records/sched/clock-padding-schedule-toggle.md) |
| `sched.playbackorder-support-matrix` | Every build-time dispatch site logs a loud (non-fatal) warning on an unsupported `PlaybackOrder`, and a declared `PlaybackOrderSupport` matrix + partition tripwire test makes adding a new order safe by construction. | 2026-07-18 | [link](records/sched/playbackorder-support-matrix.md) |
| `sched.reshuffle-scoped-reset` | `POST /api/v1/playouts/{id}/reshuffle` runs `ErasePlayoutHistory` (reseeds `Playout.Seed` + clears anchors/rerun-history) then enqueues a scoped `Reset` build, so reshuffle always reseeds — even for the non-Classic kinds `Reset` alone wouldn't reseed; `Playout.Seed` is surfaced on list/detail DTOs as visible confirmation. | 2026-07-16 | [link](records/sched/reshuffle-scoped-reset.md) |
| `sched.seasonal-scheduling-existing` | Seasonal/date-conditional scheduling already ships first-class via `IAlternateScheduleItem` (Classic `ProgramScheduleAlternate`, Block `PlayoutTemplate`) evaluated by `AlternateScheduleSelector.GetScheduleForDate` (first match in `Index` order, catch-all last); #73 is closed as already-implemented with a docs-only "seasonal/holiday" recipe added, not new code. | 2026-07-17 | [link](records/sched/seasonal-scheduling-existing.md) |
| `sched.shuffle-source-builder` | Shuffle-source construction moves to a static, DI-free `ShuffleSourceBuilder` (a shared seam, not a service) so Classic and Playlist stop cross-engine reaching into `PlayoutBuilder` statics; a unified Classic+Playlist enumerator factory is explicitly rejected as a god-factory. Block/Scripted/YAML duplication is left alone, deferred to a follow-up gated on #381. | 2026-07-17 | [link](records/sched/shuffle-source-builder.md) |
| `sched.weighted-shuffle` | Fair-share/weighted airtime distribution ships as one new `PlaybackOrder.WeightedShuffle = 9` order (equal weights = fair-share), not a retrofit of `ShuffleInOrder` (which only anti-clumps, since its padding spacers emit nothing) and not a separate orthogonal "distribution" setting; weights live on `MultiCollectionItem`/`MultiCollectionSmartItem` (DB default 1, dual-provider migration), bounded at write (1..1000) and clamped again in the enumerator, and the write path rejects `WeightedShuffle` at every dispatch site that doesn't handle it rather than let it silently degrade to unweighted random. | 2026-07-17 | [link](records/sched/weighted-shuffle.md) |
| `sched.weightedshuffle-editor` | WeightedShuffle per-source weights are edited on the multi-collection editor (property of the MultiCollection), while the WeightedShuffle order itself is offered only on classic MultiCollection schedule items; fair-share is a "reset weights to 1" action, not a stored mode. | 2026-07-19 | [link](records/sched/weightedshuffle-editor.md) |
| `scheduling.ondemand-guide-refresh-on-thaw` | When `PlayoutTimeShifter.TimeShift` slides an on-demand playout's materialized timeline forward on tune-in, it reports the channel numbers whose cached guide is now stale — the shifted channel **plus any channels that mirror it** — and `TimeShiftOnDemandPlayoutHandler` enqueues a `RefreshChannelData` for each, so every affected cached XMLTV fragment is regenerated from the just-shifted `PlayoutItem` rows. The guide and playback both read the same stored `PlayoutItem.Start/Finish`, but the guide is served from a **cached** projection — rewriting the rows without rebuilding the cache would leave the guide advertising a stale timeline. | 2026-07-21 | [link](records/scheduling/ondemand-guide-refresh-on-thaw.md) |
| `security.artwork-content-type-sniff` | Artwork content type is always derived from the stored bytes (never the client-declared value or a `?contentType=` query param) at both upload and serve, clamped to an image allow-list, closing the unauthenticated stored-XSS chain; Kestrel `MaxRequestBodySize` bounds upload DoS. | 2026-07-12 | [link](records/security/artwork-content-type-sniff.md) |
| `security.baseline-response-headers` | `SecurityHeadersMiddleware`, registered first in the pipeline, sets `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, and `Referrer-Policy: strict-origin-when-cross-origin` on every response (CSP/HSTS deliberately deferred); API-key comparison is constant-time and playout pagination is clamped. | 2026-07-11 | [link](records/security/baseline-response-headers.md) |
| `security.blazor-removal-auth-posture` | Removing the Blazor UI's OIDC-challenged surface exposes nothing a user couldn't already reach via the already-open `/app` SPA (open since phase (a)); real SPA/API authentication is deliberately deferred to #197, and the removal PR must preserve `ConditionalIptvAuthorizeFilter`, `ApiKeyAuthorizationFilter`, and `JwtHelper` access_token support. | 2026-07-11 | [link](records/security/blazor-removal-auth-posture.md) |
| `security.contract-freeze-honesty` | The OpenAPI doc's declared security/401 scheme is generated from the same `ApiKeyAuthorizationFilter.EndpointRequiresKey` predicate the runtime enforces (so declared auth can't drift from enforced auth), every `/api/*` action returns a ResponseModel (no raw Application VMs), and Channel REST resources are keyed by immutable `Id`, never mutable `Number`. | 2026-07-12 | [link](records/security/contract-freeze-honesty.md) |
| `security.corp-same-origin` | `SecurityHeadersMiddleware` sends `Cross-Origin-Resource-Policy: same-origin` on every response including `/docs`/`/openapi`, blocking cross-origin `no-cors` embedding without affecting allowed CORS-mode fetches or server-side Jellyfin `/iptv/*` requests. | 2026-07-13 | [link](records/security/corp-same-origin.md) |
| `security.csp-permissions-policy` | `SecurityHeadersMiddleware` sends an enforcing (not report-only) `Content-Security-Policy` (no `unsafe-inline`/`unsafe-eval`; the one inline theme-bootstrap script allow-listed by hash) and a deny-all `Permissions-Policy` on the SPA/`/api`/`/artwork`/`/iptv`; `/docs` and `/openapi` keep only the baseline headers, excluded from CSP because Scalar needs inline bootstrap. | 2026-07-12 | [link](records/security/csp-permissions-policy.md) |
| `security.fail-closed-api-auth` | Every mutating `/api` request requires `X-Api-Key` (no open mode); reads are gated by `Api:RequireKeyForReads` (default true) OR `[RequiresApiKey]` on sensitive controllers; CORS is an exact-origin allowlist (`ApiCors`); `ForwardedHeaders` trust stays configurable but defaults to trust-all-with-warning. | 2026-07-12 | [link](records/security/fail-closed-api-auth.md) |
| `security.iptv-access-token-transport` | The `/iptv` `?access_token=` value is percent-encoded (`Uri.EscapeDataString`) everywhere it is interpolated into an M3U/HLS/XMLTV URL (XMLTV additionally XML-escapes the encoded value), so a structural character can't malform the manifest or guide; Serilog logs a scrubbed request path (`access_token` → `***` via `IncludeQueryInRequestPath = false` + a `RequestPathScrubbed` enricher), so a 5xx/Debug `/iptv` request never writes the token; and every dynamic token-bearing `/iptv` manifest (`channels.m3u`, `xmltv.xml`, the HLS multi-variant/media playlists) returns `Cache-Control: private, no-store`. | 2026-07-23 | [link](records/security/iptv-access-token-transport.md) |
| `security.iptv-browser-token` | Under a JWT-enabled deployment (`JWT:IssuerSigningKey` set), the browser SPA obtains a short-lived, globally-scoped `/iptv/*` access token from an authenticated `GET /api/v1/auth/iptv-token` and appends it as `?access_token=`; the endpoint answers 204 when JWT is disabled (nothing to mint). Lifetime defaults to 60 min, configurable via `JWT:BrowserTokenLifetimeMinutes`. | 2026-07-22 | [link](records/security/iptv-browser-token.md) |
| `security.session-auth-dual-credential` | `ApiAuthorizationFilter` accepts a request when a valid `X-Api-Key` matches OR the principal is an authenticated session (cookie `ctv-session`, `HttpOnly`/`SameSite=Lax`); session-authenticated mutations require the presence-only `X-CSRF` header or are rejected 403. This narrows the OIDC-inert sub-claim of `security.blazor-removal-auth-posture` (#206) — the rest of that record's auth-surface enumeration still holds. | 2026-07-12 | [link](records/security/session-auth-dual-credential.md) |
| `security.session-cutover-postify` | The browser SPA authenticates cookie-only (no more `X-Api-Key` from `web/`); the machine key is repurposed to external/MCP-only via `GET /api/auth/machine-key`; every side-effecting GET/HEAD under `/api` is converted to POST so the existing CSRF gate covers it (standing rule: never add a side-effecting GET/HEAD under `/api`). | 2026-07-12 | [link](records/security/session-cutover-postify.md) |
| `session.shared-checkout-refresh` | Session end runs `scripts/refresh-shared-checkout.sh`, which fast-forwards `/Users/timothy/ersatztv` to `origin/main` (and reinstalls `web/node_modules` when the lockfile moved), refusing to touch anything unless that tree is on a clean, non-ahead `main`. | 2026-07-21 | [link](records/session/shared-checkout-refresh.md) |
| `spa.add-to-layer` | All add-to-collection/playlist/schedule affordances share one component layer at `web/src/media/addTo/`; multi-select is an explicit screen-level toggle, and the per-card menu offers schedule only for the server-validated kinds. | 2026-07-10 | [link](records/spa/add-to-layer.md) |
| `spa.app-shell-extraction` | `App.tsx` is only the composition root over `web/src/app/routes.tsx` (stable route-object identity), `app/AppShell.tsx` (shell chrome), and `app/ScreenContent.tsx` (exhaustive screen dispatch); primary actions are one explicit `PrimaryActionProvider` registration per screen, replacing the old global `ctv:primary-action` window event. | 2026-07-15 | [link](records/spa/app-shell-extraction.md) |
| `spa.autotune-detailpanel-slideover` | The Auto-Tune DetailPanel SPA is a reusable `SlideOver` primitive sharing `useOverlayBehavior` with `Dialog`, plus a shared advanced-options model extracted from ChannelBuilder; decorative panes without backend support are dropped. | 2026-07-18 | [link](records/spa/autotune-detailpanel-slideover.md) |
| `spa.channel-editor-create-logo` | Bare-channel create is a "New blank channel" action on the channels list (reusing Blazor's add-mode defaults) that navigates into the full editor, and an external logo URL always wins over an uploaded logo, matching `ChannelEditViewModel` precedence. | 2026-07-11 | [link](records/spa/channel-editor-create-logo.md) |
| `spa.channel-renumber-prompt` | Channel renumbering uses a sequential `prompt()`-driven "Renumber" action instead of drag-to-reorder. | 2026-07-09 | [link](records/spa/channel-renumber-prompt.md) |
| `spa.channels-screen-extraction` | The Channels domain is a single-file zero-prop screen (`web/src/screens/ChannelsScreen.tsx`) with a colocated test file and no sibling helper directory, since its pure logic is too small (~30 lines) to justify a separate business-rule layer like Schedules' `itemRules.ts`. | 2026-07-11 | [link](records/spa/channels-screen-extraction.md) |
| `spa.collection-custom-order-ui` | Collection custom ordering uses per-row Move up/Move down buttons (not drag) and is offered for any manual collection with custom order enabled, not just movies-only. | 2026-07-09 | [link](records/spa/collection-custom-order-ui.md) |
| `spa.datetime-local-input` | The channel-mode date/time input uses a native `<input type="datetime-local">` instead of free-text Chronic natural-language parsing. | 2026-07-09 | [link](records/spa/datetime-local-input.md) |
| `spa.deco-templates-table` | The deco-templates editor also renders its day/deco assignment as a table, extending (not replacing) the templates-editor-table convention. | 2026-07-09 | [link](records/spa/deco-templates-table.md) |
| `spa.download-sample-gate` | The SPA disables both Download Media Sample and Download Results while a troubleshooting session is starting/running (Blazor only gated Download Results). | 2026-07-09 | [link](records/spa/download-sample-gate.md) |
| `spa.legacy-redirect-matcher` | `LegacyUiRedirects.TryGetRedirect` is a two-tier matcher — an exact `OrdinalIgnoreCase` `Map` (Tier 1) then an ordered segment-template pattern list (Tier 2, first-match-wins) — collision-free by construction, with a guard invariant that no rule may prefix-match `/api`, `/artwork`, `/docs`, `/openapi`, `/iptv`, `/app`, or `/media/sources`. | 2026-07-11 | [link](records/spa/legacy-redirect-matcher.md) |
| `spa.logs-page-size-local` | The Logs page rows-per-page preference is stored in `window.localStorage` (`ctv-logs-page-size`), not a server `ConfigElement`. | 2026-07-11 | [link](records/spa/logs-page-size-local.md) |
| `spa.playback-troubleshoot-poll` | The playback-troubleshooting screen reports FFmpeg completion by polling `GET /api/troubleshoot/playback/status` (~2s) rather than a server push channel. | 2026-07-09 | [link](records/spa/playback-troubleshoot-poll.md) |
| `spa.playout-reset-button` | The SPA keeps a single Reset action (server picks the default build mode) and drops Blazor's separate "Schedule reset" button since its capability already exists via the playout's Edit-details flow. | 2026-07-09 | [link](records/spa/playout-reset-button.md) |
| `spa.playouts-screen-extraction` | The Playouts domain (including its unguarded `PlayoutsRouteScreen` route wrapper with local pathname/popstate state) moved as one unit into `web/src/screens/PlayoutsScreen.tsx`, keeping its screen-specific sub-path route ownership colocated with the base screen; a pure structural move with no API/route/CSS/behavior change. | 2026-07-14 | [link](records/spa/playouts-screen-extraction.md) |
| `spa.rulebuilder-nesting` | The visual rule builder's `Group` nests recursively to a single shared cap, `MAX_GROUP_DEPTH` (`types.ts`, currently 5, root group = depth 0) — read by the UI's "Add group" gate, `parse.ts` and the round-trip property-test generator alike; everything else about the builder is unchanged from #176 (compile-only closed Lucene subset over the stored query string, no stored rule AST, field vocabulary from `GET /api/v1/search/fields`). | 2026-07-25 | [link](records/spa/rulebuilder-nesting.md) |
| `spa.schedules-editor-draft-save` | The schedules SPA editor mutates a local draft and flushes one explicit Save (`PUT /api/schedules/{id}/items`) instead of instant-persisting each action; Copy deep-copies all source references (fixing a Blazor omission); the shuffled-schedule GET's `EnforceProperties` lossy normalization is preserved and mirrored in the SPA's option lists. | 2026-07-11 | [link](records/spa/schedules-editor-draft-save.md) |
| `spa.sidebar-collapsible-accordions` | The shell sidebar's collapse + nav-group-accordion state persists under two hyphenated `ctv-sidebar-*` localStorage keys (matching the repo's `ctv-` convention, not the prototype's dotted names); labeled groups default-collapsed. | 2026-07-18 | [link](records/spa/sidebar-collapsible-accordions.md) |
| `spa.spa-rebuild-decision` | The UI is a full React SPA (ChicoryTV) rebuild over the REST API, not a Blazor Server reskin. | 2026-06 | [link](records/spa/spa-rebuild-decision.md) |
| `spa.templates-editor-table` | The SPA templates editor renders day/block assignment as a table, not Blazor's drag-and-drop calendar grid — an accepted, deliberate parity deviation. | 2026-07 | [link](records/spa/templates-editor-table.md) |
| `spa.topbar-primary-action` | The TopBar's primary-action "+" button renders only when the active route declares a non-empty `primaryAction`, is wired (via a shared `usePrimaryAction` hook) only on single-unambiguous-create-flow list screens, and is dropped everywhere else rather than left as a dead/no-op button. | 2026-07-12 | [link](records/spa/topbar-primary-action.md) |
| `spa.yaml-validator-textarea` | The YAML playout validator takes pasted YAML via a `<textarea>`, not a server-side file path, since the SPA has no filesystem access. | 2026-07-09 | [link](records/spa/yaml-validator-textarea.md) |
| `startup.parallel-orientation` | A fresh session runs two concurrent tracks at startup — Orientation (`AGENTS.md`/`CLAUDE.md` → `docs/README.md` task-signal map → the active decisions catalog `docs/decisions/README.md`) and, only when no issue is named, Selection (`scripts/select-queue.sh N`, deterministic live-Gitea ranking). A named issue skips Selection entirely. ersatztv#237, the closed pickup tracker this replaces, is reduced to a single archival breadcrumb and MUST NOT be read for live state. | 2026-07-21 | [link](records/startup/parallel-orientation.md) |
| `testing.e2e-cleanup-scope-by-pid` | An E2E harness or agent may only kill processes whose PIDs it captured at launch — capture the PID; whoever owns the lifecycle releases it from a `trap ... EXIT INT TERM`. Never `pkill -f "dotnet ErsatzTV.dll"` (or any pattern that can match a process this run did not start). A foreign listener is reported, not reaped. | 2026-07-25 | [link](records/testing/e2e-cleanup-scope-by-pid.md) |
| `testing.e2e-local-fresh-config-dir` | Always point `scripts/e2e-local.sh` at a fresh config dir — leftover channels/schedules/DB rows bleed state between runs and corrupt assertions. (The *readiness-probe hang* this record was originally written about was fixed in #533; the fresh-dir rule stands on state-bleed grounds alone.) | 2026-07-21 | [link](records/testing/e2e-local-fresh-config-dir.md) |
| `testing.live-e2e-prepush-timing` | Run live-E2E via `scripts/e2e-local.sh` before pushing a write-path or UI change, and exercise download endpoints with curl, never a browser tab. | 2026-07-21 | [link](records/testing/live-e2e-prepush-timing.md) |
| `testing.playwright-mcp-download-and-recovery` | In Playwright-MCP E2E, fetch file-download endpoints with curl — never a browser tab or `window.open` — and if browser tools stall repeatedly, `pkill -f ms-playwright-mcp` and drive a fresh session. | 2026-07-21 | [link](records/testing/playwright-mcp-download-and-recovery.md) |
| `testing.scripted-playout-golden-deferred` | The `PlayoutBuildGoldenTests` in-memory golden net covers Sequential (YAML) as of #381. Scripted's *end-to-end pipeline* is excluded — `ScriptedPlayoutBuilder` runs a user-authored external program that drives the engine over HTTP loopback, which the in-memory harness can't pin — so that full-pipeline (integration) harness is deferred to #563. But the scheduling *behavior* those scripts drive lives entirely in the in-process `SchedulingEngine` (the `ScriptedScheduleController` is a 1:1 pass-through to it), which IS directly unit/golden-testable; the earlier "Scripted is un-golden-able by construction" framing overstated the constraint by conflating transport with engine. #395 extracts that shared switch to `ContentEnumeratorBuilder` and adds a direct regression net (`ContentEnumeratorBuilderTests`) over it. | 2026-07-22 | [link](records/testing/scripted-playout-golden-deferred.md) |
| `testing.troubleshoot-path-cannot-test-branding` | Verify logo/watermark/bug changes through a real channel playout — a green troubleshoot run proves nothing about branding. | 2026-07-21 | [link](records/testing/troubleshoot-path-cannot-test-branding.md) |
## Review due
@@ -180,6 +181,6 @@ record. Sorted soonest-first.
| Stale after | Key | Record |
| --- | --- | --- |
| 2027-01-15 | `ci.runner-placement` | [link](../decisions.md#2026-07-17--no-persistent-compiler-servers-in-ci-every-services-container-gets-an-explicit-cap-390s-small-lane-move-reversed-406) |
| 2027-02-15 | `ci.infra-shaped-red-under-load` | [link](workflow-process.md#2026-07-21--an-infra-shaped-red-under-host-load-is-not-a-code-failure-542) |
| 2027-03-15 | `ci.peak-anon-measurement` | [link](../decisions.md#2026-07-19--ci-test-job-reports-a-sampled-true-peak-anon-not-cache-inflated-memorypeak-412) |
| 2027-01-15 | `ci.runner-placement` | [link](records/ci/runner-placement.md) |
| 2027-02-15 | `ci.infra-shaped-red-under-load` | [link](records/ci/infra-shaped-red-under-load.md) |
| 2027-03-15 | `ci.peak-anon-measurement` | [link](records/ci/peak-anon-measurement.md) |
+15 -508
View File
@@ -13,515 +13,22 @@ contract-freeze), #206 (Blazor-removal auth posture), #283 (artwork content-type
## Contents
- [2026-07-11 — Blazor removal auth posture: no new exposure beyond phase (a); real auth deferred to #197 (#206)](#2026-07-11--blazor-removal-auth-posture-no-new-exposure-beyond-phase-a-real-auth-deferred-to-197-206)
- [2026-07-11 — Baseline security response headers + Phase-0 API hardening (#197, PR #279)](#2026-07-11--baseline-security-response-headers--phase-0-api-hardening-197-pr-279)
- [2026-07-12 — Artwork content-type is sniffed, never reflected (#283, S4/S9 stored XSS)](#2026-07-12--artwork-content-type-is-sniffed-never-reflected-283-s4s9-stored-xss)
- [2026-07-12 — Fail-closed API auth + sensitive-read tier + CORS/ForwardedHeaders lockdown (#197 Bundle A, PR #292)](#2026-07-12--fail-closed-api-auth--sensitive-read-tier--corsforwardedheaders-lockdown-197-bundle-a-pr-292)
- [2026-07-12 (#197 Bundle C — contract-freeze honesty)](#2026-07-12-197-bundle-c--contract-freeze-honesty)
- [2026-07-12 — Browser SPA session auth: `/api` accepts session OR machine key (#295 PR1, server-only)](#2026-07-12--browser-spa-session-auth-api-accepts-session-or-machine-key-295-pr1-server-only)
- [2026-07-12 — #295 PR2: SPA session cutover + #301 side-effecting-GET POST-ification](#2026-07-12--295-pr2-spa-session-cutover--301-side-effecting-get-post-ification)
- [2026-07-12 — Enforcing CSP + Permissions-Policy on the host (#319, ZAP baseline)](#2026-07-12--enforcing-csp--permissions-policy-on-the-host-319-zap-baseline)
- [2026-07-13 — Cross-origin resource policy: `same-origin` on every response (#330)](#2026-07-13--cross-origin-resource-policy-same-origin-on-every-response-330)
- [2026-07-22 — Short-lived browser IPTV token so the SPA reaches `/iptv/*` under JWT auth (#552)](#2026-07-22--short-lived-browser-iptv-token-so-the-spa-reaches-iptv-under-jwt-auth-552)
- [2026-07-23 — access_token transport hardening: percent-encode in M3U/HLS, redact from logs, no-store on tokened manifests (#421, #559)](#2026-07-23--access_token-transport-hardening-percent-encode-in-m3uhls-redact-from-logs-no-store-on-tokened-manifests-421-559)
---
## 2026-07-11 — Blazor removal auth posture: no new exposure beyond phase (a); real auth deferred to #197 (#206)
`key: security.blazor-removal-auth-posture` · `status: active` · `since: 2026-07-11` · `supersedes: none` · `superseded-by: none`
**Rule:** Removing the Blazor UI's OIDC-challenged surface exposes nothing a user couldn't already reach via the already-open `/app` SPA (open since phase (a)); real SPA/API authentication is deliberately deferred to #197, and the removal PR must preserve `ConditionalIptvAuthorizeFilter`, `ApiKeyAuthorizationFilter`, and `JwtHelper` access_token support.
**Signals:** Blazor removal, auth posture sign-off, OIDC attachment point · paths: `ErsatzTV/Startup.cs`, `ErsatzTV/Pages` · issues: #206, #91, #197
**Mechanics:** `ErsatzTV/Startup.cs` (Razor Pages/OIDC registration); #91 phase (b) removal PR
## Records formerly in this file
Sign-off for the #91 phase (b) removal-gate item #206 ("deleting the last challenged Blazor page leaves
only the open SPA"). The actual authorization wiring in `ErsatzTV/Startup.cs` + `ErsatzTV/Pages` was
enumerated in code (not assumed) before clearing the gate.
Each record below moved to its own file under `records/` (ersatztv#610); the rationale is
unchanged. Resolve by **key** — that is the stable identity. A date-based pointer from
another doc or an old issue comment should land here and then follow the link.
**What is gated today**
- **OIDC** (`OidcHelper.IsEnabled` — active only when `Authority`/`ClientId`/`ClientSecret` are configured):
`AddAuthentication` (cookie default, `oidc` challenge) + `AddAuthorization` `DefaultPolicy =
RequireAuthenticatedUser` + **`AddRazorPages(… AuthorizeFolder("/"))`** (Startup.cs:379-385) +
`blazor.UseAuthentication()/UseAuthorization()` inside the Blazor `MapWhen` branch (Startup.cs:764-770).
`AuthorizeFolder("/")` gates **Razor Pages only**, and the sole user-facing Razor Page is
`Pages/_Host.cshtml` — the Blazor Server host (the other `.cshtml`, `Shared/_Favicons.cshtml`, is a
cosmetic partial). **So the OIDC challenge protects exactly the Blazor UI and nothing else.**
- **`/app` (SPA)** is served by its own `MapWhen(path=/app)` static-file branch (Startup.cs:701-714) with
**no authentication/authorization middleware** — open since phase (a) (`/`→`/app`, PR #148).
- **`/api/*` controllers** carry no `[Authorize]` (verified: zero attributes in `Controllers/`); the
Razor-Pages `AuthorizeFolder`/`DefaultPolicy` never reach them. Their only optional gate is the
per-endpoint `ApiKeyAuthorizationFilter` (API-key on mutating JSON endpoints), independent of OIDC/Blazor.
- **`/iptv/*`** is gated by `ConditionalIptvAuthorizeFilter` (JWT `JwtOnlyScheme`, active only when
`JwtHelper.IsEnabled`) in its own `MapWhen` branch (Startup.cs:797-803) — independent of Blazor.
**Posture after Blazor removal.** Removing `Pages/_Host.cshtml`, `AddRazorPages`/`AuthorizeFolder("/")`,
`blazor.UseAuthentication/UseAuthorization`, `MapBlazorHub`, and `MapFallbackToPage("/_Host")` deletes the
OIDC challenge's **only attachment point** — no user-facing surface remains challenged. **No capability is
lost:** every Blazor-served capability already has an open SPA equivalent (the #91 parity effort), and the
SPA was already the unauthenticated path since phase (a), so removal exposes nothing a user could not already
reach via `/app`.
**The one honest caveat (not a regression introduced by removal):** an OIDC-configured operator's *Blazor*
admin UI sits behind a login today; after removal there is no login-gated admin UI at all (the SPA admin UI
is open). That exposure delta already happened at **phase (a)** (the open SPA became the default admin
surface); removal only deletes the now-redundant challenged duplicate. Designing real SPA/API authentication
is deliberately deferred to **#197** (cold API security review — a HARD GATE before any remote exposure).
**Removal-PR must-not-break (independent gates that survive):** `ConditionalIptvAuthorizeFilter` (`/iptv/*`
JWT), `ApiKeyAuthorizationFilter` (mutating `/api/*`), and `JwtHelper` access_token query support. **Leave
the OIDC service registrations in place** (conditional on config, inert once no Razor Page consumes them) —
ripping OIDC out is a #197 decision, not a removal-PR one. The removal PR removes only the Blazor-attached
pieces above; `MapControllers()` + `/docs` (Scalar), currently co-hosted in the Blazor `MapWhen` branch, must
survive the surgical reduction.
## 2026-07-11 — Baseline security response headers + Phase-0 API hardening (#197, PR #279)
`key: security.baseline-response-headers` · `status: active` · `since: 2026-07-11` · `supersedes: none` · `superseded-by: none`
**Rule:** `SecurityHeadersMiddleware`, registered first in the pipeline, sets `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, and `Referrer-Policy: strict-origin-when-cross-origin` on every response (CSP/HSTS deliberately deferred); API-key comparison is constant-time and playout pagination is clamped.
**Signals:** security headers, nosniff, Phase-0 hardening, constant-time comparison · paths: `ErsatzTV/Middleware/SecurityHeadersMiddleware` · issues: #197, #279, #283
**Mechanics:** `ErsatzTV/Middleware/SecurityHeadersMiddleware`
Phase-0 of the #197 remediation — the posture-**independent** safe subset, shipped ahead of the
fail-closed/CORS/versioning posture work tracked in #280–#289.
- **Baseline security headers on every response.** New `ErsatzTV/Middleware/SecurityHeadersMiddleware`,
registered **first** in the pipeline (before the `/iptv` `MapWhen` branch and `UseCors`), so it covers
`/api`, `/iptv`, `/artwork`, static, the SPA fallback, and filter-produced 4xx alike — which is why it's
middleware, not an MVC filter. It sets `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, and
`Referrer-Policy: strict-origin-when-cross-origin`. `nosniff` is the standing backstop for the artwork
content-type MIME-sniffing risk (#283). **CSP and HSTS are deliberately NOT included here**: CSP must be
validated against the ChicoryTV SPA's inline assets, and HSTS is a proxy/TLS-termination decision — both
belong to the #197 posture design (#284/roadmap), not this baseline. Headers are set eagerly (not via
`Response.OnStarting`); safe today because the pipeline has no `UseExceptionHandler`/`UseStatusCodePages`
that would `Response.Clear()` — switch to `OnStarting` if one is ever added.
- **Constant-time API-key comparison.** `ApiKeyAuthorizationFilter` compares `X-Api-Key` with
`CryptographicOperations.FixedTimeEquals` (over UTF-8 bytes) instead of ordinal `string.Equals`, removing
the response-timing oracle on the write key. Accept/reject behavior is otherwise identical.
- **Playout pagination clamped.** `GET /api/playouts` and `GET /api/playouts/{id}/items` now clamp
`Math.Clamp(pageSize, 1, 100)` + `Math.Max(0, pageNum)` before the query — applying the api-conventions §1
clamp convention the other paged endpoints already follow (these two were passing the raw client value
straight to EF `Take()`).
The larger #197 posture (fail-closed writes, sensitive-read auth tier, CORS lockdown, `/api/v1` versioning,
the OpenAPI security scheme) is decomposed into #280–#289 with the phased roadmap on #197; those PRs will
append their own decisions here as they land.
## 2026-07-12 — Artwork content-type is sniffed, never reflected (#283, S4/S9 stored XSS)
`key: security.artwork-content-type-sniff` · `status: active` · `since: 2026-07-12` · `supersedes: none` · `superseded-by: none`
**Rule:** Artwork content type is always derived from the stored bytes (never the client-declared value or a `?contentType=` query param) at both upload and serve, clamped to an image allow-list, closing the unauthenticated stored-XSS chain; Kestrel `MaxRequestBodySize` bounds upload DoS.
**Signals:** stored XSS, content-type sniffing, artwork upload/serve · paths: `ErsatzTV.Core/Images/ImageContentTypes.DetectContentType`, `GetCachedImagePathHandler` · issues: #283, S4, S9
**Mechanics:** `ErsatzTV.Core/Images/ImageContentTypes`
The artwork upload/serve path trusted client-supplied content types at both ends, giving a stored-XSS
chain on **unauthenticated** GET sinks: upload `<script>` bytes declared `image/png` →
`GET /iptv/logos/{hash}?contentType=text/html` served them as HTML in the ErsatzTV origin. The #279
`nosniff` header is not a fix here — the server was *explicitly declaring* `text/html`, which the browser
honors regardless of `nosniff`. The trust was the bug; the fix removes it at both ends.
- **Upload derives the content type from the bytes, never the declared value.** `UploadArtworkHandler`
buffers the (size-bounded) upload and calls `ErsatzTV.Core/Images/ImageContentTypes.DetectContentType`,
which uses SkiaSharp's `SKCodec` to identify the format from the image header only — pixels are **not**
decoded, so this can't be turned into a decompression-bomb vector. A payload that isn't one of the
accepted raster formats (png/jpeg/gif/webp) is rejected 422; the declared `Content-Type` is no longer
read at all (the field was dropped from the `UploadArtwork` command).
- **Serve sniffs the stored file; the `?contentType=` reflection is gone.** `GetCachedImagePath` no longer
carries a `ContentType`, and `GetImage` (`/iptv/logos`) / `GetWatermark` (`/artwork/watermarks`) dropped
their `[FromQuery] contentType` binding. `GetCachedImagePathHandler` always derives the MIME type from the
file (`MimeTypes.GetMimeTypeFromFile`) and **clamps it to the image allow-list** (`ImageContentTypes.IsAccepted`),
serving `application/octet-stream` for anything else — so a file whose bytes are not an accepted image (a
legacy cache entry poisoned before the upload sniff landed, or a hypothetical polyglot) is a non-renderable
download, never HTML/script. The removal is **structural** — there is no longer any request path that lets a
client choose the served `Content-Type`. `ArtworkContentTypeModel.UrlWithContentType` now returns the bare path, and the SPA
watermark/logo previews no longer append the query.
- **Defense-in-depth on the persisted JSON DTOs.** The `{path, contentType}` bodies (channel logo, watermark)
run their content type through `ArtworkContentTypeModel.Sanitized()`, which blanks anything outside the
image allow-list before it is stored — so a stale/hostile value can't be reflected by any future code path
even though the serve route already ignores it.
- **S9 upload-size DoS.** Kestrel `Limits.MaxRequestBodySize` is now set from `ETV_MAXIMUM_UPLOAD_MB`, so an
oversized body is rejected as it is read rather than only after the controller's post-binding `file.Length`
check (kept as the friendly-error backstop). This is a global bound; the app has no other large inbound
body (streaming is outbound GET).
`ImageContentTypes` is the single source of truth for the accepted image types (the allow-list previously
duplicated in `UploadArtworkHandler`). Both serve sinks are `[ApiExplorerSettings(IgnoreApi = true)]`, so
none of this changes the OpenAPI document.
## 2026-07-12 — Fail-closed API auth + sensitive-read tier + CORS/ForwardedHeaders lockdown (#197 Bundle A, PR #292)
`key: security.fail-closed-api-auth` · `status: active` · `since: 2026-07-12` · `supersedes: none` · `superseded-by: none`
**Rule:** Every mutating `/api` request requires `X-Api-Key` (no open mode); reads are gated by `Api:RequireKeyForReads` (default true) OR `[RequiresApiKey]` on sensitive controllers; CORS is an exact-origin allowlist (`ApiCors`); `ForwardedHeaders` trust stays configurable but defaults to trust-all-with-warning.
**Signals:** fail-closed auth, sensitive-read tier, CORS allowlist, single API key · paths: `IApiKeyProvider`, `ErsatzTV/Services/ApiKeyProvider.cs`, `FileSystemLayout.ApiKeyPath` · issues: #197, #280, #281, #282, #284, #285
**Mechanics:** `docs/api-conventions.md` §5; `ApiControllerSecurityTests`
Phase-1 of the #197 remediation — the auth posture that must land before any remote exposure.
Owner decisions (confirmed this session): **single API key** (no read/write split), and
**`Api:RequireKeyForReads` defaults `true`** (the whole `/api` surface requires the key). This does
**not** affect Jellyfin/streaming: `/iptv/*` (playlist/guide/streams/logos) and `/artwork/*` are outside
the filter's `/api` scope and keep their own optional access-token; only the management API the SPA talks
to is gated.
- **Fail-closed writes (#280, S1).** The empty-key "open" branch is deleted; there is no open mode. New
`IApiKeyProvider` (`ErsatzTV/Services/ApiKeyProvider.cs`, singleton, resolved once at startup) yields a
never-empty key: `Api:WriteKey` if set, else a key persisted at `FileSystemLayout.ApiKeyPath`
(`/config/api.key`, `0600`, path logged not value), else a generated 256-bit hex key. Every mutating
`/api` request now requires `X-Api-Key`.
- **Sensitive-read tier (#282, S3/S5).** Reads are gated by `Api:RequireKeyForReads` (default true) OR a
new `[RequiresApiKey]` marker (mirror of `[SkipApiKeyAuthorization]`) applied to
`Troubleshoot`/`Logs`/`Settings`/`Maintenance`, so that tier stays gated even if an operator opts reads
open. `OPTIONS` preflight is exempt (CORS middleware owns it). `ApiControllerSecurityTests` asserts the
tier reflectively.
- **Delete dead non-`/api` mutation surfaces (#281, S2).** `SortController`
(`POST media/collections/{id}/items`, dead Blazor SortableJS residue — the SPA uses
`PUT /api/collections/{id}/custom-order`) and `AccountController` (`POST account/logout`, dead OIDC)
bypassed the key because they sat outside `/api`. Removed rather than guarded.
- **CORS opt-in (#284, S6).** `AllowAnyOrigin/Method/Header` is replaced by the `ApiCors` policy: an
exact-origin allowlist from `Api:CorsAllowedOrigins` (semicolon list) that permits `X-Api-Key`/`If-Match`
and exposes `ETag`; with no origins configured there is no cross-origin access (the SPA is same-origin).
- **ForwardedHeaders trust + scanner loopback (#285, S7/S10).** `GET /api/maintenance/gc` → `POST`
(crawler-triggerable GC; spec regenerated). `ForwardedHeaders` trust is configurable via
`ForwardedHeaders:KnownProxies`/`KnownNetworks` — **unconfigured preserves the current trust-all
behavior but logs a warning** (flipping the default to loopback-only would break reverse-proxy scheme/host
detection and thus M3U/XMLTV absolute URLs — the operator must name their proxy network). `ScannerController`
gains `[LocalhostOnly]` (the scanner always calls back over `http://localhost:{UiPort}/api/scan/...`), which
is only spoof-resistant once ForwardedHeaders trust is restricted — the two interlock. `search/all-items`
DoS-paging is **deferred** (it feeds the SPA "add all" flow and needs coordinated pagination; the unauth
exposure is already closed by read-gating).
- **SPA (`web/`).** The client sends the stored key (`ctv-api-key`) on **every** method (not just
mutations); a new keyless **API Key** screen (`/app/api-key`) lets the user paste the generated key, and a
shell-level banner points there on any 401. See spa-conventions §5e. **First-run/upgrade UX:** with reads
gated by default, the SPA shows no data until the key (from `/config/api.key`) is entered — an intended
consequence of the strict default.
Phase-2 (contract freeze) — the declarative OpenAPI security scheme, global 401 docs, and `/api/v1`
versioning — remains #286/#287/#288. Phase-3 follow-ups: #265, #269, #172 remainder, `search/all-items`
paging, per-key rate limiting.
## 2026-07-12 (#197 Bundle C — contract-freeze honesty)
`key: security.contract-freeze-honesty` · `status: active` · `since: 2026-07-12` · `supersedes: none` · `superseded-by: none`
**Rule:** The OpenAPI doc's declared security/401 scheme is generated from the same `ApiKeyAuthorizationFilter.EndpointRequiresKey` predicate the runtime enforces (so declared auth can't drift from enforced auth), every `/api/*` action returns a ResponseModel (no raw Application VMs), and Channel REST resources are keyed by immutable `Id`, never mutable `Number`.
**Signals:** OpenAPI contract honesty, ResponseModel wrapping, Id vs Number key · paths: `OpenApiContractHonestyTests`, `ErsatzTV.Core/Api` · issues: #197, #287, #288
**Mechanics:** `OpenApiContractHonestyTests`; `docs/api-conventions.md`
**#287 — OpenAPI contract honesty by construction.** The "v1" document now emits the `ApiKey` security
scheme plus per-operation `security`/`401` derived from the *same*
`ApiKeyAuthorizationFilter.EndpointRequiresKey` predicate the runtime filter enforces, so declared auth
can never drift from enforced auth. Every operation also gets a synthesized stable `operationId` (the
framework only assigned one when `Name=` was set — ~90 were missing), and body/param-binding operations
get the documented `400 ValidationProblemDetails` they actually return. `DayOfWeek` is now a string enum
in the schema (added to `Startup.UseStringEnumSchemas`), removing the SPA's `WithDayNames` wart. Pinned by
in-process document generation in tests (`OpenApiContractHonestyTests`) rather than the committed `v1.json`.
**#288 — Wrap the last raw ViewModels; reverse the §7a "intentional `version` leak."** Minted
`MediaCollectionResponseModel`, `ProgramScheduleResponseModel`, and `ChannelDetailResponseModel` (all
`#nullable enable`) and routed `CollectionController` / `ScheduleController` / `SmartCollectionController` /
`ResolutionController.GetResolutionByName` / the channel detail GET+writes through ResponseModels, so no
`/api/*` action returns an Application VM. This reverses the earlier §7a judgment that a ResponseModel
"purely to hide one field was disproportionate": `Version` is now header-only (ETag) on every aggregate
body — confirmed safe by grepping `web/src` (the SPA reads `version` from the ETag header, never the
response body). `ChannelDetailResponseModel` is the *full editable* field set the channel editor needs
(distinct from the lean list `ChannelResponseModel`; drops only the derived `webEncodedName`). Also flipped
`#nullable enable` onto the remaining 24 lagging `ErsatzTV.Core/Api/` files for schema honesty, and added
`pageNum` paging to `GET /api/search`.
**Channel REST resources are keyed by database `Id`, never by `Number`.** `Channel.Number` is user-mutable
(editable on update, bulk-renumbered via `/api/channels/bulk/renumber`, transiently invalid mid-renumber),
so the immutable int PK is the canonical key for all `/api/channels/*` single-item routes, sub-resources
(including `playout/reset`, re-keyed from `{channelNumber}` to `{id:int}` in Bundle C), and `Location`
headers. `Number` remains the identity on broadcast surfaces only (IPTV/M3U/XMLTV), a separate contract. A
number-based lookup endpoint may be added additively later; `UniqueId` (Guid) stays out of the REST
contract absent a federation requirement.
## 2026-07-12 — Browser SPA session auth: `/api` accepts session OR machine key (#295 PR1, server-only)
`key: security.session-auth-dual-credential` · `status: active` · `since: 2026-07-12` · `supersedes: none` · `superseded-by: none`
**Rule:** `ApiAuthorizationFilter` accepts a request when a valid `X-Api-Key` matches OR the principal is an authenticated session (cookie `ctv-session`, `HttpOnly`/`SameSite=Lax`); session-authenticated mutations require the presence-only `X-CSRF` header or are rejected 403. This narrows the OIDC-inert sub-claim of `security.blazor-removal-auth-posture` (#206) — the rest of that record's auth-surface enumeration still holds.
**Signals:** session auth, dual credential, CSRF, OIDC revival, local admin · paths: `ApiAuthorizationFilter`, `ErsatzTV/Startup.cs` · issues: #295, #206, #197
**Mechanics:** `docs/spa-conventions.md` §5e (PR2); `RootWriterForceVersionTests`-adjacent auth tests
Implements the ratified #295 design (Fable [PLAN-MODE] pass, issue comment 9548). Supersedes the #206
"OIDC wiring stays inert until #197" note: the retained OIDC service registration is now **revived**, and a
cookie session becomes a first-class `/api` credential alongside the machine `X-Api-Key`. **PR1 is
server-only and backward compatible** — the SPA keeps sending its stored key; the SPA login flow, the
`ApiKeyScreen`→machine-key repurpose, and `spa-conventions §5e` land in **PR2**.
**One gate, evolved (not `[Authorize]`-per-controller).** `ApiKeyAuthorizationFilter` → `ApiAuthorizationFilter`,
same fail-closed-by-omission logic (a forgotten `[Authorize]` fails *open* — the #280 failure mode — so the
global filter stays the gate). It now accepts a request when a valid `X-Api-Key` matches **OR** the principal
is an authenticated session; the "does this endpoint need auth?" decision is still the single shared
`EndpointRequiresKey(...)` predicate (also drives OpenAPI, so the spec can't drift). Attributes renamed to
match the widened meaning: `[RequiresApiKey]`→`[RequiresAuthentication]`, `[SkipApiKeyAuthorization]`→
`[SkipApiAuthorization]`. `IApiKeyProvider`, the `X-Api-Key` header, and `Api:WriteKey`/`Api:RequireKeyForReads`
are unchanged — **machine/key behavior is byte-identical** (verified: no OpenAPI drift, existing filter tests
still green).
**CSRF (session only).** The machine key is CSRF-immune (a browser can't set a custom header cross-origin
without a credentialed CORS grant we never issue). A cookie session is not: a session-authenticated **mutation**
must carry the `X-CSRF` header (presence-only — a custom header forces a CORS preflight a cross-site page can't
satisfy) or is rejected **403**. Reinforced by `SameSite=Lax` + CORS without `AllowCredentials` (cross-origin
cookie auth is impossible by design). No antiforgery-token machinery.
**Cookie `ctv-session`.** Always registered (local login works with no IdP); OIDC handler added only when
`OIDC:*` is configured. `HttpOnly`, `SameSite=Lax`, `SecurePolicy=SameAsRequest` (so a plain-HTTP LAN isn't
bricked), 14-day sliding. `/api` XHR gets **401/403, not a redirect** (`OnRedirectToLogin`/`AccessDenied`).
The `UseAuthentication`/`UseAuthorization` middleware — deleted with Blazor in #91b — is **revived in the
`legacy` `MapWhen` branch only** (hosts `/api` + OIDC `/callback` + `/docs`; `/iptv` and `/app` untouched).
**Local store = `ConfigElement` rows, single admin, NO migration** (owner ruling F2):
`AuthLocalAdminUsername`, `AuthLocalAdminPasswordHash` (ASP.NET `PasswordHasher`, PBKDF2, via
`Microsoft.Extensions.Identity.Core`), `AuthSecurityStamp`. A password change rotates the stamp; the cookie
`OnValidatePrincipal` (`CookieSecurityStampValidator`) compares the claim to the stored stamp and rejects a
stale session (revocation). OIDC sessions carry an `etv:auth_method=oidc` claim and skip the stamp check
(governed by the IdP).
**Fail-closed out of the box + recovery.** An unconfigured instance keeps `/api` gated (the key still works);
first-run is a **setup-claim** (`POST /api/auth/setup`, first-claim-wins, only valid while unconfigured —
owner ruling F1). Recovery without the browser: `Auth:LocalAdmin:Password` env seed (`LocalAdminSeedService`,
overwrites + rotates the stamp on startup) or the machine key. Login hardening: per-IP rate limit
(`[EnableRateLimiting("auth")]`, 10 / 5 min) on login/setup/password, dummy-hash verify on unknown/unconfigured
user (no enumeration).
**Authelia = app-owned OIDC session; never trust proxy identity headers** (owner ruling F3): the container is
LAN-reachable bypassing the proxy, so `Remote-User`/`Remote-Email` header trust is spoofable. OIDC→Authelia
gives SSO without a double login. **`ForwardedHeaders` behaviour is kept unchanged from #285** (trust any peer
with a warning; restrict via `KnownProxies`/`:KnownNetworks`). A stricter "ignore `X-Forwarded-*` unless a proxy
is configured" default was implemented and then **reverted** after review (cold fork M1): the forwarded
scheme/host feed `/iptv` M3U/XMLTV/HLS absolute-URL generation (`Request.Scheme` in `GetChannelGuideHandler`/
`IptvController`), so ignoring them would regress stream URLs to `http`/internal-host for a proxied deployment
that hasn't set `KnownProxies`. **Deployment coordination:** operators behind a proxy should set
`ForwardedHeaders:KnownProxies`/`:KnownNetworks` — it gives the login rate limiter an unspoofable client IP and
marks the session cookie `Secure` behind TLS. The residual (a direct LAN peer can spoof `X-Forwarded-For` to
evade the per-IP login limit when unrestricted) is accepted defense-in-depth loss, mitigated by PBKDF2 +
no-enumeration.
**Review hardening (fork + independent Codex pass, folded into PR1).** Codex caught concurrency defects the
fork missed — folded in: (a) **atomic first-claim-wins** — setup writes the three credential rows in one
transaction guarded by the unique `ConfigElement.Key` index (a lost race → `DbUpdateException` → 409), so a
concurrent claim can't produce a mixed-state credential; (b) **consistent login snapshot** — login reads the
hash + stamp in one query and no longer rehashes-on-verify, so a login racing a password change can't capture a
newer stamp than the hash it verified (a concurrent change either fails the old password or leaves the issued
cookie carrying the pre-change stamp → revoked next request); (c) **env-seed waits on
`SystemStartup.WaitForDatabase`** (the migrator is a `BackgroundService`, so registration order alone didn't
guarantee the schema existed) — moved to `Services/RunOnce/`. Also: **logout + password require `X-CSRF`**
(the `[SkipApiAuthorization]` auth surface isn't covered by the filter's CSRF check → forced-logout CSRF), and
input length caps on username/password. **Logout rotates the security stamp** when called from a local session
(E2E-caught: `SignOutAsync` alone only clears the *client* cookie, leaving the stateless encrypted ticket
replayable server-side) — so signing out actually ends the session server-side; for the single admin this
revokes all local sessions ("log out everywhere"). Gated on an authenticated session so an unauthenticated
caller can't force-revoke the admin. **Deferred with a tracked gate:** side-effecting `[RequiresAuthentication]`
GETs (troubleshoot playback/archive) aren't CSRF-covered — **#301**, gating PR2 (latent in PR1: the SPA still
uses the machine key). OIDC-session revocation lever (no local stamp) noted for PR3 operator docs.
A **fix-commit re-review** (Codex, #242 discipline) then confirmed the above resolved and caught a second round:
(a) **HIGH — env-seed vs. setup race**: an attacker could claim admin in the startup window before
`LocalAdminSeedService` runs, and the seed's insert would then be swallowed (attacker's credential persists,
defeating the env recovery path). Fixed structurally: **the setup-claim endpoint is closed whenever
`Auth:LocalAdmin:Password` is configured** — the env seed owns the credential, so there is no claim to race
(this also strengthens the setup-claim TOFU posture: an operator on an untrusted network sets the env password
and browser setup is disabled). (b) **LOW**: a concurrent setup race-loser now returns **409** (not 422), and
`ClaimLocalAdmin`'s `DbUpdateException` catch re-checks existence and **rethrows genuine/transient DB errors**
rather than masking them as "already configured". (c) **MEDIUM — accepted**: two *simultaneous* authenticated
password changes are a non-serializable lost-update (last-write-wins; the loser's cookie may be immediately
revoked). Accepted for a **single-admin** system: it needs two concurrent authenticated sessions both submitting
the correct current password at the same instant, and the outcome is self-healing (re-login). Adding EF
optimistic concurrency to the credential rows is disproportionate here.
**OpenAPI = `ApiKey`-only; `/api/auth/*` excluded** (owner ruling F4): the spec's audience is machine/MCP
clients, and a browser-interactive cookie login isn't something a generated client drives, so the cookie path
is an additional accepted credential the doc needn't express. `AuthController` is `[ApiExplorerSettings(IgnoreApi
= true)]`. Verified: no `v1.json`/`v1.d.ts`/`endpoint-index` drift from this PR.
**Phasing.** PR1 = this (server only, no migration). PR2 = SPA (drop the key header for browser calls + add
`X-CSRF`, `AuthContext` + boot gate, login/setup screens, `ApiKeyScreen`→machine-key management, E2E,
`spa-conventions §5e`). PR3 = key rotation + operator docs (Authelia client + env reference). Rollout: PR1→PR2
same release, then a manual Authelia round-trip checklist before the prod pin bump.
## 2026-07-12 — #295 PR2: SPA session cutover + #301 side-effecting-GET POST-ification
`key: security.session-cutover-postify` · `status: active` · `since: 2026-07-12` · `supersedes: none` · `superseded-by: none`
**Rule:** The browser SPA authenticates cookie-only (no more `X-Api-Key` from `web/`); the machine key is repurposed to external/MCP-only via `GET /api/auth/machine-key`; every side-effecting GET/HEAD under `/api` is converted to POST so the existing CSRF gate covers it (standing rule: never add a side-effecting GET/HEAD under `/api`).
**Signals:** SPA cookie-only cutover, CSRF POST-ification, AuthGate boot flow · paths: `web/src/api/client.ts`, `web/src/AuthGate.tsx` · issues: #295, #301, #197
**Mechanics:** `docs/spa-conventions.md` §5e; `docs/api-conventions.md` §9; `docs/e2e-local.md`
PR1 shipped the server side (previous entry): `/api` accepts a session cookie OR the machine `X-Api-Key`, with
`X-CSRF` required on session-authenticated mutations. **PR2 is the SPA cutover** — the browser now authenticates
with the session only — plus **#301** (a session-cookie CSRF hole in side-effecting GETs).
**Browser is cookie-only; the machine key is external/MCP-only.** `web/src/api/client.ts` no longer attaches
`X-Api-Key`; it relies on the same-origin session cookie and sets `X-Csrf: '1'` on every mutating verb centrally.
The former "paste your key" `ApiKeyScreen` is repurposed to **machine-key management**: it reads the server key
from the new `GET /api/auth/machine-key` (session-gated; masked with Reveal + Copy) so an operator can hand it to
MCP / external REST clients — the browser itself never sends it again. *Why:* one credential per audience (the
ratified #295 model); leaving a browser key path alive would keep a CSRF-immune bypass around and defeat the
point.
**Boot gate, not a route** (`web/src/AuthGate.tsx`, wrapping `<App/>` in `main.tsx`): on load it calls the public
`GET /api/auth/config` then `GET /api/auth/session` and renders Setup (first-run local-admin claim) / Login
(local form + an OIDC "Sign in with SSO" button when `oidcEnabled`) / the app. Login and Setup mint **no URL** —
the gate renders them at whatever `/app/*` path was requested, so a deep link survives login for free and **no
`blazor-route-parity.md`/`domain-model.md` route rows are added**. It publishes `AuthContext`
(`{ username, method, signOut, requireLogin }`); the 401 signal (`notifyUnauthorized`) now drives re-login via a
passive shell banner (never yanks a dirty draft — it consults the navigation guard first). Auth flows that expect
a 401 inline (login, change-password) pass `suppressUnauthorizedSignal`.
**#301 — POST-ify, don't gate-the-GET.** A side-effecting GET is a CSRF vector once a `SameSite=Lax` cookie is a
normal credential (it rides a cross-site top-level navigation). The three offenders became mutating verbs so the
existing filter CSRF gate covers them with zero new machinery: `GET /api/troubleshoot/playback.m3u8` →
**`POST /api/troubleshoot/playback/start`** returning `200 { url }` (the open `/iptv` manifest the player then
loads — so hls.js/native-HLS needs no header injection, strictly better than X-CSRF-on-GET); the archive and
sample GETs → **POST** (SPA downloads them via a fetch-blob helper, never `window.open`). Removing the HEAD
variants also fixed a latent bug: a HEAD opened the `DeleteOnClose` stream and destroyed the artifact. Standing
rule added to `api-conventions.md §9`: **never add a side-effecting GET/HEAD under `/api`.**
**Machine-key GET discloses the key to any authenticated session** — deliberate: the session principal is the
single admin (local or OIDC), same-origin policy blocks a cross-site page from reading the response body, and it
is how the "copy the key for MCP" UX works without a rotation endpoint (rotation is a later PR). **Accepted
residual (OIDC logout):** `POST /api/auth/logout` ends the *app* cookie but not the IdP session, so an OIDC user
who clicks "Sign out" then "Sign in with SSO" returns without re-entering credentials — a `returnUrl`/RP-initiated
logout is a future nicety. Docs: `spa-conventions.md §5e` (SPA seams), `api-conventions.md §9`, `e2e-local.md`
(browser setup/login flow). Refs #295 #301 #197.
## 2026-07-12 — Enforcing CSP + Permissions-Policy on the host (#319, ZAP baseline)
`key: security.csp-permissions-policy` · `status: active` · `since: 2026-07-12` · `supersedes: none` · `superseded-by: none`
**Rule:** `SecurityHeadersMiddleware` sends an enforcing (not report-only) `Content-Security-Policy` (no `unsafe-inline`/`unsafe-eval`; the one inline theme-bootstrap script allow-listed by hash) and a deny-all `Permissions-Policy` on the SPA/`/api`/`/artwork`/`/iptv`; `/docs` and `/openapi` keep only the baseline headers, excluded from CSP because Scalar needs inline bootstrap.
**Signals:** enforcing CSP, script-src hash allowlist, Permissions-Policy · paths: `ErsatzTV/Middleware/SecurityHeadersMiddleware`, `SecurityHeadersMiddlewareTests` · issues: #319, #314, #197, #279
**Mechanics:** `SecurityHeadersMiddlewareTests.Csp_Script_Hash_Should_Match_The_Spa_Index`
Completes the CSP that the #279 baseline-headers entry deferred ("CSP must be validated against the ChicoryTV
SPA's inline assets"). Surfaced by the #314 out-of-ecosystem ZAP baseline (missing CSP/Permissions-Policy WARNs);
a **#197 exit item**. `SecurityHeadersMiddleware` now also sets `Permissions-Policy` (deny-all for
camera/microphone/geolocation/payment/usb) and an **enforcing** `Content-Security-Policy`.
- **Enforce, not report-only.** Report-only was the issue's acceptable fallback, but the SPA's asset graph is
small and fully knowable, so we ship an enforcing policy (report-only leaves the ZAP WARN and provides no real
protection). The policy: `default-src 'self'`; `script-src 'self' '<sha256 of the inline theme-bootstrap
script>'` (**no** `'unsafe-inline'`/`'unsafe-eval'` — the real XSS win); `style-src 'self' 'unsafe-inline'
https://fonts.googleapis.com`; `img-src 'self' data: blob:`; `font-src 'self' data: https://fonts.gstatic.com`;
`connect-src 'self'`; `object-src 'none'`; `base-uri 'self'`; `frame-ancestors 'none'`; `form-action 'self'`.
- **Why each relaxation.** The SPA is a static file, so a per-response nonce is impossible → the one inline
theme-bootstrap `<script>` is allow-listed **by hash**; `SecurityHeadersMiddlewareTests.Csp_Script_Hash_Should_
Match_The_Spa_Index` hashes the built `wwwroot/app/index.html` when present (else the committed `web/index.html`
source, since the built artifact is gitignored/absent in CI — Vite copies the inline script verbatim) and fails
if it drifts from the middleware constant. `style-src 'unsafe-inline'` covers
React's inline `style=""` attributes (no CSS-in-JS lib to hash). The **Google Fonts** hosts are required — the
SPA CSS `@import`s the Geist web font (caught by **live-E2E**, which the static grep missed); self-hosting the
font to drop the Google dependency is a follow-on hardening, not this issue. `img-src data: blob:` covers
favicon/generated-image data URIs and object-URL upload previews.
- **Scoped: `/docs` (Scalar) and `/openapi` are excluded.** The Scalar API-reference UI relies on inline bootstrap
scripts/styles a strict CSP would break; it keeps the baseline headers (nosniff/frame/referrer) but no CSP.
Hardening that admin surface (self-hosted Scalar or a Scalar-tuned CSP) is a #197 follow-up. Everything else —
SPA, `/api`, `/artwork`, `/iptv` — gets the CSP (non-HTML responses simply never exercise the script/style
directives). Verified by live-E2E (SPA renders clean, zero CSP violations) + curl (CSP present on `/app`/`/api`,
absent on `/docs`/`/openapi`). HSTS remains out (proxy/TLS decision). Refs #319 #314 #197.
## 2026-07-13 — Cross-origin resource policy: `same-origin` on every response (#330)
`key: security.corp-same-origin` · `status: active` · `since: 2026-07-13` · `supersedes: none` · `superseded-by: none`
**Rule:** `SecurityHeadersMiddleware` sends `Cross-Origin-Resource-Policy: same-origin` on every response including `/docs`/`/openapi`, blocking cross-origin `no-cors` embedding without affecting allowed CORS-mode fetches or server-side Jellyfin `/iptv/*` requests.
**Signals:** CORP, same-origin, cross-origin embedding · paths: `ErsatzTV/Middleware/SecurityHeadersMiddleware` · issues: #330, #319, #314
**Mechanics:** `ErsatzTV/Middleware/SecurityHeadersMiddleware`
The authenticated #314 ZAP scan found that ErsatzTV's baseline response posture omitted
`Cross-Origin-Resource-Policy`. `SecurityHeadersMiddleware` now sends
`Cross-Origin-Resource-Policy: same-origin` on every response, including `/docs` and `/openapi`.
Those two paths remain exempt only from the strict CSP that would break Scalar's inline bootstrap;
CORP has no equivalent rendering conflict and belongs with the middleware's path-independent baseline
headers.
`same-origin` requires the browser request and response to share the exact scheme, host, and port. It
blocks cross-origin `no-cors` loads, so direct browser embedding of ErsatzTV artwork or media from an
alternate origin is deliberately unsupported. It does not reject an allowed CORS-mode API fetch, so the
explicit `Api:CorsAllowedOrigins` machine-client path continues to work. It is also not enforced by
server-side HTTP clients, so Jellyfin's `/iptv/*` requests are unaffected; same-origin SPA artwork and
IPTV requests remain allowed. This is defense in depth for browser embedding and does not replace CORS
or authentication. Refs #330 #319 #314.
## 2026-07-22 — Short-lived browser IPTV token so the SPA reaches `/iptv/*` under JWT auth (#552)
`key: security.iptv-browser-token` · `status: active` · `since: 2026-07-22` · `supersedes: none` · `superseded-by: none`
**Rule:** Under a JWT-enabled deployment (`JWT:IssuerSigningKey` set), the browser SPA obtains a short-lived, globally-scoped `/iptv/*` access token from an authenticated `GET /api/v1/auth/iptv-token` and appends it as `?access_token=`; the endpoint answers 204 when JWT is disabled (nothing to mint). Lifetime defaults to 60 min, configurable via `JWT:BrowserTokenLifetimeMinutes`.
**Signals:** channel preview / playback-troubleshooting 401ing under JWT; `/iptv/*` not accepting `ctv-session`; minting a JWT for the browser · paths: `ErsatzTV/JwtHelper.cs`, `ErsatzTV/Controllers/Api/AuthController.cs`, `web/src/media/iptvToken.ts` · issues: #552, #60
**Mechanics:** `JwtHelper.GenerateBrowserToken()`; `AuthController.IptvToken`; `withIptvToken(url)` (SPA)
`/iptv/*` is gated by `ConditionalIptvAuthorizeFilter` only when `JWT:IssuerSigningKey` is configured,
and the `"jwt"` scheme accepts only a bearer token or `?access_token=` — **not** the SPA's `ctv-session`
cookie (a distinct scheme). Nothing minted a JWT for the browser, so under JWT the #60 channel preview
was declared `Unavailable` and the pre-existing playback-troubleshooting screen was latently broken. This
closes both with one seam.
- **Endpoint.** `GET /api/v1/auth/iptv-token` on `AuthController` (already `[SkipApiAuthorization]` +
self-checks the principal, like `machine-key`). Requires any authenticated session (401 otherwise);
returns `{ token, expiresAt }` when `JwtHelper.IsEnabled`, else **204 No Content** — `/iptv/*` is open
then, so there is nothing to append and the SPA plays the plain URL. `Cache-Control: no-store`. Excluded
from the OpenAPI document (`AuthController` is `[IgnoreApi]`, per the #295 F4 ruling — a
browser-interactive credential is not something a generated client drives).
- **A GET is correct here** despite the "no side-effecting GET under `/api`" rule (`api-conventions.md §9`):
minting a JWT writes **no server state** (stateless token, no DB row, no revocation list), so it is not a
CSRF-relevant side effect, and same-origin policy already blocks a cross-site page from reading the
credentialed response body — identical reasoning to the `machine-key` GET.
- **Scope: global.** The `"jwt"` scheme validates only signature + lifetime (no audience/channel claims),
and the token is minted only to the already-authenticated admin who can reach every channel.
Channel-scoping would mean adding claim-based auth to `ConditionalIptvAuthorizeFilter` and the streaming
path — deferred until a non-admin preview audience exists.
- **Lifetime: 60 min default, `JWT:BrowserTokenLifetimeMinutes` override, clamped to 24h.** The token
re-validates on every `/iptv/*` request, so lifetime is the max continuous watch before playback stalls;
60 min comfortably covers an operator verification session, an expired idle session just needs Retry
(mints fresh), and a security-conscious operator can tighten it. A non-positive/unparseable value falls
back to the default rather than minting an already-expired token; a value above 24h (a seconds-vs-minutes
typo would otherwise mint a multi-year bearer token) is clamped down. **Revocation is by short lifetime
only** — a stateless JWT has no per-token revocation; rotating `JWT:IssuerSigningKey` invalidates all
tokens (the existing lever). The SPA's `resetIptvTokenCache()` (called on the preview panel's Retry and on
each troubleshooting Play) makes a user-initiated retry re-mint, so a stale token or a stale "JWT disabled"
latch from a since-reconfigured backend can't wedge a recovery attempt.
- **Deferred hardening (broader than #552) — RESOLVED by `security.iptv-access-token-transport` (#421, #559).**
The `?access_token=` transport itself had pre-existing weaknesses this feature inherited, then bounded by the
short lifetime: Serilog's request log included the full query (so a token could reach logs on an `/iptv` 5xx),
and the token-bearing dynamic manifests carried no `Cache-Control: no-store`. Both predate this feature
(Jellyfin and the M3U playlist already pass `access_token` in `/iptv` URLs); their fixes were cross-cutting
changes to shared request-logging / manifest behavior, tracked as a follow-up and now landed in the record
below (which also adds the #421 M3U quote-safety encoding).
- **Only the top-level manifest needs the token.** The multi-variant playlist embeds `access_token` into
its variant URL (`IptvController.GetMultiVariantPlaylist`), and HLS segments are served by
`UseStaticFiles` at `RequestPath=/iptv/session` — **outside** `ConditionalIptvAuthorizeFilter` (which is
a `[ServiceFilter]` on `IptvController` only) — so segment GETs are ungated regardless. The SPA's
`withIptvToken(url)` appends the token to the one manifest URL (a no-op when JWT is off; caches the token
in memory until shortly before expiry) and is used by both the channel-preview panel and the
playback-troubleshooting screen.
## 2026-07-23 — access_token transport hardening: percent-encode in M3U/HLS, redact from logs, no-store on tokened manifests (#421, #559)
`key: security.iptv-access-token-transport` · `status: active` · `since: 2026-07-23` · `supersedes: none` · `superseded-by: none`
**Rule:** The `/iptv` `?access_token=` value is percent-encoded (`Uri.EscapeDataString`) everywhere it is interpolated into an M3U/HLS/XMLTV URL (XMLTV additionally XML-escapes the encoded value), so a structural character can't malform the manifest or guide; Serilog logs a scrubbed request path (`access_token` → `***` via `IncludeQueryInRequestPath = false` + a `RequestPathScrubbed` enricher), so a 5xx/Debug `/iptv` request never writes the token; and every dynamic token-bearing `/iptv` manifest (`channels.m3u`, `xmltv.xml`, the HLS multi-variant/media playlists) returns `Cache-Control: private, no-store`.
**Signals:** access_token quote-safety, url-tvg breakout, M3U attribute escaping, XMLTV icon src encoding, request-log token redaction, no-store manifest, token replay from logs/cache · paths: `ErsatzTV.Core/Iptv/ChannelPlaylist.cs`, `ErsatzTV/Controllers/IptvController.cs`, `ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs`, `ErsatzTV/Middleware/RequestLogScrubber.cs`, `ErsatzTV/Startup.cs`, `ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs` · issues: #421, #559, #552, #376
**Mechanics:** `ChannelPlaylistAccessTokenTests`; `RequestLogScrubberTests`; `IptvControllerCacheHeaderTests`
Resolves the "deferred hardening" bullet under `security.iptv-browser-token` (#552). The shared `?access_token=`
transport had three pre-existing weaknesses that #552's short-lived browser token only *bounded* (not fixed);
they predate #552 because Jellyfin and the M3U playlist already pass `access_token` in `/iptv` URLs.
- **Percent-encode the token in M3U/HLS URLs (#421).** `ChannelPlaylist.ToM3U` interpolates the token into
quoted M3U attributes (`url-tvg="..."`, `tvg-logo="..."`) and bare stream URLs; `IptvController.AccessTokenQuery`
and the internal segmenter URL (`FFmpegLibraryProcessService.WrapSegmenter`) do the same. A token containing a
`"` could terminate a quoted attribute early, and an `&` could split the query — for strict parsers. The value
is a URL query parameter, so the correct fix is percent-encoding (`Uri.EscapeDataString`) at each interpolation
site; ASP.NET decodes `Request.Query`, so the round-trip is lossless and validation is unchanged. For a normal
base64url JWT this is a **no-op** (every character is RFC 3986 unreserved), so the M3U goldens are byte-identical.
This is the M3U analogue of #376's XMLTV `SecurityElement.Escape` fix — #376 correctly did **not** apply to M3U
(M3U is not XML), which is why #421 was split out. The **XMLTV twin** (`GetChannelGuideHandler`) is fixed the same
way for symmetry: the token there lands in a URL query value *inside* an XML attribute, so it now percent-encodes
**then** XML-escapes (`SecurityElement.Escape(Uri.EscapeDataString(token))`) — #376's XML-escape alone guarded XML
well-formedness but not URL correctness, so a token with `&` would still truncate the query after the consumer
XML-unescapes the attribute.
- **Redact the token from request logs (#559).** `UseSerilogRequestLogging` had `IncludeQueryInRequestPath = true`,
and the `ex != null` / `StatusCode > 499` branches log at **Error** regardless of path — so a 5xx on a tokened
`/iptv` URL wrote the replayable, globally-scoped token to the log under default settings. Fix:
`IncludeQueryInRequestPath = false` (built-in `RequestPath` is now path-only) plus a `RequestPathScrubbed`
diagnostic-context property — built by `RequestLogScrubber.ScrubbedPath`, which rebuilds the query with the
`access_token` value masked to `***` and everything else (e.g. `mode=segmenter`) preserved for debugging — that
the message template references in place of `{RequestPath}`. A distinct property name is required because Serilog
appends its own `RequestPath` *after* enriched properties, so reusing the name would let the built-in value win.
**Load-bearing residual:** this scrubs only `UseSerilogRequestLogging`. ASP.NET's own
`Microsoft.AspNetCore.Hosting.Diagnostics` "Request starting" line logs the raw query (token and all) at
Information; it is suppressed today solely by the `"Microsoft": "Warning"` override in `appsettings.json`, which
is **not** reachable from the runtime logging-settings UI (the `HttpLevelSwitch` overrides only
`Serilog.AspNetCore.RequestLoggingMiddleware`). Keep that override — lowering `Microsoft` to Information/Debug for
routing/auth debugging re-leaks the token. A category-agnostic scrub filter is the follow-up if that override is
ever relaxed.
- **`no-store` on token-bearing dynamic manifests (#559).** `channels.m3u`, `xmltv.xml`, and the HLS
multi-variant/media playlists embed the caller's token in their body/URLs but set no cache headers, so a browser
or intermediary could cache the token-bearing manifest. Each now returns `Cache-Control: private, no-store`. This
covers the Jellyfin-facing M3U/XMLTV as well as the #552 HLS preview manifests — all three share the same
cache-replay risk — and does not affect Jellyfin, which refetches the playlist/guide on its own schedule
regardless of HTTP cache headers. The `.ts` live streams and the ungated HLS segments (served by `UseStaticFiles`
at `/iptv/session`, outside `ConditionalIptvAuthorizeFilter`) carry no token and are unchanged.
- 2026-07-11 — Baseline security response headers + Phase-0 API hardening (#197, PR #279) — [`security.baseline-response-headers`](records/security/baseline-response-headers.md)
- 2026-07-11 — Blazor removal auth posture: no new exposure beyond phase (a); real auth deferred to #197 (#206) — [`security.blazor-removal-auth-posture`](records/security/blazor-removal-auth-posture.md)
- 2026-07-12 (#197 Bundle C — contract-freeze honesty) — [`security.contract-freeze-honesty`](records/security/contract-freeze-honesty.md)
- 2026-07-12 — #295 PR2: SPA session cutover + #301 side-effecting-GET POST-ification — [`security.session-cutover-postify`](records/security/session-cutover-postify.md)
- 2026-07-12 — Artwork content-type is sniffed, never reflected (#283, S4/S9 stored XSS) — [`security.artwork-content-type-sniff`](records/security/artwork-content-type-sniff.md)
- 2026-07-12 — Browser SPA session auth: `/api` accepts session OR machine key (#295 PR1, server-only) — [`security.session-auth-dual-credential`](records/security/session-auth-dual-credential.md)
- 2026-07-12 — Enforcing CSP + Permissions-Policy on the host (#319, ZAP baseline) — [`security.csp-permissions-policy`](records/security/csp-permissions-policy.md)
- 2026-07-12 — Fail-closed API auth + sensitive-read tier + CORS/ForwardedHeaders lockdown (#197 Bundle A, PR #292) — [`security.fail-closed-api-auth`](records/security/fail-closed-api-auth.md)
- 2026-07-13 — Cross-origin resource policy: `same-origin` on every response (#330) — [`security.corp-same-origin`](records/security/corp-same-origin.md)
- 2026-07-22 — Short-lived browser IPTV token so the SPA reaches `/iptv/*` under JWT auth (#552) — [`security.iptv-browser-token`](records/security/iptv-browser-token.md)
- 2026-07-23 — access_token transport hardening: percent-encode in M3U/HLS, redact from logs, no-store on tokened manifests (#421, #559) — [`security.iptv-access-token-transport`](records/security/iptv-access-token-transport.md)
+5 -45
View File
@@ -7,50 +7,10 @@ in the active read-path). Active successor for channel health: `api.channel-heal
---
## 2026-07-17 — Channel health on the API = the raw `PlayoutCount` fact on the list DTO, not a derived status enum (#72)
`key: api.channel-health-signal` · `status: superseded` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: api.channel-health-object@2026-07-23`
**Rule:** (superseded) Channel health rides `ChannelResponseModel`/`ChannelListItem` DTOs as a raw `int PlayoutCount` fact (free — `GetAll` already `Include`s `Playouts`), not a new endpoint, not `/channels/state` (runtime-liveness cadence), and not a derived `ChannelHealth` enum (would freeze policy before the #383/#384 auto-tune status taxonomy lands).
**Signals:** channel health, PlayoutCount, config-derived vs runtime-liveness cadence, frozen /api/v1 · paths: `ChannelRepository.GetChannel`, `Mapper.GetPlayoutsCount`, `api-conventions.md` §3a · issues: #72, #383, #384, #401
**Mechanics:** superseded by `api.channel-health-object` (ersatztv#415); see `docs/decisions.md` → that record for the `health` object that replaces `PlayoutCount`-as-verdict
## Records formerly in this file
#72 asks for per-channel status in the Channels list, "especially channels that will fail to play".
Each record below moved to its own file under `records/` (ersatztv#610); the rationale is
unchanged. Resolve by **key** — that is the stable identity. A date-based pointer from
another doc or an old issue comment should land here and then follow the link.
**Where it lives: `ChannelResponseModel` (the lean list DTO), not a new endpoint and not
`/channels/state`.** Channel health is *config-derived* — it changes when someone edits a playout, not
tick to tick — whereas `/channels/state` is the fast-poll runtime-liveness feed (`OnAir` = someone is
streaming *right now*). Folding health into the polled feed would recompute rarely-changing data every
tick and mix two cadences in one DTO; a third endpoint is over-engineering for one derived integer on a
list whose consumer already reads it. Precedent: `api-conventions.md` §3a stamps the server-derived
`IsLocked` onto `PlayoutListItemResponseModel` for exactly this reason. **It is also free**: `GetAll`
already `Include`s `Playouts` and `MirrorSourceChannel.Playouts` and was discarding them, so no extra
query and no N+1 on a large lineup.
**A raw fact (`int PlayoutCount`), not a `ChannelHealth` enum.** v1 has exactly one trustworthy negative
signal, and the auto-tune arc (#383/#384) is about to churn the status taxonomy (origin, auto-tune
outcomes) — freezing a server-side enum now guarantees a breaking rev of a frozen-additive `/api/v1`
surface. `PlayoutCount` mirrors the long-standing `ChannelViewModel.PlayoutCount` 1:1, is a fact rather
than a policy, and leaves the SPA to derive `0 ⇒ "No playout"` in one predicate.
**What v1 deliberately does NOT compute** — each was considered and ruled out, so don't "finish" them
without reading this:
- **Empty schedule behind an existing playout.** `EmptyScheduleHealthCheck` only understands **Classic**
`ProgramSchedule` playouts. Block, Sequential, Scripted and ExternalJson channels have no
`ProgramSchedule` at all, so a badge driven off that query would be silently absent or wrong for four of
the five schedule kinds — the #71 "verify a shared primitive covers ALL variants" trap. Needs a
per-kind emptiness notion first.
- **Broken / missing source.** `FileNotFound`/`Unavailable` are server-wide media-item counts with no
channel attribution; mapping media → collection → schedule → channel is a project, not a field.
- **User-defined vs auto-generated origin** (#72 scope item a). No honest signal exists:
`Channel` has no origin column, and `ChannelPlayoutSource.Generated` is a *playout-strategy* value that
SPA-created blank channels also carry, so it would mislabel them. Requires a new column + a dual-provider
migration, and provenance belongs to the auto-tune arc that stamps it at creation. A join through the
`"Channel Lineups"` system playlist group was **rejected**: it is a heuristic that breaks the moment a
user edits the channel. Deferred to a follow-up blocked on the auto-tune backend.
Keeping all three out held #72 to a **read-path-only** change: no migration, no write-handler live-E2E.
**Corrected in passing:** `ChannelRepository.GetChannel` never included `Playouts`, so
`GET /api/v1/channels/{id}` reported `playoutCount: 0` for every channel, which silently disabled the
channel editor's playout-source guard. Both call sites now share `Mapper.GetPlayoutsCount` (Mirror-aware).
The related *silent* server-side coercion of Mirror→Generated (a 200 that discards the caller's intent,
against the §3 "surface it, don't silently filter" rule) is filed as **#401**, not fixed here.
- 2026-07-17 — Channel health on the API = the raw `PlayoutCount` fact on the list DTO, not a derived status enum (#72) — [`api.channel-health-signal`](api/channel-health-signal.md)
@@ -0,0 +1,53 @@
---
key: api.channel-health-signal
title: 2026-07-17 — Channel health on the API = the raw `PlayoutCount` fact on the list DTO, not a derived status enum (#72)
status: superseded
since: '2026-07-17'
supersedes: none
superseded-by: api.channel-health-object@2026-07-23
rule: '(superseded) Channel health rides `ChannelResponseModel`/`ChannelListItem` DTOs as a raw `int PlayoutCount` fact (free — `GetAll` already `Include`s `Playouts`), not a new endpoint, not `/channels/state` (runtime-liveness cadence), and not a derived `ChannelHealth` enum (would freeze policy before the #383/#384 auto-tune status taxonomy lands).'
signals: 'channel health, PlayoutCount, config-derived vs runtime-liveness cadence, frozen /api/v1 · paths: `ChannelRepository.GetChannel`, `Mapper.GetPlayoutsCount`, `api-conventions.md` §3a · issues: #72, #383, #384, #401'
mechanics: superseded by `api.channel-health-object` (ersatztv#415); see `docs/decisions.md` → that record for the `health` object that replaces `PlayoutCount`-as-verdict
---
#72 asks for per-channel status in the Channels list, "especially channels that will fail to play".
**Where it lives: `ChannelResponseModel` (the lean list DTO), not a new endpoint and not
`/channels/state`.** Channel health is *config-derived* — it changes when someone edits a playout, not
tick to tick — whereas `/channels/state` is the fast-poll runtime-liveness feed (`OnAir` = someone is
streaming *right now*). Folding health into the polled feed would recompute rarely-changing data every
tick and mix two cadences in one DTO; a third endpoint is over-engineering for one derived integer on a
list whose consumer already reads it. Precedent: `api-conventions.md` §3a stamps the server-derived
`IsLocked` onto `PlayoutListItemResponseModel` for exactly this reason. **It is also free**: `GetAll`
already `Include`s `Playouts` and `MirrorSourceChannel.Playouts` and was discarding them, so no extra
query and no N+1 on a large lineup.
**A raw fact (`int PlayoutCount`), not a `ChannelHealth` enum.** v1 has exactly one trustworthy negative
signal, and the auto-tune arc (#383/#384) is about to churn the status taxonomy (origin, auto-tune
outcomes) — freezing a server-side enum now guarantees a breaking rev of a frozen-additive `/api/v1`
surface. `PlayoutCount` mirrors the long-standing `ChannelViewModel.PlayoutCount` 1:1, is a fact rather
than a policy, and leaves the SPA to derive `0 ⇒ "No playout"` in one predicate.
**What v1 deliberately does NOT compute** — each was considered and ruled out, so don't "finish" them
without reading this:
- **Empty schedule behind an existing playout.** `EmptyScheduleHealthCheck` only understands **Classic**
`ProgramSchedule` playouts. Block, Sequential, Scripted and ExternalJson channels have no
`ProgramSchedule` at all, so a badge driven off that query would be silently absent or wrong for four of
the five schedule kinds — the #71 "verify a shared primitive covers ALL variants" trap. Needs a
per-kind emptiness notion first.
- **Broken / missing source.** `FileNotFound`/`Unavailable` are server-wide media-item counts with no
channel attribution; mapping media → collection → schedule → channel is a project, not a field.
- **User-defined vs auto-generated origin** (#72 scope item a). No honest signal exists:
`Channel` has no origin column, and `ChannelPlayoutSource.Generated` is a *playout-strategy* value that
SPA-created blank channels also carry, so it would mislabel them. Requires a new column + a dual-provider
migration, and provenance belongs to the auto-tune arc that stamps it at creation. A join through the
`"Channel Lineups"` system playlist group was **rejected**: it is a heuristic that breaks the moment a
user edits the channel. Deferred to a follow-up blocked on the auto-tune backend.
Keeping all three out held #72 to a **read-path-only** change: no migration, no write-handler live-E2E.
**Corrected in passing:** `ChannelRepository.GetChannel` never included `Playouts`, so
`GET /api/v1/channels/{id}` reported `playoutCount: 0` for every channel, which silently disabled the
channel editor's playout-source guard. Both call sites now share `Mapper.GetPlayoutsCount` (Mirror-aware).
The related *silent* server-side coercion of Mirror→Generated (a 200 that discards the caller's intent,
against the §3 "surface it, don't silently filter" rule) is filed as **#401**, not fixed here.
@@ -0,0 +1,33 @@
---
key: docs.append-only-guard
title: 2026-07-12 — decisions.md is append-only, enforced; root-screenshot guard (#303 H9/H3)
status: superseded
since: '2026-07-12'
supersedes: none
superseded-by: docs.decision-lifecycle@2026-07-21
rule: (superseded) `docs/decisions.md` is append-only, enforced by hook + CI.
signals: 'decisions-guard hook · paths: `.claude/hooks/decisions-guard.sh`, `.husky/commit-msg` · issues: #303 (H9)'
mechanics: superseded by `scripts/decisions_validate.py` (ersatztv#521); see `docs/decisions.md` → `docs.decision-lifecycle`
---
**This log is append-only by construction, not just by convention.** A commit or PR that deletes or
modifies an existing line of `docs/decisions.md` is blocked — by the Husky `commit-msg` hook
(`.claude/hooks/decisions-guard.sh staged`) locally and the blocking `decisions-guard` CI job (same
script, `range` mode) on PRs. Shared detection, deliberately different granularity: the Husky hook
gates **each commit** (its own message must carry the token); CI gates the **PR-wide** net diff
(token in any commit of the range suffices), so the local hook is the stricter primary gate and CI the
push/bypass backstop. Insertions anywhere are always allowed, so a normal new entry (TOC line
near the top + a block appended at the bottom, both pure insertions) passes untouched. Detection is
`git diff --numstat` deleted-count > 0, which is robust to markdown `-` list markers (a byte-level `-`
prefix would false-match). The block is lifted only by the literal **`[decisions-edit]`** token in the
commit message, reserved for two cases: fixing a factual error, and superseding a reversed decision
(add the new entry, prepend a `> **Superseded …**` banner to the old one, tag its Index line
`(superseded)` — keep the old rationale, never silently rewrite). **Consolidation** of superseded
entries is a release-checklist step (`docs/ci-cd.md` → Versioning & releases), backstopped by a
non-blocking 1800-line **size floor** in the `decisions-guard` job (the read-cost point past which the
log no longer fits one default agent Read), so append-only doesn't accrete contradictory *or
unreadably-large* history between releases (Timothy's call, 2026-07-12: mark-and-keep on reversal,
consolidate at each milestone, size-floor backstop).
The companion H3 root-screenshot guard was split out during the #521 migration to the active record
`ci.root-screenshot-guard` in `docs/decisions/release-ci-governance.md`.
@@ -0,0 +1,37 @@
---
key: docs.queue-state-gitea-tracker
title: 2026-07-11 — Queue state lives in the pinned Gitea tracker (#237), not in the handoff file
status: superseded
since: '2026-07-11'
supersedes: none
superseded-by: startup.parallel-orientation@2026-07-21
rule: '(superseded) Volatile session/queue state lives in pinned Gitea tracker issue #237 (goal + arc in the body, append-only session-comment log, in-progress/review labels), not in a wholesale-rewritten handoff file; the handoff file keeps only the static kickoff prompt and append-only lore.'
signals: 'last-writer-wins race, claim/`in-progress` label, triage verdict · paths: `docs/handoffs/chicorytv-issue-queue.md` · issues: #237, #520'
mechanics: superseded by `scripts/select-queue.sh` (ersatztv#520); see `docs/decisions.md` → `startup.parallel-orientation`
---
With multiple sessions/agents working the repo in parallel, the old protocol — every session
wholesale-rewrites `docs/handoffs/chicorytv-issue-queue.md` on main (session state + queue +
next-session prompt) — became a last-writer-wins race. New protocol: **volatile queue state
moved to Gitea**, which is concurrency-safe by construction. Pinned tracker issue **#237**
holds the goal + ordered arc in its body (edited rarely, only on arc changes, re-read before
edit) and an append-only session-comment log (fixed template: Closed / Filed / Triage /
Arc change / Recommended next). Milestone `Blazor removal (#91 phase b)` + the `review` and
`in-progress` labels are the machine-queryable view. Sessions **claim** an issue before working
it (`in-progress` label + claim comment; the tiny read→claim race window is accepted, later
claimant backs off; stale claims — no commits/comments ~48h — may be taken over with a comment).
Every new issue gets an explicit end-of-session triage verdict — gate-blocker (milestone + arc
slot) or backlog (label only) — so review findings adjust the queue only through that step and
the arc doesn't drift. The handoff file keeps only the **static kickoff prompt** and the
**append-only Lessons lore** (per-session prompts are gone; task context lives in issue bodies).
**Why superseded (#520, 2026-07-21):** the arc completed and #237 closed (2026-07-13); a closed
tracker cannot serve as live queue state, and continuing to read it as such was a live regression
risk (an agent skimming an old comment or this very record could re-treat #237's prose as current).
`scripts/select-queue.sh` (2026-07-19) already replaced the mechanical parts of this rule with a
deterministic, live-Gitea-only query — this record's job was really "queue state lives in Gitea,
not in the handoff file," and that half is still true; what's superseded is the *specific store*
(#237's body/comments) now that maintenance/backlog mode has no arc to narrate. See
`startup.parallel-orientation` for the replacement: two concurrent session-start tracks
(orientation via the docs/decisions catalog + `docs/README.md` map, and selection via the script),
with #237 reduced to a single archival breadcrumb.
@@ -14,30 +14,10 @@ directory is and `docs/decisions/migration-map.md` for the full mapping.
---
## 2026-07-12 — decisions.md is append-only, enforced; root-screenshot guard (#303 H9/H3)
`key: docs.append-only-guard` · `status: superseded` · `since: 2026-07-12` · `supersedes: none` · `superseded-by: docs.decision-lifecycle@2026-07-21`
**Rule:** (superseded) `docs/decisions.md` is append-only, enforced by hook + CI.
**Signals:** decisions-guard hook · paths: `.claude/hooks/decisions-guard.sh`, `.husky/commit-msg` · issues: #303 (H9)
**Mechanics:** superseded by `scripts/decisions_validate.py` (ersatztv#521); see `docs/decisions.md` → `docs.decision-lifecycle`
## Records formerly in this file
**This log is append-only by construction, not just by convention.** A commit or PR that deletes or
modifies an existing line of `docs/decisions.md` is blocked — by the Husky `commit-msg` hook
(`.claude/hooks/decisions-guard.sh staged`) locally and the blocking `decisions-guard` CI job (same
script, `range` mode) on PRs. Shared detection, deliberately different granularity: the Husky hook
gates **each commit** (its own message must carry the token); CI gates the **PR-wide** net diff
(token in any commit of the range suffices), so the local hook is the stricter primary gate and CI the
push/bypass backstop. Insertions anywhere are always allowed, so a normal new entry (TOC line
near the top + a block appended at the bottom, both pure insertions) passes untouched. Detection is
`git diff --numstat` deleted-count > 0, which is robust to markdown `-` list markers (a byte-level `-`
prefix would false-match). The block is lifted only by the literal **`[decisions-edit]`** token in the
commit message, reserved for two cases: fixing a factual error, and superseding a reversed decision
(add the new entry, prepend a `> **Superseded …**` banner to the old one, tag its Index line
`(superseded)` — keep the old rationale, never silently rewrite). **Consolidation** of superseded
entries is a release-checklist step (`docs/ci-cd.md` → Versioning & releases), backstopped by a
non-blocking 1800-line **size floor** in the `decisions-guard` job (the read-cost point past which the
log no longer fits one default agent Read), so append-only doesn't accrete contradictory *or
unreadably-large* history between releases (Timothy's call, 2026-07-12: mark-and-keep on reversal,
consolidate at each milestone, size-floor backstop).
Each record below moved to its own file under `records/` (ersatztv#610); the rationale is
unchanged. Resolve by **key** — that is the stable identity. A date-based pointer from
another doc or an old issue comment should land here and then follow the link.
The companion H3 root-screenshot guard was split out during the #521 migration to the active record
`ci.root-screenshot-guard` in `docs/decisions/release-ci-governance.md`.
- 2026-07-12 — decisions.md is append-only, enforced; root-screenshot guard (#303 H9/H3) — [`docs.append-only-guard`](docs/append-only-guard.md)
+5 -38
View File
@@ -7,43 +7,10 @@ verbatim, never in the active read-path). Active successor for music-video recon
---
## 2026-07-20 — `JellyfinMusicVideoLibraryScanner` reconciles by library-scoped path diff + hard delete, not server itemId soft-trash (#494)
`key: scan.musicvideo-reconciliation` · `status: superseded` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: scan.musicvideo-server-identity@2026-07-25`
**Rule:** (superseded) `JellyfinMusicVideoLibraryScanner` reconciles removed music videos by a library-scoped local-path diff plus hard delete (`TrashMissingMusicVideos`), not the server-itemId soft-trash pattern the other media-server scanners use, because music videos carry no server identity.
**Signals:** music-video trash sweep, path-based identity, cross-kind safety, path-keyed identity, empty-fetch guard reuse, remove-stale+add-new dedup · paths: `JellyfinMusicVideoLibraryScanner.TrashMissingMusicVideos`, `FindMusicVideoPaths`/`DeleteByPath`, `IMusicVideoRepository`, `MediaServerReconciliationGuard` · issues: #494, #477, #488, #496, #500
**Mechanics:** `ScanLibrary_Should_Not_CrossDelete_Movie_Or_Show_Sharing_The_LibraryPath`; `ScanLibrary_Should_Not_Sweep_When_Jellyfin_Returns_Zero_Items`; integration tests extending the #488 harness. #500 — when mirroring the remove-stale + add-new idiom, dedup the incoming set on **the same key its add filter compares** (the filter is materialized before the loop mutates `existing`, so duplicates both pass): `Name`, `Guid` for guids, and for Plex `Actors` an artwork-preferring dedup shared with the remove filter (whose key is `(Name, artwork-presence)`). Remaining un-deduped copies of the idiom: #600. Superseded by `scan.musicvideo-server-identity` (ersatztv#496): music videos gained a `JellyfinMusicVideo` ItemId/Etag identity, so the path diff + hard delete became an itemId diff + soft `FileNotFound` trash.
## Records formerly in this file
The Jellyfin music-video scanner did add/update only — a music video removed on the Jellyfin side lingered in
ErsatzTV forever and could still be scheduled. It now runs a trash sweep at the end of `ScanLibrary`
(`TrashMissingMusicVideos`), mirroring the `MediaServer{Movie,Television,OtherVideo}LibraryScanner` "gone
upstream ⇒ remove" pattern but with a deliberately different identity function, because music videos lack the
media-server identity those base scanners rely on.
Each record below moved to its own file under `records/` (ersatztv#610); the rationale is
unchanged. Resolve by **key** — that is the stable identity. A date-based pointer from
another doc or an old issue comment should land here and then follow the link.
- **Identity is (LibraryPathId, path), not server itemId.** The base scanners diff `GetExisting*` (keyed by
`MediaServerItemId`) against the incoming server item ids, then soft-trash via `FlagFileNotFound`. Music videos
have **no `JellyfinMusicVideo` entity and no `ItemId`/`Etag`** — the scanner is a standalone
`IJellyfinMusicVideoLibraryScanner` that injects the *local* `IMusicVideoRepository`, which offers no
itemId-keyed existing-set or flag seam. So the sweep diffs the **local path** set instead: existing =
`FindMusicVideoPaths(libraryPath)` `.Except` the incoming items' replaced local paths, then hard-deletes the
remainder with `DeleteByPath` + `IScannerProxy.RemoveMediaItems`, and cleans now-empty artists with
`IArtistRepository.DeleteEmptyArtists`. Hard delete (not soft `FileNotFound` trash) because there is no
per-item FileNotFound seam on this path and the issue's Done-when is "removed".
- **Cross-kind safety is a property of the queries, not the media kind.** `MediaItem` is TPT with `LibraryPathId`
on the abstract base, so a Movie, Show and MusicVideo can share one `LibraryPath` (a mixed Jellyfin library).
Both `FindMusicVideoPaths` and `DeleteByPath` filter `LibraryPathId` **and** join the concrete `MusicVideo`
table, so the sweep can only ever see/delete music videos — a Movie/Show under the same `LibraryPath` is
invisible to it. Pinned by `ScanLibrary_Should_Not_CrossDelete_Movie_Or_Show_Sharing_The_LibraryPath`.
- **Reuses the #477 empty-fetch guard.** The sweep is gated by `MediaServerReconciliationGuard.ShouldFlagMissing`
— a successful fetch that returns zero items (server mid-restore / transient) is indistinguishable from a real
emptying, so the whole-library wipe is refused and logged. Pinned as a negative control by
`ScanLibrary_Should_Not_Sweep_When_Jellyfin_Returns_Zero_Items` (removing the guard flips it red).
- **Known limitation (deferred to per-library identity).** `MusicVideoRepository.GetOrAdd` dedups a path
**globally** (no `LibraryPathId` predicate), so a file served by two libraries with overlapping local paths is
a single row owned by whichever library scanned it first. If that owner later stops reporting the file while
another library still serves it, this sweep removes the shared row. A proper fix needs per-library music-video
identity (a `JellyfinMusicVideo` etag entity + migration) — the issue's "option 2 / fold into the base
scanner" refactor — tracked as #496.
- **Tests.** Integration tests (real `ArtistRepository`/`MusicVideoRepository`/`LibraryRepository` over in-memory
SQLite, extending the #488 harness) pin removal, empty-artist cleanup, cross-kind safety, and the empty-fetch
guard. Proven non-vacuous: all four fail against the pre-fix scanner except the guard control, which only
earns its keep once the sweep exists.
- 2026-07-20 — `JellyfinMusicVideoLibraryScanner` reconciles by library-scoped path diff + hard delete, not server itemId soft-trash (#494) — [`scan.musicvideo-reconciliation`](scan/musicvideo-reconciliation.md)
@@ -0,0 +1,46 @@
---
key: scan.musicvideo-reconciliation
title: 2026-07-20 — `JellyfinMusicVideoLibraryScanner` reconciles by library-scoped path diff + hard delete, not server itemId soft-trash (#494)
status: superseded
since: '2026-07-20'
supersedes: none
superseded-by: scan.musicvideo-server-identity@2026-07-25
rule: (superseded) `JellyfinMusicVideoLibraryScanner` reconciles removed music videos by a library-scoped local-path diff plus hard delete (`TrashMissingMusicVideos`), not the server-itemId soft-trash pattern the other media-server scanners use, because music videos carry no server identity.
signals: 'music-video trash sweep, path-based identity, cross-kind safety, path-keyed identity, empty-fetch guard reuse, remove-stale+add-new dedup · paths: `JellyfinMusicVideoLibraryScanner.TrashMissingMusicVideos`, `FindMusicVideoPaths`/`DeleteByPath`, `IMusicVideoRepository`, `MediaServerReconciliationGuard` · issues: #494, #477, #488, #496, #500'
mechanics: '`ScanLibrary_Should_Not_CrossDelete_Movie_Or_Show_Sharing_The_LibraryPath`; `ScanLibrary_Should_Not_Sweep_When_Jellyfin_Returns_Zero_Items`; integration tests extending the #488 harness. #500 — when mirroring the remove-stale + add-new idiom, dedup the incoming set on **the same key its add filter compares** (the filter is materialized before the loop mutates `existing`, so duplicates both pass): `Name`, `Guid` for guids, and for Plex `Actors` an artwork-preferring dedup shared with the remove filter (whose key is `(Name, artwork-presence)`). Remaining un-deduped copies of the idiom: #600. Superseded by `scan.musicvideo-server-identity` (ersatztv#496): music videos gained a `JellyfinMusicVideo` ItemId/Etag identity, so the path diff + hard delete became an itemId diff + soft `FileNotFound` trash.'
---
The Jellyfin music-video scanner did add/update only — a music video removed on the Jellyfin side lingered in
ErsatzTV forever and could still be scheduled. It now runs a trash sweep at the end of `ScanLibrary`
(`TrashMissingMusicVideos`), mirroring the `MediaServer{Movie,Television,OtherVideo}LibraryScanner` "gone
upstream ⇒ remove" pattern but with a deliberately different identity function, because music videos lack the
media-server identity those base scanners rely on.
- **Identity is (LibraryPathId, path), not server itemId.** The base scanners diff `GetExisting*` (keyed by
`MediaServerItemId`) against the incoming server item ids, then soft-trash via `FlagFileNotFound`. Music videos
have **no `JellyfinMusicVideo` entity and no `ItemId`/`Etag`** — the scanner is a standalone
`IJellyfinMusicVideoLibraryScanner` that injects the *local* `IMusicVideoRepository`, which offers no
itemId-keyed existing-set or flag seam. So the sweep diffs the **local path** set instead: existing =
`FindMusicVideoPaths(libraryPath)` `.Except` the incoming items' replaced local paths, then hard-deletes the
remainder with `DeleteByPath` + `IScannerProxy.RemoveMediaItems`, and cleans now-empty artists with
`IArtistRepository.DeleteEmptyArtists`. Hard delete (not soft `FileNotFound` trash) because there is no
per-item FileNotFound seam on this path and the issue's Done-when is "removed".
- **Cross-kind safety is a property of the queries, not the media kind.** `MediaItem` is TPT with `LibraryPathId`
on the abstract base, so a Movie, Show and MusicVideo can share one `LibraryPath` (a mixed Jellyfin library).
Both `FindMusicVideoPaths` and `DeleteByPath` filter `LibraryPathId` **and** join the concrete `MusicVideo`
table, so the sweep can only ever see/delete music videos — a Movie/Show under the same `LibraryPath` is
invisible to it. Pinned by `ScanLibrary_Should_Not_CrossDelete_Movie_Or_Show_Sharing_The_LibraryPath`.
- **Reuses the #477 empty-fetch guard.** The sweep is gated by `MediaServerReconciliationGuard.ShouldFlagMissing`
— a successful fetch that returns zero items (server mid-restore / transient) is indistinguishable from a real
emptying, so the whole-library wipe is refused and logged. Pinned as a negative control by
`ScanLibrary_Should_Not_Sweep_When_Jellyfin_Returns_Zero_Items` (removing the guard flips it red).
- **Known limitation (deferred to per-library identity).** `MusicVideoRepository.GetOrAdd` dedups a path
**globally** (no `LibraryPathId` predicate), so a file served by two libraries with overlapping local paths is
a single row owned by whichever library scanned it first. If that owner later stops reporting the file while
another library still serves it, this sweep removes the shared row. A proper fix needs per-library music-video
identity (a `JellyfinMusicVideo` etag entity + migration) — the issue's "option 2 / fold into the base
scanner" refactor — tracked as #496.
- **Tests.** Integration tests (real `ArtistRepository`/`MusicVideoRepository`/`LibraryRepository` over in-memory
SQLite, extending the #488 harness) pin removal, empty-artist cleanup, cross-kind safety, and the empty-fetch
guard. Proven non-vacuous: all four fail against the pre-fix scanner except the guard control, which only
earns its keep once the sweep exists.
+5 -37
View File
@@ -7,42 +7,10 @@ in the active read-path). Active successor for the rule builder: `spa.rulebuilde
---
## 2026-07-18 — SmartCollection rule builder: compile-only closed subset, no stored AST, one-level nesting (#176)
`key: spa.smartcollection-rule-builder` · `status: superseded` · `since: 2026-07-18` · `supersedes: none` · `superseded-by: spa.rulebuilder-nesting@2026-07-25`
**Rule:** (superseded) The SmartCollection visual rule builder compiles to/from a closed subset of the Lucene grammar over the existing stored query string — no new AST, one level of group nesting.
**Signals:** SmartCollection, rule builder, Lucene compile/parse · paths: `web/src/builder/rules/`, `compile.ts`, `parse.ts`, `roundtrip.test.ts` · issues: #176, #69
**Mechanics:** superseded by `spa.rulebuilder-nesting` (ersatztv#436) — the compile-only closed-subset and field-catalog stances carry forward there; only the one-level nesting cap was reversed. See `docs/decisions.md` → that record; `spa-conventions.md` §12
## Records formerly in this file
The SmartCollection create/edit dialog gained a visual rule builder (`web/src/builder/rules/`)
alongside the existing raw-Lucene textarea. **The SmartCollection still stores a plain Lucene query
string — no new stored rule AST, no schema change.** The builder compiles its in-memory rule tree
into a **closed subset** of the Lucene grammar (`compile.ts`) and parses exactly that subset back out
(`parse.ts`, the exact inverse — returns `null`, not a best-effort guess, for anything outside the
subset); escaping is total, so any builder-authored query round-trips losslessly, proven by a 500-tree
property test (`roundtrip.test.ts`, including Lucene special characters). Opening an existing
SmartCollection tries the parse first and falls back to raw-text mode on `null` (fuzzy queries,
boosts, mixed AND/OR at one nesting level, or nesting deeper than one level).
Each record below moved to its own file under `records/` (ersatztv#610); the rationale is
unchanged. Resolve by **key** — that is the stable identity. A date-based pointer from
another doc or an old issue comment should land here and then follow the link.
**Why compile-only over persisting an authoritative rule AST**: an AST would still need a
Lucene→rules parser to open every *pre-existing* free-text query — including every query the
Auto-Tune feature (#69) generates — so the AST would buy almost nothing (it still can't represent
arbitrary hand-written Lucene) while costing a dual-provider EF migration and a second source of
truth to keep in sync with the Lucene grammar. Compile-only keeps the query string as the single
source of truth and treats the builder as a structured *editor* over it, not a new storage model.
**One-level-nesting "Kodi" model.** `types.ts` defines a top `Group` (`match: all|any`) over `Rule`s
and/or **one level** of sub-`Group`s — enough to express `type:movie AND (genre:Horror OR
genre:Thriller)`, which covers the smart-playlist patterns Kodi-style rule builders are known for.
Arbitrary/recursive nesting was scoped out as YAGNI; revisit only if a real query needs it.
**Field vocabulary comes from a new catalog endpoint, not a hardcoded list.** `GET
/api/v1/search/fields` (read-only, MCP-introspectable; see `api-conventions.md`) returns the curated,
typed, labeled field set derived from `LuceneSearchIndex` — name/label/type/group/values — and is the
single source of truth the builder's field pickers (`fieldCatalog.ts`'s `useSearchFields` hook) and
operator/value-input choices are driven from, so the builder's vocabulary can't drift from what the
index actually supports.
**Deferred as separate follow-up issues** (explicitly out of scope for #176): facet-value typeahead
for value inputs, relative-date operators, nesting deeper than one level, and inline adoption of
`RuleBuilder` by ChannelBuilder / Auto-Tune (it was built reusable for exactly that reuse — see
`spa-conventions.md` §12).
- 2026-07-18 — SmartCollection rule builder: compile-only closed subset, no stored AST, one-level nesting (#176) — [`spa.smartcollection-rule-builder`](spa/smartcollection-rule-builder.md)
@@ -0,0 +1,45 @@
---
key: spa.smartcollection-rule-builder
title: '2026-07-18 — SmartCollection rule builder: compile-only closed subset, no stored AST, one-level nesting (#176)'
status: superseded
since: '2026-07-18'
supersedes: none
superseded-by: spa.rulebuilder-nesting@2026-07-25
rule: (superseded) The SmartCollection visual rule builder compiles to/from a closed subset of the Lucene grammar over the existing stored query string — no new AST, one level of group nesting.
signals: 'SmartCollection, rule builder, Lucene compile/parse · paths: `web/src/builder/rules/`, `compile.ts`, `parse.ts`, `roundtrip.test.ts` · issues: #176, #69'
mechanics: superseded by `spa.rulebuilder-nesting` (ersatztv#436) — the compile-only closed-subset and field-catalog stances carry forward there; only the one-level nesting cap was reversed. See `docs/decisions.md` → that record; `spa-conventions.md` §12
---
The SmartCollection create/edit dialog gained a visual rule builder (`web/src/builder/rules/`)
alongside the existing raw-Lucene textarea. **The SmartCollection still stores a plain Lucene query
string — no new stored rule AST, no schema change.** The builder compiles its in-memory rule tree
into a **closed subset** of the Lucene grammar (`compile.ts`) and parses exactly that subset back out
(`parse.ts`, the exact inverse — returns `null`, not a best-effort guess, for anything outside the
subset); escaping is total, so any builder-authored query round-trips losslessly, proven by a 500-tree
property test (`roundtrip.test.ts`, including Lucene special characters). Opening an existing
SmartCollection tries the parse first and falls back to raw-text mode on `null` (fuzzy queries,
boosts, mixed AND/OR at one nesting level, or nesting deeper than one level).
**Why compile-only over persisting an authoritative rule AST**: an AST would still need a
Lucene→rules parser to open every *pre-existing* free-text query — including every query the
Auto-Tune feature (#69) generates — so the AST would buy almost nothing (it still can't represent
arbitrary hand-written Lucene) while costing a dual-provider EF migration and a second source of
truth to keep in sync with the Lucene grammar. Compile-only keeps the query string as the single
source of truth and treats the builder as a structured *editor* over it, not a new storage model.
**One-level-nesting "Kodi" model.** `types.ts` defines a top `Group` (`match: all|any`) over `Rule`s
and/or **one level** of sub-`Group`s — enough to express `type:movie AND (genre:Horror OR
genre:Thriller)`, which covers the smart-playlist patterns Kodi-style rule builders are known for.
Arbitrary/recursive nesting was scoped out as YAGNI; revisit only if a real query needs it.
**Field vocabulary comes from a new catalog endpoint, not a hardcoded list.** `GET
/api/v1/search/fields` (read-only, MCP-introspectable; see `api-conventions.md`) returns the curated,
typed, labeled field set derived from `LuceneSearchIndex` — name/label/type/group/values — and is the
single source of truth the builder's field pickers (`fieldCatalog.ts`'s `useSearchFields` hook) and
operator/value-input choices are driven from, so the builder's vocabulary can't drift from what the
index actually supports.
**Deferred as separate follow-up issues** (explicitly out of scope for #176): facet-value typeahead
for value inputs, relative-date operators, nesting deeper than one level, and inline adoption of
`RuleBuilder` by ChannelBuilder / Auto-Tune (it was built reusable for exactly that reuse — see
`spa-conventions.md` §12).
+5 -29
View File
@@ -7,34 +7,10 @@ never in the active read-path). Active successor: `startup.parallel-orientation`
---
## 2026-07-11 — Queue state lives in the pinned Gitea tracker (#237), not in the handoff file
`key: docs.queue-state-gitea-tracker` · `status: superseded` · `since: 2026-07-11` · `supersedes: none` · `superseded-by: startup.parallel-orientation@2026-07-21`
**Rule:** (superseded) Volatile session/queue state lives in pinned Gitea tracker issue #237 (goal + arc in the body, append-only session-comment log, in-progress/review labels), not in a wholesale-rewritten handoff file; the handoff file keeps only the static kickoff prompt and append-only lore.
**Signals:** last-writer-wins race, claim/`in-progress` label, triage verdict · paths: `docs/handoffs/chicorytv-issue-queue.md` · issues: #237, #520
**Mechanics:** superseded by `scripts/select-queue.sh` (ersatztv#520); see `docs/decisions.md` → `startup.parallel-orientation`
## Records formerly in this file
With multiple sessions/agents working the repo in parallel, the old protocol — every session
wholesale-rewrites `docs/handoffs/chicorytv-issue-queue.md` on main (session state + queue +
next-session prompt) — became a last-writer-wins race. New protocol: **volatile queue state
moved to Gitea**, which is concurrency-safe by construction. Pinned tracker issue **#237**
holds the goal + ordered arc in its body (edited rarely, only on arc changes, re-read before
edit) and an append-only session-comment log (fixed template: Closed / Filed / Triage /
Arc change / Recommended next). Milestone `Blazor removal (#91 phase b)` + the `review` and
`in-progress` labels are the machine-queryable view. Sessions **claim** an issue before working
it (`in-progress` label + claim comment; the tiny read→claim race window is accepted, later
claimant backs off; stale claims — no commits/comments ~48h — may be taken over with a comment).
Every new issue gets an explicit end-of-session triage verdict — gate-blocker (milestone + arc
slot) or backlog (label only) — so review findings adjust the queue only through that step and
the arc doesn't drift. The handoff file keeps only the **static kickoff prompt** and the
**append-only Lessons lore** (per-session prompts are gone; task context lives in issue bodies).
Each record below moved to its own file under `records/` (ersatztv#610); the rationale is
unchanged. Resolve by **key** — that is the stable identity. A date-based pointer from
another doc or an old issue comment should land here and then follow the link.
**Why superseded (#520, 2026-07-21):** the arc completed and #237 closed (2026-07-13); a closed
tracker cannot serve as live queue state, and continuing to read it as such was a live regression
risk (an agent skimming an old comment or this very record could re-treat #237's prose as current).
`scripts/select-queue.sh` (2026-07-19) already replaced the mechanical parts of this rule with a
deterministic, live-Gitea-only query — this record's job was really "queue state lives in Gitea,
not in the handoff file," and that half is still true; what's superseded is the *specific store*
(#237's body/comments) now that maintenance/backlog mode has no arc to narrate. See
`startup.parallel-orientation` for the replacement: two concurrent session-start tracks
(orientation via the docs/decisions catalog + `docs/README.md` map, and selection via the script),
with #237 reduced to a single archival breadcrumb.
- 2026-07-11 — Queue state lives in the pinned Gitea tracker (#237), not in the handoff file — [`docs.queue-state-gitea-tracker`](docs/queue-state-gitea-tracker.md)
+11 -301
View File
@@ -11,308 +11,18 @@ cross-editor ETag rotation). Refs #197.
## Contents
- [2026-07-11 — Optimistic-concurrency contract for replace-all PUTs (#253 PR1: infra + Block reference)](#2026-07-11--optimistic-concurrency-contract-for-replace-all-puts-253-pr1-infra--block-reference)
- [2026-07-11 — #253 PR3: Diff + Scalar concurrency fan-out (Collection / Playout×2 / MultiCollection / RerunCollection)](#2026-07-11--253-pr3-diff--scalar-concurrency-fan-out-collection--playout2--multicollection--reruncollection)
- [2026-07-11 — Stable child identity for schedule-item replace (#259, split from #252/#253)](#2026-07-11--stable-child-identity-for-schedule-item-replace-259-split-from-252253)
- [2026-07-12 (#269 — non-If-Match root writers force-write past a concurrent Version bump)](#2026-07-12-269--non-if-match-root-writers-force-write-past-a-concurrent-version-bump)
- [2026-07-12 — Cross-editor ETag rotation completed for Collection/Playout config siblings (#269)](#2026-07-12--cross-editor-etag-rotation-completed-for-collectionplayout-config-siblings-269)
- [2026-07-12 — If-Match evaluates per RFC 7232: valid-but-non-matching → 412, only grammar violations → 400 (#265)](#2026-07-12--if-match-evaluates-per-rfc-7232-valid-but-non-matching--412-only-grammar-violations--400-265)
- [2026-07-18 — Concurrent same-item add is idempotent, not a 500: catch the unique-violation per provider (#308)](#2026-07-18--concurrent-same-item-add-is-idempotent-not-a-500-catch-the-unique-violation-per-provider-308)
---
## 2026-07-11 — Optimistic-concurrency contract for replace-all PUTs (#253 PR1: infra + Block reference)
`key: concurrency.replace-all-contract` · `status: active` · `since: 2026-07-11` · `supersedes: none` · `superseded-by: none`
**Rule:** Replace-all aggregate PUTs carry a uniform plain `int Version` concurrency token (EF `.IsConcurrencyToken()`), checked pre-save and enforced by the EF UPDATE guard, returning 412 (not 409) on a stale `If-Match`.
**Signals:** ETag, If-Match, Version token, 412 Precondition Failed · paths: `api-conventions.md` §7a, `ConcurrencyHeaders`, `ApiResults.ToErrorResult` · issues: #253, #197, #265, #259
**Mechanics:** `docs/api-conventions.md` §7a; `SaveChangesWithConcurrencyGuard`
## Records formerly in this file
Replace-all aggregate PUTs had **no** optimistic concurrency — a stale second tab silently overwrote a
fresher edit (200, no signal) across ~10 aggregate surfaces. PR1 lands the shared contract on the Block
reference aggregate; PRs 2–4 fan it out. The full ratified design + independent-review hardening is
[#253#issuecomment-8472](http://192.168.1.95:3000/timothy/ersatztv/issues/253#issuecomment-8472);
the mechanics live in `api-conventions.md` §7a. Decisions frozen here:
Each record below moved to its own file under `records/` (ersatztv#610); the rationale is
unchanged. Resolve by **key** — that is the stable identity. A date-based pointer from
another doc or an old issue comment should land here and then follow the link.
- **Token = uniform plain `int Version`** on each root implementing `IVersionedAggregate`, EF-mapped
`.IsConcurrencyToken()`, one dual-provider migration (`AddAggregateVersions`, `defaultValue: 0`). **Not**
a reused `DateUpdated` (tick-collision, SQLite TEXT precision, couples UI cosmetics to correctness) and
**not** a MySQL-native rowversion (portability over provider-native).
- **412 Precondition Failed**, not 409 — 409 stays the §3a EntityLocker "build in progress" guard;
distinct codes → distinct SPA UX. New `PreconditionFailedError : BaseError` → 412 in `ApiResults.ToErrorResult`.
- **Pre-check AND EF token both required.** The handler pre-check (a standalone `Either` introduced AFTER
the validation pipeline — never via `Apply`, which `Join()`-flattens the subtype to 422) gives a clean
412; the unconditional `root.Version++` + `IsConcurrencyToken` UPDATE-guard + a `SaveChangesWithConcurrencyGuard`
backstop closes the residual load→save TOCTOU (`DbUpdateConcurrencyException` → 412).
- **Unconditional bump** (not "only when a child changed"): EF writes the root row only when a scalar
differs, so a no-op PUT-back must still bump to fire the token and rotate every other client's ETag.
- **Config-only aggregate boundary**: every mutating handler of a root's *editor-visible config state*
bumps `Version` (incl. bulk `ExecuteUpdate/Delete` writers via `.SetProperty`); regenerated build output
(playout items/history) is outside the token — neither bumped nor guarded.
- **Header-only ETag**, strong tag of the decimal `Version`; parsed/emitted by `ConcurrencyHeaders`. The
successful PUT returns the new ETag (else a same-tab second save 412s against its own write).
- **Phasing**: Phase 1 (this arc) = a missing `If-Match` force-writes (zero breakage) while the SPA starts
echoing; Phase 2 (a later PR) flips missing → **428** after every editor echoes and one release soaks.
`If-Match: *` stays the scripted force-write escape hatch.
- **Child stable-identity is OUT of #253** (the "moved fill-group item inherits the wrong slot's state"
concern on the positional reconcile) — root-anchored versioning is orthogonal to it; split to **#259**.
- **If-Match status semantics** (non-canonical/weak/list → 400) are fail-safe; the stricter RFC 7232
"valid-but-non-matching → 412" refinement is deferred to #197 (**#265**).
## 2026-07-11 — #253 PR3: Diff + Scalar concurrency fan-out (Collection / Playout×2 / MultiCollection / RerunCollection)
`key: concurrency.diff-scalar-fanout` · `status: active` · `since: 2026-07-11` · `supersedes: none` · `superseded-by: none`
**Rule:** The frozen Block optimistic-concurrency recipe (api-conventions §7a) fans out to Collection/Playout×2/MultiCollection/RerunCollection, keeping a guard-returned `PreconditionFailedError` out of any handler's generic `catch(Exception)`→422 mapping, and preserving each aggregate's existing `SaveChangesAsync() > 0` gate semantics under the new unconditional `Version++`.
**Signals:** concurrency fan-out, PreconditionFailedError, SaveChangesWithConcurrencyGuard · paths: `api-conventions.md` §7a · issues: #253, #269, #232, #197
**Mechanics:** `RootWriterForceVersionTests`-adjacent handler tests; `api-conventions.md` §7a
**Context.** PR3 of the #253 optimistic-concurrency arc fans the frozen Block recipe (api-conventions §7a)
across the five Diff/Scalar aggregates. Three judgment calls beyond the mechanical copy:
**H1 — Playout `catch(Exception)`→422.** The two Playout replace handlers wrap `SaveChangesAsync` in a
`catch(Exception)` that maps any exception to a bare `BaseError` (→ 422). Rather than let the guard's
concurrency failure be reshaped into a 422, the guarded save (`SaveChangesWithConcurrencyGuard`) returns a
`PreconditionFailedError` **Left as a value** and the handler returns it before the post-commit block —
so it never reaches the catch. Proven by the pre-check-subtype tests (a `.Apply` flatten would fail
`ShouldBeOfType<PreconditionFailedError>`) plus a non-vacuous Playout racing-save test.
**M2 — the `SaveChangesAsync() > 0` gates.** RerunCollection and Collection-custom-order run their
playout-refresh **unconditionally** on a successful save (the unconditional `Version++` makes the old gate
always-true; the "nothing changed" branch is dead). MultiCollection is the exception: it saved the name
first specifically so a name-only change wouldn't rebuild playouts, so we bump `Version` on that **first**
save and leave the **second** (items) save's `> 0` gate intact — a name-only edit still bumps + rotates the
ETag but does not rebuild. Enumerating every behavior the gate provided before reworking it (the #232 lesson).
**Sibling-writer scope (deferred).** §7a's config-only boundary says every writer of an aggregate's
editor-visible config bumps `Version`. PR3 ships the five primary endpoints' full contract + the one
design-named bulk writer (`UpdateDefaultDecoHandler`, safe via `.SetProperty`). It **defers** the other
same-root non-bulk config writers (`UpdateCollectionHandler`, `RemoveItemsFromCollectionHandler`,
`UpdatePlayoutHandler`, the `ScheduleFile` handlers) and the repository-mediated `Add*ToCollection` family.
Rationale: the primary endpoints' own bump+guard fully cover the two-tab lost-update the issue targets;
the deferred writers only affect cross-editor ETag *rotation*, and adding an unconditional bump to a handler
that uses plain `SaveChangesAsync` (not the guard) converts a latent lost-update into a **new 500**
(`DbUpdateConcurrencyException`) — doing it safely needs a uniform guard+bump+412 pass of its own, better
done with the #197 contract work. Tracked as a follow-up issue.
**VMs.** `Playout.Version` surfaces via `PlayoutNameViewModel` (required arg); the three collection VMs
(`MediaCollectionViewModel`, `MultiCollectionViewModel`, `RerunCollectionViewModel`) carry `int Version = 0`
(defaulted — 0 for the selection-placeholder constructions, real value from the Mapper projection).
Header-only via ETag, never echoed in a response body (the Block precedent).
**Post-merge addendum (PR3 review, #269).** Activating the `Version` token means EF guards *every* root
UPDATE, so non-participating root-scalar writers that use plain `SaveChangesAsync` (playout settings /
schedule-file / on-demand-checkpoint, collection name) would 500 on a concurrent bump. The realistic
UPDATE writers were fixed in-PR with `ConcurrencyExtensions.SaveChangesForcingVersion` (Phase-1
force-write on conflict: adopt the stored token, retry, never revert the concurrent bump). The deferral
above is re-scoped to the DELETE handlers + repository `Add*` writers only (→ #269).
## 2026-07-11 — Stable child identity for schedule-item replace (#259, split from #252/#253)
`key: concurrency.schedule-item-child-identity` · `status: active` · `since: 2026-07-11` · `supersedes: none` · `superseded-by: none`
**Rule:** `PUT /api/schedules/{id}/items` reconciles by an optional round-tripped child `Id` (null/absent/0 ⇒ new item), never by array position, so fill-group/shuffle state follows the logical item across reorders; an unknown or duplicate id is rejected 422 (checked after the §7a `CheckVersion`, so 412 precedes 422).
**Signals:** stable child identity, schedule-item replace, id-based reconcile · paths: `api-conventions.md` §7c · issues: #259, #252, #253, #197
**Mechanics:** `docs/api-conventions.md` §7c
`PUT /api/schedules/{id}/items` now reconciles by an optional round-tripped child id, not by array
position, so an item's persisted fill-group/shuffle state (`PlayoutScheduleItemFillGroupIndex`, FK
`OnDelete(Cascade)`) follows the logical item across reorders/inserts instead of being inherited by
whatever previously held its new slot. Contract + rules in **api-conventions §7c**. Key decisions:
- **`ScheduleItemRequest.Id` (`int?`)**: null/absent/`0` ⇒ new item (controller normalizes `0`→null so the
handler is two-state). Any id present ⇒ id-based reconcile; a fully id-less payload keeps the verbatim
positional fallback (legacy; retires with the §7a Phase-2 `If-Match`→428 flip).
- **Unknown or duplicate id ⇒ 422, nothing persisted**; the guards live in the handler **after** §7a
`CheckVersion`, so **412 precedes 422** — a client that is both version-stale and id-stale gets the reload
signal, not a payload-bug signal. Rationale for reject-not-insert on an unknown id: under Phase-1
force-write a stale id is a live lost-update signal, so silently inserting-as-new would duplicate the item
and return a different id than the client sent (the exact class §7a exists to surface). This is also the
correct #197 posture — never honor an unrecognized identifier.
- **Scope = schedule items only.** Blocks/templates/deco-templates/playlists stay positional: their children
are stateless config rows (no FK'd state to misattribute; #3/#4 have no GET child id). Child ids are added
only where a child row anchors server-side state; the contract can be retrofitted per-endpoint later
(field stays optional) — so this is not #197 ossification pressure.
- **TPT subtype change at a matched id** stays delete+insert (EF can't retype in place); state resets and a
new id is returned, so the SPA must re-seed item state from the PUT response (a stale id on a second save
now 422s).
## 2026-07-12 (#269 — non-If-Match root writers force-write past a concurrent Version bump)
`key: concurrency.force-write-non-ifmatch` · `status: active` · `since: 2026-07-12` · `supersedes: none` · `superseded-by: none`
**Rule:** Any handler that leaves a versioned root `Modified` or `Deleted` but takes no `If-Match` (deletes, item add/remove bumpers, scalar-config writers) must save through `ConcurrencyExtensions.SaveChangesForcingVersion` — force-write past a concurrent `Version` bump rather than throw an unhandled `DbUpdateConcurrencyException` (500).
**Signals:** force-write, non-If-Match writers, DbUpdateConcurrencyException · paths: `ConcurrencyExtensions.SaveChangesForcingVersion` · issues: #269, #253, #302, #197
**Mechanics:** `RootWriterForceVersionTests`; `docs/api-conventions.md` §7a
**Routing the aggregate delete handlers + `UpdateProgramScheduleHandler` through `SaveChangesForcingVersion`.**
Once #253 made each replace-all root's `Version` an `IsConcurrencyToken`, EF started guarding *every*
UPDATE **and DELETE** of that row with `WHERE Version=@orig` — so any writer that is not part of the
If-Match contract but still saves via plain `SaveChangesAsync` throws an unhandled
`DbUpdateConcurrencyException`→**500** if a replace-all editor bumps the row in its narrow load→save window.
PR3 already force-wrote the exposed *UPDATE* siblings (Playout settings/`ScheduleFile`/checkpoint,
`UpdateCollectionHandler`); a completeness sweep for #269 found the gap was wider than reported —
**18 writers** in total, all on plain `SaveChangesAsync`. **The correct exposure filter is "any handler
that leaves a versioned root `Modified` or `Deleted`", NOT just `Version`-bumpers + deletes** — an early
sweep used the narrower filter and a review of PR #302 caught what it missed (`ErasePlayoutHistory` below):
- the **nine versioned-root delete handlers** (`DeletePlayout`/`DeleteCollection`/`DeleteMultiCollection`/
`DeleteRerunCollection`/`DeletePlaylist`/`DeleteBlock`/`DeleteTemplate`/`DeleteDecoTemplate`/
`DeleteProgramSchedule`) — a DELETE is now token-guarded too;
- `UpdateProgramScheduleHandler` (bumps `Version` then saved plainly — the ProgramSchedule case PR3 only
*suspected*);
- the **seven item add/remove bumpers** that PR2 wired to bump their root but left on plain save —
`AddProgramScheduleItem`/`DeleteProgramScheduleItem` and the five
`Add{Items,Movie,Show,Season,Episode}ToPlaylist` handlers;
- **`ErasePlayoutHistoryHandler`** — modifies Playout root **scalars** (`Seed`/`Anchor`/`OnDemandCheckpoint`)
**without** bumping `Version`, inside an explicit transaction with no try/catch → the one the bumper-only
filter missed; reachable via `POST /api/playouts/{id}/erase-items-and-history`.
All now save through `ConcurrencyExtensions.SaveChangesForcingVersion`.
**Two deliberate boundaries (documented, not gaps):** (1) the background build/time-shift Playout-scalar
writers (`BuildPlayoutHandler` via `PlayoutBuilder`'s `Anchor`/`Seed`; `PlayoutTimeShifter`'s
`OnDemandCheckpoint`) are token-guarded too but **intentionally left on plain save** — they never surface a
request-path 500 (`BuildPlayoutHandler` catches → a build-failure `BaseError`; `PlayoutTimeShifter` runs only
via the background worker), and force-writing would be *wrong*: a concurrent config edit that bumped
`Version` also enqueues a rebuild, so failing the in-flight build and letting the rebuild redo it with fresh
config is correct (force-writing would persist output built from stale config). (2)
Item-add force-write can leave a duplicate/gap `Index` (accepted Phase-1 effect): the handler computes the
new index from its stale child list, so if a concurrent replace-all grew the list the item lands at a
now-colliding index (no unique constraint on `PlaylistItem.Index`/`ProgramScheduleItem.Index`) — non-
corrupting, self-correcting on the next edit, still strictly better than the pre-#269 500; a
reload-and-recompute-on-conflict refinement is a candidate for #197. Decision:
**force-write, not 412** — these endpoints take no `If-Match` (an unconditional DELETE/settings-edit should
win over a concurrent editor), matching the Phase-1 force-write posture. A delete has no ETag to rotate, so
it needs only the force-write, not a `Version` bump. A genuine row-deletion race (two concurrent deletes)
still surfaces as a `DbUpdateConcurrencyException` — accepted (rare, non-corrupting, the resource is already
gone). **Still deferred to #197:** *cross-editor ETag rotation* for the non-bumping config siblings and the
scanner-shared `Add*ToCollection` family (they don't 500 — they insert children / `ExecuteDelete`, neither
of which is token-guarded — they just don't rotate an open editor's ETag). Non-vacuously tested by racing a
bump *through the handler* via a pre-tracked context (`RootWriterForceVersionTests`), plus an explicit
negative control proving the plain-save path throws.
## 2026-07-12 — Cross-editor ETag rotation completed for Collection/Playout config siblings (#269)
`key: concurrency.etag-rotation-completion` · `status: active` · `since: 2026-07-12` · `supersedes: none` · `superseded-by: none`
**Rule:** Every handler that mutates a versioned root's editor-visible config state must bump `Version` (rotating the ETag) with no per-aggregate carve-outs, short-circuiting on a genuine no-op before the bump so idempotent re-submits don't fire spurious rebuild fan-out; `SaveChangesForcingVersion` rebases the retry (stored + pending delta), never adopts the stored token verbatim.
**Signals:** ETag rotation, no-op idempotence, SaveChangesForcingVersion rebase · paths: `CollectionEtagRotationTests`, `PlayoutScheduleFileEtagRotationTests` · issues: #269, #253, #197, #308
**Mechanics:** `docs/api-conventions.md` §7a; `CollectionEtagRotationTests`, `PlayoutScheduleFileEtagRotationTests`
The #253 optimistic-concurrency contract (§7a) had a documented tail: the non-If-Match config-sibling
writers of a versioned root mutated editor-visible state **without** bumping `Version`, so editing through
them did not rotate an open editor's ETag (a cross-editor invalidation gap — never a lost-update or a 500,
which the primary endpoints' bump+guard already cover). #269's first slice (PR #302) removed the 500 exposure
by routing those writers through `SaveChangesForcingVersion`; this slice completes the **rotation**.
Handlers now bumping `Version` (all via `SaveChangesForcingVersion`, since they take no `If-Match` → a
concurrent replace-all bump force-writes, never 412/500): the Collection `Add*ToCollection` family (11
handlers) and `RemoveItemsFromCollectionHandler` bump `Collection.Version`; `UpdateCollectionHandler`
(name/flag), `UpdatePlayoutHandler` (`DailyRebuildTime`), and the three `ScheduleFile` writers
(`UpdateSequential`/`UpdateScripted`/`UpdateExternalJsonPlayout`) — which already force-wrote — now also bump.
Decisions frozen (ratified with Fable before implementation, feeding the #197 contract freeze):
- **Rotate on every editor-visible config change, no per-aggregate carve-outs.** §7a's config-only boundary
("every mutating handler of a root's editor-visible config bumps `Version`") already held for Playlist
`Add*`/schedule item writers; the Collection/Playout siblings were an inconsistency, not a judgment call. A
membership add rotating an open custom-order editor's ETag (→ 412 → reload) is correct: its list is genuinely
stale. Blast radius of the aggressive-but-safe rotation is a reload, never data loss.
- **No-op idempotence — the trap Fable caught.** These handlers gate their reindex/`BuildPlayout` fan-out on
`SaveChanges() > 0`. An *unconditional* bump makes that gate always-true, so an idempotent re-add / same-value
re-submit would fire spurious rebuilds across every playout using the aggregate. Fix: short-circuit a genuine
no-op **before** the bump — the Add handlers by an explicit membership check (which also fixes the latent
duplicate-`CollectionItem` insert on a *sequential* re-add; two *concurrent* same-item adds can still both
pass the check and the loser 500s on the composite-PK unique violation — `SaveChangesForcingVersion` catches
only `DbUpdateConcurrencyException`, not `DbUpdateException`. That race is narrow and pre-existing, deferred
to #308), the scalar writers (`UpdateCollection`, `UpdatePlayout`, the
three `ScheduleFile` writers) by `ChangeTracker.HasChanges()`. A no-op neither bumps nor rebuilds nor rotates
the ETag — which is itself correct (nothing changed).
- **The `Add*ToCollection` family is not repository-mediated.** #269's original framing ("repository-mediated,
shared with the scanner hot path") was wrong: `IMediaCollectionRepository` is read-only; each handler loads
the `Collection` into its own `dbContext` and writes directly. So the rotation bump is a pure API-layer
concern and the scanner's separate membership-write path is untouched — a background scan does **not** rotate
the editor ETag (correct: background indexing is not an editor action).
- **Force-write rebases the bump, never adopts the stored token verbatim (Codex review of this PR).**
`SaveChangesForcingVersion` originally resolved a conflict by setting current=original=stored — which
silently *discarded* a sibling's pending `Version++` when a versioned writer committed in its load→save
window (sibling loads 1, bumps to pending 2, concurrent PUT commits 2 → retry wrote 2, so the concurrent
writer's ETag "2" stayed valid and the rotation was lost under exactly the race it exists for). Fixed in
this PR (it affects all 25 bumpers routed through the helper, including the pre-existing playlist/schedule
ones): the retry now rebases — original = stored, current = stored + (pending current − pending original) —
so a bumper lands at stored+1 and a non-bumper (delta 0, e.g. `ErasePlayoutHistory`) adopts stored unchanged.
The race tests assert the post-race Version (3, not 2) and fail against the verbatim-adopt implementation.
- **No new status codes.** These endpoints take no `If-Match` and force-write, so they never 412; no
`[ProducesResponseType(...412...)]` and no OpenAPI regen (response types unchanged). Only §7a prose changes.
Tests: `CollectionEtagRotationTests` (rotation + no-op-without-bump-or-rebuild + force-write-past-concurrent-bump
for Add/Remove/Update) and `PlayoutScheduleFileEtagRotationTests` (ScheduleFile rotation + no-op-without-refresh),
the no-op guard proven non-vacuous by inverting the membership check. The `#265` RFC-7232 If-Match parser
refinement (valid-but-non-matching/weak/list → 412 not 400) is a **separate** PR (disjoint surface: the shared
parser + `CheckVersion`, not the handler saves). Refs #253 #269 #197 · `api-conventions.md` §7a.
## 2026-07-12 — If-Match evaluates per RFC 7232: valid-but-non-matching → 412, only grammar violations → 400 (#265)
`key: concurrency.ifmatch-rfc7232` · `status: active` · `since: 2026-07-12` · `supersedes: none` · `superseded-by: none`
**Rule:** `ConcurrencyHeaders.ParseIfMatch` is a real RFC 7232 entity-tag/list parser: a syntactically-valid tag that doesn't strong-match (weak/empty/non-canonical/out-of-range/list) returns 412, and only a genuine grammar violation returns 400.
**Signals:** RFC 7232, If-Match parsing, strong-tag matching · paths: `ConcurrencyHeaders`, `IfMatchCondition`, `VersionedAggregateExtensions.CheckVersion` · issues: #265, #253, #197
**Mechanics:** `docs/api-conventions.md` §7a
Closing the last #253 concurrency-contract piece. `ConcurrencyHeaders.ParseIfMatch` previously classified
**any** non-canonical/weak/list `If-Match` value as `Malformed → 400` (a deliberate fail-safe: reject rather
than risk a stale write, deferred from the reference-aggregate PR). That was RFC-incorrect. Per **RFC 7232
§3.1**, a syntactically-valid entity-tag that simply doesn't strong-match must return **412 Precondition
Failed**, and **400** is reserved for a genuine grammar violation.
**What changed.** The parser is now a real RFC 7232 entity-tag/list parser (`If-Match = "*" / 1#entity-tag`).
It **scans** the list (it does *not* `Split(',')` — a comma is a valid `etagc`, so it can appear inside a quoted
opaque-tag: `"3,5"` is ONE tag, and a comma separates members only outside the quotes), trims only RFC OWS
(SP/HTAB — not `string.Trim()`, which would strip NBSP and let `" * "` masquerade as the `*` force-write),
validates each member as `[ "W/" ] DQUOTE *etagc DQUOTE`, and collects the versions of the **strong** members
whose opaque text is the exact canonical decimal we emit. Outcomes:
- **weak** (`W/"3"`), **empty** (`""`), **non-canonical** (`"03"`, `"3.0"`, `"+3"`), **out-of-range**
(`"99999999999999999999"`) → valid tags that contribute no version → **412** (a `Version`-kind with an
*empty* candidate set is a guaranteed no-match).
- **list** (`"3", "5"`) → any strong member that matches proceeds; weak/non-canonical members drop out.
- genuine grammar violations (unquoted `3`, SP inside the tag `" 3 "`, unterminated `"3`, `garbage`, a
separator-only header) → **400**.
**Type reshape.** `IfMatchCondition.ExpectedVersion : Option<int>` → `ExpectedVersions : Option<Seq<int>>`
(`None` = force-write; `Some(set)` = strong-match against the set, empty ⇒ always 412), and
`VersionedAggregateExtensions.CheckVersion(Option<int>)` → `CheckVersion(Option<Seq<int>>)` = set membership.
This threads through all 10 replace/update commands + handlers + request mappers + 9 controllers uniformly; no
wire-contract change (400 and 412 were already declared on every PUT; the field is header-derived and internal,
so no OpenAPI/DTO change).
*Why now, not #197:* it is the shared parser all replace-all PUTs copy, and the 412-vs-404 ordering the issue
worried about was already correct (each handler loads/validates → 404 before `CheckVersion`). *Why safe:* the
first-party SPA only ever echoes the single canonical strong tag we emit, so no shipped client changes behavior;
the change only makes a hand-written/tooling `If-Match` get the RFC-correct status. Docs: `api-conventions.md`
§7a. Refs #265 #253 #197.
## 2026-07-18 — Concurrent same-item add is idempotent, not a 500: catch the unique-violation per provider (#308)
`key: concurrency.idempotent-concurrent-add` · `status: active` · `since: 2026-07-18` · `supersedes: none` · `superseded-by: none`
**Rule:** A concurrent duplicate `Add*ToCollection` that loses the race on the composite-key unique constraint is treated as an idempotent no-op (skip the reindex/rebuild fan-out), not a 500 — detected via a provider-specific `TvContext.IsUniqueConstraintViolation` delegate defaulting to "no".
**Signals:** idempotent add, unique-constraint violation, provider error classifier · paths: `TvContext.IsUniqueConstraintViolation`, `SqliteErrorClassifier`, `MySqlErrorClassifier` · issues: #308, #269, #253
**Mechanics:** `docs/api-conventions.md` §7a ("Idempotent insert under concurrency"); `ConcurrencyExtensions.TrySaveChangesForcingVersion`
**Decision.** The `Add*ToCollection` family's membership pre-check (#269) is not atomic with the insert, so two
*concurrent* adds of the same item both observe it absent and both stage the `CollectionItem` composite key; the
loser's `SaveChangesForcingVersion` threw a unique/PK-violation `DbUpdateException` (SQLite error 19 / MySQL 1062)
it did not catch → **500**. We now treat that loss as an **idempotent no-op**, not an error: the desired end state
(the item is a member) already holds because the racing winner inserted it, rotated the ETag, and fanned out the
rebuild.
**Mechanism.** A `bool`-returning sibling `ConcurrencyExtensions.TrySaveChangesForcingVersion` wraps
`SaveChangesForcingVersion` and catches *only* a classified unique/PK violation, returning `false`. The 10
single-item handlers return `Unit.Default` on `false` (skip the reindex/rebuild fan-out — the winner did it). The
bulk `AddItemsToCollection` handler cannot no-op — that would silently drop the non-colliding items when a batch
partially overlaps a concurrent add — so it **retries** on a fresh context against recomputed membership (bounded
loop; the common no-collision path runs once).
**Provider seam.** Detection is provider-specific but the Application layer must not reference the provider
packages, so it follows the existing `TvContext` static-provider-config idiom (`IsSqlite`, `LastInsertedRowId`): a
settable `TvContext.IsUniqueConstraintViolation` delegate, pointed at `SqliteErrorClassifier` (extended codes 1555
PK / 2067 UNIQUE) or `MySqlErrorClassifier` (`Number == 1062`) from `Startup.cs`, defaulting to a conservative
"no" so an unwired provider never silently swallows a save failure. Chosen over DI to avoid threading a new
service through 11 handlers, and because the provider discriminator already lives as a `TvContext` static.
**Scope boundary.** `Add*ToPlaylist` is deliberately **untouched**: `PlaylistItem` has its own identity PK and no
unique index on `(PlaylistId, MediaItemId)` — a playlist may legitimately contain the same item more than once, so
there is no constraint to violate.
**Tests.** A negative-control anchor proves the race genuinely throws a classified `DbUpdateException`; the fix's
end-to-end handler tests reproduce a *real cross-connection* race via a shared-cache SQLite harness + a
`SavingChanges` interceptor that inserts the conflicting row on another connection mid-save (the single-connection
in-memory fixture cannot). Every fix-dependent test was verified to fail with the catch disabled. Mechanics:
`api-conventions.md` §7a ("Idempotent insert under concurrency"). Refs #308 #269 #253.
- 2026-07-11 — #253 PR3: Diff + Scalar concurrency fan-out (Collection / Playout×2 / MultiCollection / RerunCollection) — [`concurrency.diff-scalar-fanout`](records/concurrency/diff-scalar-fanout.md)
- 2026-07-11 — Optimistic-concurrency contract for replace-all PUTs (#253 PR1: infra + Block reference) — [`concurrency.replace-all-contract`](records/concurrency/replace-all-contract.md)
- 2026-07-11 — Stable child identity for schedule-item replace (#259, split from #252/#253) — [`concurrency.schedule-item-child-identity`](records/concurrency/schedule-item-child-identity.md)
- 2026-07-12 (#269 — non-If-Match root writers force-write past a concurrent Version bump) — [`concurrency.force-write-non-ifmatch`](records/concurrency/force-write-non-ifmatch.md)
- 2026-07-12 — Cross-editor ETag rotation completed for Collection/Playout config siblings (#269) — [`concurrency.etag-rotation-completion`](records/concurrency/etag-rotation-completion.md)
- 2026-07-12 — If-Match evaluates per RFC 7232: valid-but-non-matching → 412, only grammar violations → 400 (#265) — [`concurrency.ifmatch-rfc7232`](records/concurrency/ifmatch-rfc7232.md)
- 2026-07-18 — Concurrent same-item add is idempotent, not a 500: catch the unique-violation per provider (#308) — [`concurrency.idempotent-concurrent-add`](records/concurrency/idempotent-concurrent-add.md)
@@ -0,0 +1,23 @@
---
key: api.artwork-rooted-urls
title: '2026-07-07 — API artwork contract: rooted URLs produced server-side'
status: active
since: '2026-07-07'
supersedes: none
superseded-by: none
rule: API response DTOs return artwork as rooted, directly-usable URLs (plus passthrough for absolute/proxy URLs), never relative Blazor-convention paths.
signals: 'no SPA `<base href>`, `ApiArtwork` helper · paths: `ErsatzTV.Core/Api/ApiArtwork.cs`, `ErsatzTV.Application/LibraryBrowse/Queries/GetLibraryBrowseItemsHandler.cs` · issues: none cited (PR #181, PR #183)'
mechanics: '`ErsatzTV.Core/Api/ApiArtwork.cs`'
---
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 `<img src>`... 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 `<base href>`, 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`.
@@ -0,0 +1,64 @@
---
key: api.async-op-contract
title: 2026-07-11 — Async-op API contract normalization + playout build observability + F9 scan endpoints (#235)
status: active
since: '2026-07-11'
supersedes: none
superseded-by: none
rule: Queue-triggering `/api/*` endpoints normalize onto one contract — 202 Accepted (queued), 404 (missing entity), 409 (lock held), 422 (domain precondition) — with Trakt as the reference implementation; playout list/detail GETs also carry an `isLocked` observability flag as the HTTP-observable substitute for a live push channel.
signals: '`QueueShowScanResult`, `ResetAllPlayoutsResponseModel`, `MaintenanceController.EmptyTrash`/`CleanArtwork` · paths: `LibrariesController.ScanShow`, `ChannelController.ResetPlayout`, `PlayoutController.ResetAll` · issues: #235, adversarial-reviewer#20 F7/F8/F9, #232, #215'
mechanics: '`docs/api-conventions.md` §3a/§3b'
---
Reviewer#20 F7/F8/F9. Normalizes the queue-triggering `/api/*` endpoints onto one contract, closes the two
F9 `Libraries.razor` parity gaps, and hardens the Trakt batch-lock lifecycle. Much of the F8 surface was
**already normalized** by #232 (library scan → `QueueLibraryScanResult` 202/404/409/422) and #215 (per-id
playout mutations + reset → 409 lock guard) — this issue finished the remaining outliers.
**Normalized async-op contract** (queue-triggering endpoints): **202 Accepted** = work queued; **404
ProblemDetails** = entity missing (controller pre-check); **409 ProblemDetails** = lock held (the running
job, or a mutation racing it — §3a/§3b); **422 ProblemDetails** = domain precondition (sync disabled /
unsupported / start failed). Trakt was the reference implementation. Changes made:
- `MaintenanceController.EmptyTrash` — error path **500 text/plain → 404/422 ProblemDetails** (`ToErrorResult`).
- `MaintenanceController.CleanArtwork` — silent **200 → 202** (fire-and-forget enqueue). No SPA consumer.
- `LibrariesController.ScanShow` — conflated **400 `{error}` → 202/404/409/422** via a new
`QueueShowScanResult` enum (6 outcomes incl. an honest `ScanFailed`→422, distinct from `Unsupported`).
- `ChannelController.ResetPlayout` — **200 → 202** (queue-triggering; 404/409 unchanged).
- `PlayoutController.ResetAll` — **202 (no body) → 202 + `ResetAllPlayoutsResponseModel`** reporting
`queuedPlayoutIds` / `skippedLocked` / `skippedUnsupported` (replaces the silent skip; still 202, still
skips locked/ExternalJson by design per §3a — now it *reports* what it skipped).
- `TroubleshootController.TroubleshootPlayback` — bare body-less `NotFound()` → **404/422 ProblemDetails**
with distinguishing detail. **Status codes the SPA HLS player depends on were preserved** — verified
`HlsPlayer.tsx` never branches on this endpoint's status (playback state comes from the separate
`/api/troubleshoot/playback/status` poll); only the error *body* was enriched.
**Playout build observability**: the list endpoint (`GET /api/playouts`) already stamped `isLocked` +
`BuildStatus` on `PlayoutListItemResponseModel` (#215); this issue adds **`isLocked` to the single-playout
`GET /api/playouts/{id}`** (`PlayoutResponseModel`), so the detail poll surface carries the §3a lock flag
too. No dedicated `GET /api/playouts/{id}/status` push channel was added — the flag on the existing GETs is
the HTTP-observable substitute for Blazor's live lock event, matching the `GET /api/trakt/status` precedent.
**F9 parity endpoints** (the `Libraries.razor` deletion gate — #202 did NOT close these):
- **Deep scan**: `POST /api/libraries/{id}/scan` gains `?deep=false`, threaded through
`QueueLibraryScanByLibraryId(LibraryId, DeepScan=false)` into `ForceSynchronize{Plex,Jellyfin,Emby}LibraryById(id, deep)`
(was hardcoded `false`). Non-breaking: existing callers omit it.
- **External-collections scan**: new `POST /api/media-sources/{plex|jellyfin|emby}/{id}/scan-collections?deep=false`
on the three #202 media-source controllers, dispatching `Synchronize{X}Collections(id, ForceScan:true, deep)`.
Each pre-checks source existence (404), acquires the per-source **collections** lock (`Lock{X}Collections()` —
the lock *is* the running scan, so a false = **409**), then enqueues and returns 202; the controller
compensating-unlocks in a `catch` if the enqueue throws (§3b), and `ScannerService` releases in its `finally`.
Thin SPA clients shipped (`scanLibrary(id, deep)`, `scanCollections`); **the SPA deep-scan / collections
buttons are the removal PR's remaining parity work** (parity doc §5).
**F7 Trakt batch-lock leak fix**: the global Trakt lock was released only when the *terminal* batch message
(`Unlock: true`) was processed; a `WorkerService` shutdown/cancellation before that message leaked the lock
permanently (subsequent Trakt ops 409 until restart — same class as #231/#233/#234). Fix: `WorkerService`
now releases the Trakt lock in a `finally` on read-loop exit if still held. Non-vacuous regression test proven
against an inverted-condition control.
**Accepted-by-design** (per the issue's decision-record ask): the worker's channels are **unbounded** and
there is **no shutdown drain** — messages still queued at process exit are dropped. This is acceptable because
the entity locks are **in-memory singletons that die with the process**, so a dropped message can't strand a
lock across restarts (the F7 `finally` covers the *within-process* shutdown-break leak, which is the only way
a lock outlives its batch while the process keeps running). Adding a bounded-channel backpressure / graceful
drain is out of scope and would not fix a correctness bug.
@@ -0,0 +1,21 @@
---
key: api.channel-health-object
title: 2026-07-23 — Channel health = a server-derived `health` object on the channel DTOs, built-timeline detection (#415)
status: active
since: '2026-07-23'
supersedes: api.channel-health-signal@2026-07-17
superseded-by: none
rule: '`ChannelResponseModel`/`ChannelDetailResponseModel` carry a server-derived `health` object (`ChannelHealthResponseModel { Status, Faults[], PlayoutCount, BrokenSourceItemCount }`) computed **read-time** from the built timeline (`Playout.BuildStatus` + upcoming `PlayoutItem → MediaItem.State`, `Finish >= now`), kind-agnostic across all 5 `PlayoutScheduleKind` values; `Status`/`Faults` are const-string classes (`ChannelHealthStatus`, `ChannelFault`), not C# enums, so the SPA hand-maintains the union (mirrors `ChannelPreviewAvailability`). This supersedes #72''s "raw fact only, no derived enum, empty-schedule/broken-source deliberately not computed" stance now that the auto-tune taxonomy churn (#383/#384) it was waiting on has landed (see `channel.origin-marker` sibling record, #414).'
signals: 'channel health, ChannelHealthResponseModel, ChannelHealthStatus, ChannelFault, Healthy/Problems/Unknown, NoPlayout/NeverBuilt/BuildFailed/EmptyUpcoming/BrokenSource, built-timeline detection, BuildStatus, PlayoutItem MediaItem.State FileNotFound Unavailable, assessable gate, on-demand suppresses absence signals, Problems rollup filter, willNeverPlay hasProblems · paths: `ErsatzTV.Core/Api/Channels/ChannelHealthResponseModel.cs`, `ChannelRepository.GetAll`, `Mapper.GetHealth`, `GetAllChannelsForApiHandler`, `web/src/screens/ChannelsScreen.tsx`, `api-conventions.md`, `domain-model.md`, `spa-conventions.md` · issues: #415, #72, #71, #383, #384, #414'
mechanics: '`docs/superpowers/specs/2026-07-23-channel-fault-detection-design.md` (full design); `api-conventions.md` (health object shape); `domain-model.md` (channel-health row); `spa-conventions.md` (Problems filter + badge convention)'
---
#415 was deferred from #72 scope item (b): "empty schedule" and "broken/missing source" faults were real but uncomputed, each explicitly ruled out in the superseded record for a stated reason. This record reverses both rulings now that the blocking condition — the #383/#384 auto-tune status taxonomy churning the DTO shape — has resolved (#414 landed the origin column as a sibling, non-health field).
**Built-timeline (kind-agnostic) detection, not per-kind config introspection.** Every fault falls out of what the scheduler has already materialized — `Playout.BuildStatus` (`{LastBuild, Success, Message}`) for never-built/build-failed, and `Playout.Items` (the built `PlayoutItem` timeline, each carrying `MediaItemId`/`MediaItem`) for empty-upcoming and broken-source. Because the timeline is the same shape for all five `PlayoutScheduleKind` values (Classic, Block, Sequential, Scripted, ExternalJson), coverage is *by construction* — the #71 "verify a shared primitive covers ALL variants" trap, which the superseded record's own `EmptyScheduleHealthCheck` (Classic-only) fell into, cannot bite here. Scripted, which has no schedule entity to introspect at all, needs no special case. `MediaItem.State` flips on scan (not build), which rules out a build-time snapshot — detection is necessarily read-time, costed as one bounded `GROUP BY PlayoutId` aggregate query (not an N+1) over upcoming `PlayoutItem`s.
**Five-fault taxonomy, rolled up to one `status`.** `NoPlayout` (0 playouts, the absorbed #72 fact), `NeverBuilt` (assessable playout never built), `BuildFailed` (last build `Success == false`), `EmptyUpcoming` (built OK, 0 upcoming items), `BrokenSource` (≥1 upcoming item pointing at a `FileNotFound`/`Unavailable` `MediaItem`). Rollup: `Problems` if any contributing playout has a fault, `Healthy` if any is assessable-and-clean with none, `Unknown` if nothing is assessable — never a false `Healthy` and never a false `Problems`.
**The assessable gate distinguishes absence signals from presence signals.** `NeverBuilt`/`EmptyUpcoming` are inferred from *missing* content and are suppressed for `PlayoutMode == OnDemand` (an idle on-demand playout legitimately has no fresh build and drains its timeline between tune-ins — without suppression this is a false-positive storm across every on-demand channel; a suppressed absence signal contributes `Unknown`, not a false `Problems`). `BuildFailed`/`BrokenSource` are proven by content that *is* there and is bad, so they stay live in every `PlayoutMode` — they only fire when the bad thing actually exists and so cannot false-positive on legitimate idleness.
**Server owns the rollup so SPA and MCP read one verdict.** `health` rides the same `list channels`/`get channel` response both clients already fetch — no second endpoint to correlate by id, and no client re-deriving policy from raw facts (the thing the superseded record explicitly avoided freezing before the taxonomy existed). `PlayoutCount` is retained unchanged on the DTO for backward compatibility (additive-only `/api/v1` freeze); the SPA's "Problems" filter (`web/src/screens/ChannelsScreen.tsx`, `hasProblems`, replacing the old single-fault `willNeverPlay`/"No playout" filter) and per-row badges read `health.status`/`health.faults` instead.
@@ -0,0 +1,37 @@
---
key: api.channel-preview-capability
title: 2026-07-21 — Browser channel preview is a server-declared per-channel capability (#60)
status: active
since: '2026-07-21'
supersedes: none
superseded-by: none
rule: Whether a channel can be previewed in the browser is declared by the server, not derived by the SPA, as an additive `Preview` field (`{Availability, ManifestUrl, UnavailableReason}`) on `ChannelResponseModel`.
signals: 'a Play button that does nothing; preview eligibility inferred from a display string; a green preview on a Transport Stream channel being read as validating its configured pipeline · paths: `ErsatzTV.Core/Api/Channels/ChannelPreviewResponseModel.cs`, `ErsatzTV.Application/Channels/Mapper.cs`, `web/src/screens/channels/ChannelPreviewPanel.tsx` · issues: #60, #552'
mechanics: '`Mapper.GetPreview(StreamingMode, channelNumber, isEnabled, playoutCount)` is pure and JWT-agnostic'
---
`ChannelPreviewAvailability` is one of `Available`, `ForcedHlsOnly`, or `Unavailable`, computed in one
place from the real `StreamingMode` enum plus the channel's enabled/playout state. The SPA renders and
acts on it and derives nothing — deriving it client-side would mean keying behavior off
`Mapper.GetStreamingMode`'s human-readable display label, where a copy tweak would silently break
playback.
`Unavailable` covers two causes, checked in this order (first match wins): the channel is disabled
(`IptvController` 404s a disabled channel, so preview must not even try), and the channel has zero
playouts (a manifest request against one blocks indefinitely). At first pass these two were keying
preview on `StreamingMode` alone, so a disabled or playout-less channel was declared `Available` and
then failed confusingly.
Only the two HLS modes are browser-playable; a browser cannot play the `video/mp2t` that the
Transport Stream modes serve over `/iptv/*`. Those are declared `ForcedHlsOnly`: preview is offered
only as an explicit opt-in that requests `/iptv/channel/{n}.m3u8?mode=segmenter`, and is always shown
with a caveat that the check does not exercise the channel's configured pipeline. Fatal HLS errors
are reported, never auto-recovered — a diagnostic surface must show the fault rather than retry past
it; a user-initiated Retry re-issues the manifest request via a real `playToken` because the manifest
GET starts a server-side session, so a byte-identical repeat URL would otherwise be a no-op.
Originally, a JWT-enabled deployment made preview `Unavailable` (reason `IPTV JWT authentication is
enabled`) because `/iptv/*` does not accept the SPA's `ctv-session` cookie and nothing minted a JWT
for the browser. #552 closed that: the SPA now mints a short-lived token and appends it as
`?access_token=`, so this projection no longer inspects JWT status at all. See
`security.iptv-browser-token`.
@@ -0,0 +1,17 @@
---
key: api.decode-by-id
title: 2026-07-07 — Decode-style endpoints take a row id and look up server-side
status: active
since: '2026-07-07'
supersedes: none
superseded-by: none
rule: Endpoints that decode/expand opaque stored state accept a database row id and resolve it server-side rather than round-tripping client-supplied serialized state.
signals: '`PlayoutHistoryDetailsResponseModel` · paths: `PlayoutController.GetHistoryDetails` · issues: none cited (PR #182)'
mechanics: '`GET /api/playouts/history/{id}`, `PlayoutController.GetHistoryDetails`'
---
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.
@@ -0,0 +1,19 @@
---
key: api.from-lineup-clear-to-none
title: 2026-07-21 — `from-lineup` advanced overrides express "clear to none" via a typed `clear` enum list (#135)
status: active
since: '2026-07-21'
supersedes: none
superseded-by: none
rule: '`POST /api/v1/channels/from-lineup` (and the Auto-Tune per-channel `advanced`, which reuses the same DTO) distinguishes *inherit* from *clear-to-none* with a typed `clear` enum list on `advanced`. A field left null/omitted still inherits the template value (unchanged for every existing client); naming a field in `clear` forces it to none on the new channel even when the template sets one. Sending both a set value and a clear for the same field is a validation error.'
signals: 'clear to none, inherit vs none, advanced override, watermark/filler clear, template-minus-one-setting · paths: `ErsatzTV.Core/Api/Channels/CreateChannelFromLineupClearField.cs`, `ErsatzTV.Application/Channels/Commands/CreateChannelFromLineupHandler.cs`, `ErsatzTV/Controllers/Api/Requests/CreateChannelFromLineupRequest.cs`, `web/src/builder/advancedOptions.tsx` · issues: #135, #89, #385, #386'
mechanics: '`CreateChannelFromLineupHandler.ResolveClearable`/`ValidateClear`; SPA `applyOverridesToRequest`/`collectClears`/`CLEAR` sentinel; api-conventions.md §2, spa-conventions.md'
---
**Why a `clear` list, not a sentinel or per-field flags.** The gap (found in #89 review) was that the handler resolved every advanced override with `advanced.X ?? template.X`, so a client sending `null` always *inherited*. That is correct for the common path but leaves "this channel should have NO watermark / pre-roll filler even though the template has one" inexpressible. The fix had to keep `omitted = inherit` byte-stable for existing clients (`/api/v1` is frozen-additive, #286), so it is a new optional field, not a reshaping of the existing ones. A `{set, value}` wrapper per field would have rewritten every field's wire type; a reserved `0` sentinel is magic and asymmetric between int ids and strings; parallel `clearX` bools add one field per clearable. A single **typed enum list** is additive, self-documenting, type-checked (an invalid value is a 400 at model binding), covers ids and strings with one mechanism, and extends by adding an enum member. The clearable set is the eight template-inheritable fields where "none" is meaningful: watermark, the four fillers, and the preferred audio/subtitle language + audio title.
**Set + clear of the same field is rejected, not silently resolved.** The SPA never produces that state (a select is inherit, a value, or None), so the check exists to keep hand-crafted / machine-client requests unambiguous rather than picking a winner. A null/empty set value alongside a clear is fine (redundant, not conflicting).
**The enum lives in `ErsatzTV.Core`, not the Application command, on purpose.** The OpenAPI string-enum pass (`Startup.UseStringEnumSchemas`) scans the Core assembly wholesale; an enum defined in `ErsatzTV.Application` renders as a bare `integer` in the spec while every sibling advanced-options enum (`PlaybackOrder`, `ChannelSubtitleMode`, …) is a string enum. Placing `CreateChannelFromLineupClearField` in `ErsatzTV.Core/Api/Channels/` makes the wire contract a string enum by construction, matching its siblings.
**SPA is id-fields-first; the API is complete ahead of the UI.** The Channel Builder + Auto-Tune DetailPanel re-add a real "None" option to the five id selects (watermark + fillers) — the pickers #89 had degraded to "Inherit"-only — routed through a `CLEAR` overrides sentinel folded into `advanced.clear` at request-build time (`applyOverridesToRequest`, so the sentinel never leaks as a field value). The three string clear-fields are covered by the backend enum for machine clients (MCP) but the SPA text inputs keep "empty = inherit"; adding a tri-state to those inputs is deferred, not blocked. This is the deliberate "REST API is a real audience" posture (`rest-api-purpose-mcp-and-new-ui`).
@@ -0,0 +1,52 @@
---
key: api.healthcheck-remediation-dto
title: 2026-07-17 — Health-check remediation is server-declared `{Kind, Target}` on an additive DTO; the SPA acts on it (#164)
status: active
since: '2026-07-17'
supersedes: none
superseded-by: none
rule: Health-check remediation is server-declared `{Kind, Target}` metadata on an additive DTO field; the SPA renders/acts on it, it doesn't derive labels itself.
signals: 'health checks, remediation, AppRoute/ExternalDoc · paths: `HealthCheckResponseModel`, `HealthCheckLink` · issues: #164, #286, #108'
mechanics: '`HealthCheckResponseModel.Remediation`; Application `Mapper.GetStatus`'
---
#164 asked to make the ~14 health checks *actionable* — the Dashboard health panel showed problems
with no way to investigate or fix them. Two structural decisions came out of it.
**Remediation is server-declared metadata, not SPA-derived.** Each check that has a fix knows where the
fix lives, so the *check* declares it. The domain `HealthCheckLink` grew from `(string Link)` to
`(string Target, HealthCheckLinkKind Kind)` with `Kind ∈ {ExternalDoc, AppRoute}` and two factories
(`HealthCheckLink.ExternalDoc(url)` / `HealthCheckLink.AppRoute("/app/...")`). Only the 4 checks that
built links and the API mapper touched `.Link`, so the widening was local. The SPA then *acts* on the
kind: `AppRoute` → client-side `navigateToPath(target)` button; `ExternalDoc` → new-tab anchor. The
human label is derived SPA-side from the route (a small lookup + prettified fallback) rather than sent
over the wire — keeping the DTO minimal.
**The DTO evolved additively (`/api/v1` is frozen-additive, #286).** `HealthCheckResponseModel` kept
its existing `Detail` and gained `Brief` (← the domain `BriefMessage` the old mapper silently dropped)
and `Remediation { Kind, Target }` (a nested `HealthCheckRemediationResponseModel`). The old flat
`string? Link` is **kept and still populated** (mirrors `Remediation.Target`) but documented deprecated —
we don't remove a frozen field, and existing consumers keep working. `Remediation.Kind` is a plain
string ("ExternalDoc"/"AppRoute") mapped in the Application `Mapper` exactly like `Status`
("pass"/"fail"/…), not a wire enum — matching the established pattern for that DTO.
**Three defects the audit surfaced, fixed here.** (1) The Application `Mapper.GetStatus` threw
`ArgumentOutOfRangeException` on `NotApplicable`; the handler filters `NotApplicable` before mapping so
it was latent, but the mapper is now **total** (defense-in-depth — a future caller that skips the filter
can't 500 the endpoint). `InternalsVisibleTo("ErsatzTV.Tests")` was added to the Application assembly
(mirroring Core's precedent) to unit-test that totality directly. (2) Two checks linked to **stale
Blazor routes** (`media/trash`, `search?query=…`) — repointed to the SPA `/app/trash` and
`/app/search?query=…` as `AppRoute`s. (3) A dead `Open Classic UI` → `/system/health` link lingered in
`SettingsScreen` (a #91b leftover that just 302'd to `/app`); removed (see `blazor-route-parity.md`
Section 4 correction).
**Actionable checks that had no link gained an `AppRoute`** (metadata → `/app/libraries`, empty
schedules → `/app/schedules`, HW-accel / VAAPI → `/app/ffmpeg-profiles`, FFmpeg reports → `/app/settings`).
Pure-noise / no-clean-action checks (UnifiedDocker, MacOsConfigFolder, FFmpegCapabilities, the Info-tier
nags) were left untouched — semantic-tier changes (e.g. adding a Pass path, demoting a nag) were
deliberately **not** bundled into a remediation-UX PR.
**Deferred (own issue): a TTL cache for `PerformHealthChecks`** (#108 — every `GET /api/v1/health`
re-runs all 14 checks, 4 shelling out to ffmpeg, and the existing summary cache is write-only dead
code). Orthogonal to the UX; filed separately so a SPA-polled health panel gets a cache before it
polls.
@@ -0,0 +1,40 @@
---
key: api.healthcheck-ttl-cache
title: 2026-07-19 — Health-check results are TTL-cached; `?refresh=true` forces a fresh run (#431)
status: active
since: '2026-07-19'
supersedes: none
superseded-by: none
rule: Health-check results are held in a 30s TTL cache inside `HealthCheckService`; a non-forced `GET /api/v1/health` returns the cached list, and `?refresh=true` (or a forced internal caller) bypasses it to run fresh.
signals: 'health check caching, TTL, refresh query param · paths: `HealthCheckService._memoryCache`, api-conventions.md §1/§3b · issues: #431, #164'
mechanics: '`PerformHealthChecks(forceRefresh, ...)`; `GET /api/v1/health?refresh=true`'
---
`HealthCheckService.PerformHealthChecks` re-ran all 14 checks on **every** call, four of which shell out to
`ffmpeg`/`ffprobe` via CliWrap — so a bare `GET /api/v1/health` spawned ~4 subprocesses per request. The
existing `HealthCheckSummary` cache was **write-only** (populated + published, never read back to short-circuit
a re-run). Harmless while the SPA Dashboard health panel refreshes on-demand only, but a real cost the moment
anything *polls* health (a status widget, an MCP client, monitoring). Split out of #164 as the orthogonal
performance half.
- **A short TTL cache of the full result list lives inside `HealthCheckService`.** A `_memoryCache` entry
(`"healthcheck.results"`, `TimeSpan.FromSeconds(30)`) holds the last `List<HealthCheckResult>`; a non-forced
call returns it directly on a hit, skipping both the 14 checks and the summary `Publish`. Chosen over
"make the existing summary cache read-through" because the API returns the full per-check list, not the
2-int summary — the summary entry (`"healthcheck.summary"`, read by `GetHealthCheckSummary`) is kept as-is
(un-expiring) so its fallback behavior is unchanged.
- **`PerformHealthChecks` gained a `bool forceRefresh` first parameter** (interface signature change; one
implementer, 3 live callers). `forceRefresh: true` bypasses the cache and repopulates it.
- **The refresh surface is an optional `?refresh=` query param on the existing GET**, following the
`?deep=` bool-query-param exemplar (`api-conventions.md` §1/§3b) — additive, backward-compatible, no new
endpoint. `[FromQuery] bool refresh` → `GetAllHealthCheckResultsForApi(Refresh)` → `PerformHealthChecks(request.Refresh, …)`.
The SPA "Refresh health" button calls `/api/v1/health?refresh=true`; the initial/poll load calls the bare
path (cached). A separate `POST …/refresh` endpoint was rejected as unnecessary surface for a read.
- **Who forces vs. who reads the cache:** the API GET poll path reads the cache; the **startup**
`RunHealthChecksService` and the **troubleshooting** support bundle force a fresh run (both want current
state — startup is a cold cache anyway, and a diagnostic bundle should reflect *now*, not a ≤30s-old poll).
The legacy `GetAllHealthCheckResults` handler is dead (no senders) and reads the cache.
- **Thundering-herd on a cold cache was left out of scope** (no request-coalescing lock): polling is sequential
per client and the TTL collapses steady-state load, so at most a handful of exactly-simultaneous cold callers
re-run — a once-per-30s edge, not the repeated per-request cost the issue targets. Recorded here so a later
reviewer doesn't read the absence of a `SemaphoreSlim` as an oversight.
@@ -0,0 +1,21 @@
---
key: api.logs-sort-params
title: '2026-07-11 — Logs column sorting: allow-listed `sortField`/`sortDirection` on `GET /api/logs`'
status: active
since: '2026-07-11'
supersedes: none
superseded-by: none
rule: '`GET /api/logs` takes allow-listed `sortField` (`timestamp`|`level`) and `sortDirection` (`asc`|`desc`) query params, normalized (not rejected) on an unrecognized value.'
signals: '`MudTableSortLabel` parity, clamp-not-422 normalization · paths: `LogsController.GetLogs`, `LogsScreen.tsx` · issues: none cited'
mechanics: '`LogsController.GetLogs`'
---
Parity for `Logs.razor`'s `MudTableSortLabel` columns (Timestamp, Level — Message was never
sortable in Blazor either). `LogsController.GetLogs` adds `sortField` (`timestamp` | `level`,
default `timestamp`) and `sortDirection` (`asc` | `desc`, default `desc`) query params, normalized
server-side the same way `pageNum`/`pageSize` are clamped rather than rejected with a 422: an
unrecognized `sortField` silently falls back to `timestamp`, an unrecognized `sortDirection` falls
back to `desc` — the pre-existing default behavior (newest-first) is unreachable to break via a bad
query string. `LogsScreen.tsx` renders the two sortable headers as buttons with a chevron
indicating the active field/direction; clicking the active column toggles direction, clicking the
other column switches to it ascending.
@@ -0,0 +1,21 @@
---
key: api.mediatr-passthrough
title: 2026-06 — REST API wraps existing MediatR handlers 1:1, no service layer
status: active
since: 2026-06
supersedes: none
superseded-by: none
rule: The REST API is thin controllers over existing MediatR handlers, with no new service/business-logic layer.
signals: 'CQRS passthrough, `Either<BaseError, T>` mapping, handler-level fixes not controller papering · paths: `docs/rest-api.md`, `docs/api-conventions.md` §3 · issues: #2, #172'
mechanics: '`docs/api-conventions.md` §3'
---
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<BaseError, T>`, 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.
@@ -0,0 +1,25 @@
---
key: api.openapi-mirrors-runtime
title: 2026-07-09 — OpenAPI spec mirrors the runtime Newtonsoft serializer (#198)
status: active
since: '2026-07-09'
supersedes: none
superseded-by: none
rule: The generated OpenAPI spec is made to match the runtime Newtonsoft wire contract (via `NewtonsoftSchemaNamingTransformer`), not the reverse.
signals: '`CustomContractResolver`, `CustomNamingStrategy`, System.Text.Json drift · paths: `OpenApiSerializerContractTests` · issues: #198'
mechanics: '`NewtonsoftSchemaNamingTransformer`, `OpenApiSerializerContractTests`'
---
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.
@@ -0,0 +1,17 @@
---
key: api.parentid-drillin
title: 2026-07-07 — Season/episode/music-video drill-in via `parentId`, not new child-listing endpoints
status: active
since: '2026-07-07'
supersedes: none
superseded-by: none
rule: Media drill-in (season/episode/artist/music-video) is served by an optional `parentId` query param on library-browse, not dedicated per-kind child-listing endpoints.
signals: 'library-picker season drill-in, media-detail browsing · paths: library-browse endpoint · issues: none cited (PRs #181/#183)'
mechanics: library-browse endpoint `parentId` param
---
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.
@@ -0,0 +1,46 @@
---
key: api.playout-build-lock-409
title: 2026-07-10 — Playout API mutations return 409 while the build lock is held (#215)
status: active
since: '2026-07-10'
supersedes: none
superseded-by: none
rule: Every id-keyed playout/channel mutation endpoint checks `IEntityLocker.IsPlayoutLocked(id)` and returns 409 Conflict while a build is in-flight, mirroring Blazor's disabled-buttons behavior; reset-all stays 202 and silently skips locked playouts.
signals: '`ApiResults.ConflictProblem`, `PlayoutListItemResponseModel.IsLocked`, advisory check-then-act · paths: `PlayoutController.cs`, `ChannelController.cs` · issues: #215, adversarial-reviewer#18'
mechanics: '`ApiResults.ConflictProblem`'
---
Blazor disabled per-playout Reset/Erase/Delete/Edit while a `BuildPlayout` was in flight
(`EntityLocker.IsPlayoutLocked`, `Playouts.razor` + per-kind editors); the REST API had no
equivalent, so a client could race an in-flight build with a destructive `ExecuteDelete` and leave
a half-built playout. Adversarial-reviewer#18 promoted this to a #91-phase-(b) removal gate: after
Blazor is deleted the invariant would vanish entirely.
Decision: enforce the invariant **server-side** on the API rather than re-implementing a live push
channel. `PlayoutController` and `ChannelController` inject `IEntityLocker`; every id-keyed mutation
— `PUT /api/playouts/{id}`, `.../deco`, `.../alternate-schedules`, `.../templates`,
`POST .../erase-items`, `.../erase-items-and-history`, `DELETE /api/playouts/{id}`, and
`POST /api/channels/{channelNumber}/playout/reset` — checks `IsPlayoutLocked(id)` first and returns
**409 Conflict** (`ApiResults.ConflictProblem`, new shared helper mirroring `NotFoundProblem`) while
the build lock is held. The PUTs are gated too (not just the destructive ops): the target invariant
is "no mutation during a build", matching Blazor's edit-disable.
- **The guard is advisory check-then-act, not mutual exclusion** — same posture as Blazor's disabled
buttons. A `BuildPlayout` already sitting in the worker queue can take the lock a few milliseconds
after the check passes, so the original race is *narrowed*, not eliminated; consequences remain
self-healing (the next rebuild corrects a half-mutated playout). True prevention — having each
mutation acquire the playout lock for its duration — was deliberately not taken: `LockPlayout`
publishes `PlayoutUpdatedNotification` (UI churn per mutation) and would make mutations block
builds, a semantics change out of scope for restoring Blazor parity.
- **`reset-all` is deliberately NOT gated** — it stays 202. `ResetAllPlayoutsHandler` already
*silently skips* locked playouts, which matches Blazor and the handler semantics; a fire-and-forget
bulk enqueue always accepts.
- **SPA mirrors the lock via data, not a push channel** — `PlayoutListItemResponseModel` gains an
`IsLocked` bool (set from `IsPlayoutLocked` in the controller's list projection). The playouts
screen disables Reset/Erase/Erase-and-history/Delete for a locked row and shows a "Building…"
Badge; on a 409 from any mutation it surfaces the error and calls `query.refresh()` so the row
picks up the flag. No new polling was added (the existing 30s channel-state poll is unchanged).
Precedent for the 409 shape: `TraktController` (left as-is with its own private `ConflictProblem()`
to keep the diff small). Convention recorded in `api-conventions.md` §3a.
@@ -0,0 +1,50 @@
---
key: api.postcommit-cancellation-none
title: '2026-07-11 — Post-commit side effects run on `CancellationToken.None` (generalized from #251 to #254)'
status: active
since: '2026-07-11'
supersedes: none
superseded-by: none
rule: Once a mutation commits, its entire compensating side effect (enqueues, publishes, reindexes, cache refresh, and any post-commit lookup gating one of those) runs on `CancellationToken.None` so a late client disconnect can't half-abort an already-committed change.
signals: '`WriteAsync` enqueue, `mediator.Publish`, search-index reindex · paths: `docs/api-conventions.md` §7b, `MediaCollections/`, `ProgramSchedules/`, `Playouts/`, `Channels/` handlers · issues: #251, #254, adversarial-reviewer#22'
mechanics: '`docs/api-conventions.md` §7b'
---
Audit #22 (adversarial-reviewer) found ~20 command handlers threading the request `cancellationToken`
into work that runs **after** `SaveChangesAsync` commits — the post-commit `WriteAsync` enqueue that
rebuilds/refreshes the affected entity, `mediator.Publish`, search-index reindex, cache refresh. A late
HTTP-client disconnect cancels that token, so the *already-committed* mutation throws on the way out
**and silently drops its side effect** (the playout rebuild is never queued → the persisted edit never
takes visible effect until a manual Reset). #251 fixed this for the deco handlers; #254 generalizes the
policy across the codebase.
**Decision.** Once a mutation has committed, the *entire* compensating side effect — enqueues, publishes,
reindexes, cache refreshes, and any post-commit lookup that **gates** one of those enqueues — runs on
`CancellationToken.None`. The commit is the point of no return; past it the side effect must not be
half-abortable. Full convention + the two boundaries in `docs/api-conventions.md` §7b.
**Scope of the #254 sweep (this PR).** Swept the single-`SaveChanges` handlers under `MediaCollections/`,
`ProgramSchedules/`, `Playouts/`, `Channels/` (20 handlers). Deliberately **excluded**:
- **`BuildPlayoutHandler`** — a background/worker handler; its token is the worker shutdown token, not a
client-disconnect token, so its downstream enqueues *correctly* honor cancellation.
- **`UpdateFFmpegSettingsHandler` + the two `Configuration/` settings handlers** — they commit via several
sequential `IConfigElementRepository.Upsert` calls with an interleaved enqueue; "when is it committed"
is a partial-commit-under-cancellation question broader than the clean single-`SaveChanges` F4 pattern.
Left for a separate follow-up.
- **Response-projection reloads** (`ReplaceProgramScheduleItemsHandler` / `AddProgramScheduleItemHandler`
post-commit graph reload that builds the *returned* view model) keep the request token — a cancelled
response after a durable commit + `None`-enqueue loses nothing.
- Handlers a no-token `WriteAsync()` already makes behaviorally correct (`default` == `None`) were left
alone (explicit-`None` there is cosmetic).
Also folded in the two other #254 items on the same handlers: the channel-guide `{number}.xml` delete in
`DeleteChannelHandler`/`DeletePlayoutHandler` now routes through `IFileSystem.File.Delete` (observable
under `MockFileSystem`) **before** the commit (a post-commit delete orphans the xml on a crash; the guide
xml is regenerable on demand, so a pre-commit delete is the safe ordering), and
`ReplacePlayoutAlternateScheduleItemsHandler` now rejects an empty item list in the handler (not only at
the controller pre-guard) so a direct caller can't trip the `Max()`-on-empty crash.
**Coordination note for #253 PR2–PR4.** Those PRs add `Version++` (pre-commit) to the mutating handlers of
the versioned aggregates — several of which this sweep also touched (post-commit token, a different line
region). Low git-conflict risk, but merge `main` in and expect to see the `CancellationToken.None`
convention already present on the post-commit enqueues.
@@ -0,0 +1,20 @@
---
key: api.put-replace-index-order
title: 2026-07 — PUT-replace list endpoints derive `Index` from array order; alternate-schedules last row = catch-all default
status: active
since: 2026-07
supersedes: none
superseded-by: none
rule: PUT-replace-the-whole-list endpoints derive each item's `Index` from its request-array position, never a client-supplied field; alternate-schedule/playout-template rows are evaluated in `Index` order with the least-conditional row placed last as the catch-all default.
signals: '`ReplaceScheduleItemsRequest.ToCommand`, `IAlternateScheduleItem`, first-match-wins · paths: `AlternateScheduleSelector.cs` · issues: #179'
mechanics: 'PR #179, `AlternateScheduleSelector.cs`'
---
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`).
@@ -0,0 +1,18 @@
---
key: api.response-dtos
title: 2026-07 — Response DTOs live in `ErsatzTV.Core/Api`, file-scoped `#nullable enable`
status: active
since: 2026-07
supersedes: none
superseded-by: none
rule: New REST response DTOs live in `ErsatzTV.Core/Api/<Domain>/*ResponseModel.cs` with a file-scoped `#nullable enable` pragma; controllers never expose Application VM types directly.
signals: 'nullable-disabled Core project, ResponseModel mirroring ViewModel shape · paths: `ErsatzTV.Core/Api`, `docs/api-conventions.md` §2 · issues: none cited'
mechanics: '`docs/api-conventions.md` §2'
---
New REST response DTOs go in `ErsatzTV.Core/Api/<Domain>/*ResponseModel.cs` and mirror the shape of
the corresponding Application-layer ViewModel — controllers never expose VM types directly. Because
`ErsatzTV.Core.csproj` sets `<Nullable>disable</Nullable>` 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.
@@ -0,0 +1,29 @@
---
key: api.schedule-item-flat-dto
title: 2026-07-10 — Schedule-item GET returns a flat, non-polymorphic DTO (`ScheduleItemResponseModel`)
status: active
since: '2026-07-10'
supersedes: none
superseded-by: none
rule: Schedule-item GET/POST/PUT use a flat, non-polymorphic `ScheduleItemResponseModel` (every subtype field promoted to a nullable top-level member) instead of the polymorphic Application VM hierarchy, with mutation field names matching `ScheduleItemRequest` 1:1 for a lossless round-trip.
signals: '`ScheduleItemResponseMapper`, `ScheduleItemResponseRoundTripTests`, `EnforceProperties` normalization · paths: `ErsatzTV.Core/Api/Scheduling/` · issues: #126, #207, #212'
mechanics: '`ScheduleItemResponseRoundTripTests`'
---
`GET/POST/PUT /api/schedules/{id}/items` return `ScheduleItemResponseModel` /
`ScheduleItemsResponseModel` (`ErsatzTV.Core/Api/Scheduling/`), **not** the Application-layer
`ProgramScheduleItemViewModel` hierarchy (One/Flood/Multiple/Duration subtypes). The polymorphic VM
only described its base shape in OpenAPI, so the SPA couldn't see the subtype fields (issue #126).
The flat DTO promotes every subtype field to a nullable top-level member — `multipleMode`,
`multipleCount` (renamed from the VM's `Count`), `playoutDuration`, `tailMode`,
`discardToFillAttempts` — mapped by pattern-matching the concrete VM in
`ScheduleItemResponseMapper` (`ErsatzTV.Application/ProgramSchedules/`). Its **mutation fields are
named 1:1 with `ScheduleItemRequest`** so a GET maps losslessly back to a PUT/POST
(`ScheduleItemResponseRoundTripTests` is the release gate proving the fixed point). It also carries
picker-hydration fields the editor needs: `collectionName`/`smartCollectionName`/…/`playlistName`,
`playlistGroupId` (to preselect the playlist's group), per-filler names, `watermarks` /
`graphicsElements` as `NamedIdResponseModel` lists, the computed `name`, and `durationEstimate`.
`GetProgramScheduleItemsHandler.EnforceProperties` still rewrites StartType→Dynamic, Flood→One and
Playlist/Rerun→PlaybackOrder None when `ShuffleScheduleItems` is on — that lossy normalization is
deliberate and lives on the read side (documented + tested). New shared `NamedIdResponseModel`
(`ErsatzTV.Core/Api/`) is the generic `{id, name}` embed for API responses. Issues #126/#207/#212.
@@ -0,0 +1,43 @@
---
key: api.scheduling-hardening
title: '2026-07-13 — Scheduling API hardening: null-name 500s, duplicate template items, unreachable 404 (#172)'
status: active
since: '2026-07-13'
supersedes: none
superseded-by: none
rule: Create/Replace handlers guard against null/whitespace `name` (`IsNullOrWhiteSpace`, not just `Length`) to prevent NRE-500s, template-item overlap validation compares by index (not record value-equality) to catch exact-duplicate items, and unreachable 404 `ProducesResponseType` attributes on create-only actions are trimmed.
signals: '`BlockTemplateItem` record value-equality bypass, `api-conventions.md` §3b handler-hardening checklist · paths: `ReplaceTemplateItemsHandler`, `api-conventions.md` §3b · issues: #172, #144'
mechanics: '`docs/api-conventions.md` §3b'
---
Cleared the still-live findings from issue #172 (consolidated non-blocking nits from the #144 S1/S2
reviews). Most of the 2026-07-07 list had already been ratified deliberate (§8 "(none)" synthesized
rows; §3b deep-FK non-existence-check) or fixed since (the unauthenticated `/api/logs` +
`/api/troubleshoot/info` GETs now carry `[RequiresAuthentication]` per §9; the Trakt matched-items link
points at the live `/app/search`; `GET /api/search` already fans out via `Task.WhenAll`). Three were
genuinely live:
- **Null/empty `name` → 500 (10 handlers).** Create + Replace/Update handlers for Block, Template,
DecoTemplate, Deco (8, all genuine 500s), plus `UpdateFFmpegProfile` (genuine 500; `CreateFFmpegProfile`
was already guarded) and `CreatePlaylist` (its DTO coalesces `null`→`""`, so an empty-name persist, not a
500) all did `if (request.Name.Length > 50)` on a client-nullable `string Name` → unhandled
`NullReferenceException`. Fixed to `if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length >
50)` — kills the NRE, and also rejects empty/whitespace names (matching the group-create handlers'
`NotEmpty` behavior, closing a latent "block/template named ''" gap). Chose the one-line guard over
refactoring each handler onto the `NotEmpty`/`NotLongerThan` combinator to keep the blast radius tiny and
preserve each handler's existing error message + 422 mapping. Convention captured in `api-conventions.md`
§3b handler-hardening checklist.
- **Exact-duplicate template items bypassed overlap validation.** `ReplaceTemplateItemsHandler`'s O(n²)
overlap loop skipped on `item == otherItem`, but `BlockTemplateItem` is a `record`, so two value-identical
items (same BlockId + StartTime → same computed EndTime) were value-equal and skipped — both persisted
unvalidated. Switched to index-based iteration (`i != j`) so identical items at distinct positions are
compared and register as a (self-)intersection → rejected 422. (The SPA's index-based check already caught
this client-side; it was an API-only gap.)
- **Unreachable 404 on create-group actions.** `POST /api/blocks/groups` and `POST /api/templates/groups`
declared `[ProducesResponseType(ProblemDetails, 404)]` copied from precedent, but a create has no parent
lookup that can 404 (only 201/422). Trimmed — OpenAPI spec regenerated.
Deliberately **not** fixed (documented as accepted): the §8 "(none)" synthetic rows, the §3b deep-FK
non-existence-check, and the missing `Name=` on `PlayoutController` Create/Delete/Update (moot — the
"v1"-doc `OperationIdOpenApiTransformer` (#197 Bundle C) already synthesizes stable operationIds for
`Name=`-less ops, and adding `Name=` would risk renaming generated SPA client methods). Refs #172 #197.
@@ -0,0 +1,51 @@
---
key: api.search-allitems-paging
title: 2026-07-18 — Search all-items is paged to cap DoS exposure; SPA add-all pages to completeness (#293)
status: active
since: '2026-07-18'
supersedes: none
superseded-by: none
rule: '`GET /api/v1/search/all-items` is paginated (capped page size, `Totals` field) to bound DoS exposure; the SPA add-all flow pages to completeness instead of relying on an unbounded response.'
signals: 'search all-items, pagination, DoS hardening · paths: `SearchController.SearchAllItems`, `LuceneSearchIndex`, `web/src/api/search.ts` · issues: #293, #285, #308, #384'
mechanics: '`MaxAllItemsPageSize`/`DefaultAllItemsPageSize` clamps; `getAllSearchItemIds`'
---
`GET /api/v1/search/all-items` (`SearchController.SearchAllItems` → `QuerySearchIndexAllItemsHandler`) fired
ten index searches with **`limit: 0`** (= "return every hit", `LuceneSearchIndex` line ~244), so a single
broad query (e.g. one matching the whole library) materialized *every* matching doc across all ten media
kinds into ten `List<int>` buckets and serialized them in one response — unbounded work per request. #285
closed the original *unauthenticated* exposure (the endpoint is now behind `Api:RequireKeyForReads`, default
true); the residual was DoS-hardening against an **authenticated** caller with a very broad query. Deferred
from #285 because the SPA "add all to collection/playlist" flow materializes the full id set before the add
POST, so a naive hard cap would silently truncate "add all".
**Decision (issue option (a), operator-confirmed): paginate the endpoint and teach the SPA add-all flow to
page to completeness** — rather than option (b) (a generous cap + truncation signal). Chosen because
"add all" must stay complete for real use, and it matches the sibling `GET /api/v1/search` /
`GET /api/v1/channels/auto-tune/members` (#384) paging convention already in the codebase.
- **Endpoint (additive).** `SearchAllItems` gains optional `pageNum` (0-based) + `pageSize`, clamped exactly
like the §1 Logs / sibling `Search` precedent: `pageSize = Math.Clamp(pageSize, 1, MaxAllItemsPageSize)`
with `MaxAllItemsPageSize = 1000`, `DefaultAllItemsPageSize = 500`. `pageNum` is clamped
`Math.Clamp(pageNum, 0, MaxAllItemsPageNum)` with `MaxAllItemsPageNum = 2_000_000` — the upper bound keeps
`pageNum * pageSize` (the search skip) inside `int` range so an absurd page number can't overflow to a 500
(the sibling `Search` only floors at 0; the all-items endpoint hardens the upper bound too since this is a
DoS-hardening change). The clamp is applied per media kind (a page returns ≤ `pageSize` ids of
*each* of the ten kinds), so one response is bounded to ≤ 10 × `pageSize` ids. `QuerySearchIndexAllItems`
carries `PageNum`/`PageSize`; the handler passes `skip = PageNum × PageSize`, `limit = PageSize` into
`ISearchIndex.Search` (native skip/limit) and reads `SearchResult.TotalCount` (the true total, free) per
kind.
- **Response (additive, frozen-v1-safe).** The ten `…Ids` buckets are unchanged; a new non-null nested
`Totals` (`SearchResultAllItemsTotalsResponseModel`, ten `…Count` ints) is added so a client knows how many
ids exist per kind and can page to completeness. Nothing is removed or retyped (#286 additive-only holds).
- **Deliberate default-behavior change.** A caller that sends no `pageSize` now gets one page (default 500 /
kind) plus `Totals`, not the entire id set. This is the security change the issue asks for; it is safe here
because the only in-repo consumer is the SPA (updated in the same PR) and any external/MCP caller can read
`Totals` and page. Recorded as intentional, not a regression.
- **SPA pages to completeness.** `web/src/api/search.ts` `getSearchAllItems(query, pageNum, pageSize)` gains
the params; a new `getAllSearchItemIds(query)` loops pages (requesting `pageSize = 1000`, the server max),
accumulating every bucket until each kind has collected its `Totals` count (with an empty-page safety break
against total-count drift), and returns the merged `SearchAllItemIds`. `SearchScreen.addAll` calls it
instead of the single-shot fetch; the #221 stale-query guard and the single add POST are unchanged.
- **Out of scope (unchanged):** the add POST itself still accepts the full merged id set in one request body
— bounding *that* surface is a separate concern (see #308 for the add path); #293 is the GET.
@@ -0,0 +1,46 @@
---
key: api.search-field-values
title: 2026-07-23 — Facet-value typeahead is a new endpoint, allow-listed to text fields, no caching (#434)
status: active
since: '2026-07-23'
supersedes: none
superseded-by: none
rule: '`GET /api/v1/search/fields/{name}/values?q=&limit=` returns distinct WHOLE values from the database for one of a narrow allow-list of catalog fields (not the Lucene term dictionary — analyzed `TextField`s store lowercased word tokens, e.g. "Science Fiction" → `science`/`fiction`, useless as a typeahead suggestion), 404 for an unknown field, a non-`text` field, or a `text` field with no distinct-value source; case-insensitive prefix-filtered on `q`, `limit` clamped to `[1, 50]` (default 50).'
signals: 'facet-value typeahead, rule builder value combobox, distinct field values, GetSearchFieldValues, text field allow-list, DB-sourced distinct values, content_rating split · paths: `ErsatzTV/Controllers/Api/SearchController.cs`, `ErsatzTV.Application/Search/Queries/GetSearchFieldValues.cs`, `ErsatzTV.Application/Search/Queries/GetSearchFieldValuesHandler.cs`, `web/src/api/search.ts` · issues: #434, #176'
mechanics: '`SearchController.GetSearchFieldValues`; `GetSearchFieldValuesHandler`; api-conventions.md; spa-conventions.md §12'
---
Enum fields (e.g. `type`, `content_rating` group) already ship their allowed values inline on
`SearchFieldResponseModel` from the existing `GET /api/v1/search/fields` catalog (`spa.smartcollection-rule-builder`,
#176), so they need no endpoint — a client already has the full value set. **Text** fields (title, studio,
genre-as-free-text, etc.) don't: their values are whatever strings the library actually contains, so the
rule builder's value input for a text field needs a live lookup rather than a fixed list.
The handler allow-lists on `field.Type != "text"` (matching the same `SearchFieldCatalog.Fields` the
`/fields` endpoint serves) and returns `Option.None` → 404 for anything else, rather than silently returning
an empty list for a field that will never have values — a 404 tells a caller "wrong field kind," an empty
200 would look like "no matches yet."
**DB-sourced, not the search index.** The handler injects `IDbContextFactory<TvContext>` and resolves an
explicit per-field-name `IQueryable<string>` (or, for a few special cases, an in-memory list) rather than
querying `ISearchIndex`: `genre`/`show_genre` → `Set<Genre>()`, `studio` → `Set<Studio>()`, `director` →
`Set<Director>()`, `writer` → `Set<Writer>()`, `actor` → `Actors`, `artist` → `ArtistMetadata.Title` (entity
artists only — free-text music-video/song artist credits are a known, intentionally-uncovered gap), `tag` →
`Set<Tag>()` excluding `Tag.NfoCountryTypeId`/`Tag.PlexNetworkTypeId` (reapplying the indexer's own
exclusions so country/network strings don't leak in as tags), `network` → `Set<Tag>()` filtered to
`Tag.PlexNetworkTypeId`, `collection` → `Collections`, `video_codec` → `MediaStreams` filtered to
`MediaStreamKind.Video`, `album` → `MusicVideoMetadata.Album` concatenated with `SongMetadata.Album`. Every
DB-sourced field runs the same pipeline: `.Where(v => v.ToLower().StartsWith(qLower)).Distinct().OrderBy(v =>
v).Take(limit)`, translated to SQL by EF for both SQLite and MySQL. Two fields are computed in memory instead
of queried: `state` (the fixed 4-value `MediaItemState` enum) and `video_dynamic_range` (the literal
`["hdr", "sdr"]`). `content_rating` is special-cased: the DB stores an unsplit `"PG-13/TV-14"` string across
`MovieMetadata`/`ShowMetadata`/`OtherVideoMetadata`/`RemoteStreamMetadata`, so the handler pulls the distinct
raw strings then `Split('/')`s, trims, and dedupes in memory before the same prefix-filter/sort/take — this
matches what search actually matches on, rather than surfacing the compound string as one facet value.
**`title`, `show_title`, `album_artist` are explicitly NOT supported** (404, free-text fallback): `title`/
`show_title` are near-unique free-text fields spanning ~9 metadata tables where a distinct list of every
title isn't a useful facet; `album_artist` backs onto `SongMetadata.AlbumArtists`, a value-converted
`IList<string>` column EF can't translate into a server-side distinct query.
**Why a thin query, not a cache.** No result cache, no debounce on the server side (the SPA combobox
debounces the keystroke) — each per-field query is a bounded, indexed `Distinct`/`Take`; adding a cache
before there's a measured cost would be premature.
@@ -0,0 +1,17 @@
---
key: api.search-paging-cap
title: 2026-07-11 — Trash "See all" reuses library-browse paging; search stays capped per kind (#213)
status: active
since: '2026-07-11'
supersedes: none
superseded-by: none
rule: 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.
signals: '`GetLibraryBrowseItems`, `state:FileNotFound` query · paths: `GET /api/v1/search`, `GET /api/v1/library/browse` · issues: #213'
mechanics: '`GET /api/v1/library/browse`'
---
`GET /api/v1/search` still returns at most 100 items per media kind, which is the cheap first page for
the common case. For an overflowing kind, the SPA's "See all N …" action pages `GET
/api/v1/library/browse` with `query=state:FileNotFound`, `mediaType`, `pageNum`, and `pageSize=100`, then
appends the results client-side. This reuses the same `GetLibraryBrowseItems` query behind search,
adds no API surface, and only pays for follow-up requests when a kind exceeds the first-page cap.
@@ -0,0 +1,51 @@
---
key: api.versioning-v1
title: '2026-07-13 — API versioning: the whole `/api` surface is mounted at `/api/v1`, additive-only after freeze (#286)'
status: active
since: '2026-07-13'
supersedes: none
superseded-by: none
rule: 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`.
signals: '`ApiVersionRewriteMiddleware`, `ApiRouteVersioningTests`, RFC 8594 Deprecation header · paths: `docs/api-conventions.md` §1/§9 · issues: #286, #197'
mechanics: '`ApiRouteVersioningTests`, `docs/api-conventions.md` §1/§9'
---
The #197 cold review's C1 **BLOCKER**: `/api/*` was entirely unversioned (`info.version` was cosmetic), so the
first breaking change would silently break the SPA and any external/MCP client with no negotiation path. This is
the Phase-2 contract-freeze gate — versioning can't be added compatibly *after* the contract ossifies, so it
lands before freeze.
**What changed.** Every route under `/api` was swept to `/api/v1` — all 251 controller route attributes, the ~24
`Location`-header literals, the scanner callback URL (`CallLibraryScannerHandler.GetBaseUrl`), and the
`Startup` request-log path literal. This is **uniform**: the machine JSON API, the browser-session auth surface
(`/api/v1/auth/*`, still `IgnoreApi`), the internal loopback callbacks (`/api/v1/scan/*`) and the scripted-build
surface (`/api/v1/scripted/*`) are all versioned, so there is no unversioned corner and the compat rewrite needs
no exclusion list. The OpenAPI `v1.json` (160 paths), `endpoint-index.md`, and the SPA (945 request literals +
its test mocks, incl. regex/positional URL parsers) were regenerated/swept in lockstep. **No wire-DTO or
status-code change** — only the path prefix moved.
**Legacy compat = an in-pipeline rewrite, NOT a redirect** (`ApiVersionRewriteMiddleware`, sequenced before
`UseRouting` in the API branch). A legacy caller hitting an unversioned `/api/foo` has its request *path*
rewritten to `/api/v1/foo` and continues in-pipeline — method, body, auth headers and query string all survive,
so curl / the future MCP server / bookmarked URLs keep working with no round-trip (a 307/308 redirect would have
been fragile for non-GET + custom-header clients). Rewritten (legacy) responses carry RFC 8594 `Deprecation: true`
+ `Link: </docs>; rel="deprecation"`, and a `Sunset` header when `Api:LegacyRoutesSunset` is configured. An
already-versioned path (`/api/v1/*`) passes through untouched; a future `/api/v2/*` is **not** forced back to v1
(the middleware only fills in a *missing* version).
**Freeze semantics (owner decisions):** once shipped, `/api/v1` is **additive-only** — new endpoints/optional
fields are fine; renaming/removing/retyping an existing one requires a new `/api/v2`, never an in-place break.
The legacy-rewrite compat shim has a **2-release sunset window** (owner-chosen) before removal; the actual removal
is a tracked Phase-3 follow-up, not this PR. Existing pre-freeze warts (e.g. channel `{id}` vs `{channelNumber}`,
the synthesized negative-id "(none)" group rows) are frozen as-is per their own prior decisions.
**Route-convention standardization (#286, owner-requested).** The leading-slash inconsistency (238 absolute
`"/api/…"` method routes vs 13 relative `"api/…"`) is resolved: the standard is a **leading-slash absolute route
on each method's `[Http*]` attribute, no class-level `[Route]`** — except the two controllers where many actions
share a parametrized prefix (`ScannerController` `{scanId}`, `ScriptedScheduleController` `{buildId}`, ~40
methods), which keep a leading-slash absolute **class** `[Route("/api/v1/…")]` with relative method segments (the
right tool for a shared prefix). Enforced by `ApiRouteVersioningTests`: it reflects over every `[ApiController]`
in `Controllers.Api`, computes each action's *effective* route (ASP.NET's class+method combination rule), and
asserts it matches `^/api/v\d+/` — so a new controller that drifts (relative or unversioned) fails CI, the
"fix-it-while-you're-in-the-file" gate the `dotnet format` rules use. Browser-nav endpoints deliberately outside
`/api` (e.g. `GET /auth/oidc/login`) are out of scope for the test. Docs: `api-conventions.md` §1/§9. Refs #286 #197.
@@ -0,0 +1,30 @@
---
key: blazor.rollback-tag
title: 2026-07-11 — Pre-removal Blazor rollback tag `blazor-final` (#205)
status: active
since: '2026-07-11'
supersedes: none
superseded-by: none
rule: 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.
signals: '`git tag -a blazor-final`, `ersatztv:blazor-final` test-image restore path · paths: none · issues: #205'
mechanics: '`git tag blazor-final`'
---
Removal-gate item #205: the removal PR deletes both the Blazor reference implementation and the
`/system/health` escape hatch, so a post-deletion parity gap would otherwise be an archaeology exercise
(guessing which release tag still matches `main` minus Blazor). Decision + procedure, to run as the **first
action of the Step 2 deletion PR merge** (not before — `main` moves until then):
1. On the `main` commit **immediately preceding** the removal merge (the last commit that still contains
`ErsatzTV/Pages/**`), cut an annotated tag and push it:
`git tag -a blazor-final -m "Last commit with the legacy Blazor Server UI (pre-#91-phase-b removal)"`
then `git push origin blazor-final`. The tag name is **`blazor-final`** (not `v*`) so it does **not**
trigger the `v*` prod-release build in `.gitea/workflows/docker-build.yml`.
2. **Restore path** (if a gap surfaces post-removal): `git checkout blazor-final` → `docker build -f
docker/Dockerfile -t ersatztv:blazor-final .` → pin the **test** container to that image while the gap is
fixed forward on `main`. Alternatively `git revert` the single deletion merge commit (keep the deletion as
one squash/merge commit specifically to make this a one-liner).
3. Document the tag + restore path in the removal PR body; update this entry with the tag's commit sha when
cut.
Not cut this session — `main` still carries Blazor and will advance before the removal PR.
@@ -0,0 +1,54 @@
---
key: blazor.ui-removed
title: 2026-07-11 — Blazor Server UI removed (#91 phase b)
status: active
since: '2026-07-11'
supersedes: none
superseded-by: none
rule: 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`.
signals: '`blazor-final` rollback tag, #206 auth-posture sign-off, forbidden-prefix guard lifted for `/media/sources` · paths: `ErsatzTV/Pages/**`, `ErsatzTV/Shared/**`, `ErsatzTV/ViewModels/**`, `ErsatzTV/Validators/**` · issues: #91, #145, #151, #152, #153, #155, #202, #207, #212, #213, #235, #204, #206, #25'
mechanics: '`Startup.cs` MapFallback, `LegacyUiRedirects.cs`'
---
The #91 phase (b) removal PR deletes the legacy Blazor Server UI now that the ChicoryTV SPA has parity
(all gates cleared: #145, #151/#152/#153/#155, #202, #207, #212/#213, #235 F9). The SPA is the only UI.
**Deleted.** `ErsatzTV/Pages/**` (all `.razor`, incl. `_Host.cshtml`, `FragmentNavigationBase.cs`,
`MultiSelectBase.cs`), `ErsatzTV/Shared/**` (all `.razor` + `_Favicons.cshtml`), `ErsatzTV/ViewModels/**`
(39 Blazor edit-form VMs), `ErsatzTV/Validators/**` (10 Blazor edit-VM FluentValidation validators),
`App.razor`, `_Imports.razor`, `ErsatzTV/Locals/Shared/**` + `ErsatzTV/Locals/Pages/**` (Blazor
localization resx — `ErsatzTV/Locals/Resources.*` is KEPT), `ErsatzTV/wwwroot/css/**` (site.css),
`ErsatzTV/wwwroot/lib/**` (jquery, jqueryui, sortablejs, hls, media-chrome, roboto), `libman.json`, and
`ErsatzTV.Tests/Pages/MultiSelectBaseTests.cs`.
**9 packages pruned** (from both `Directory.Packages.props` and `ErsatzTV/ErsatzTV.csproj`; each verified
to have zero remaining consumers after the Blazor deletion): **MudBlazor**, **Heron.MudCalendar**,
**Blazored.FluentValidation**, **BlazorSortable** — unambiguous Blazor UI; **MediatR.Courier.DependencyInjection**
— `ICourier` was consumed only by the deleted pages, and the app's `mediator.Publish` notification path is
plain MediatR (unaffected by the `AddCourier` removal); **Markdig**, **HtmlSanitizer**, **Chronic.Core**,
**NaturalSort.Extension** — verified zero non-Blazor consumers post-deletion.
**Startup surgical reduction.** Removed `AddRazorPages` (+`AuthorizeFolder("/")`), `AddServerSideBlazor`,
`AddMudServices`, `AddSortable`, `AddCourier`, the Blazor-attached `UseAuthentication`/`UseAuthorization`,
`MapBlazorHub`, and `MapFallbackToPage("/_Host")`. The former "blazor" `MapWhen` branch (lambda param
renamed `blazor`→`legacy`) is KEPT — it still co-hosts `MapControllers()`, `/docs` (Scalar), dev
`MapOpenApi()`, and the `LegacyUiRedirects` middleware. `MapFallbackToPage("/_Host")` is REPLACED by a
catch-all `endpoints.MapFallback(...)` that 302-redirects any unmatched path to `PathBase + "/app"`
EXCEPT paths under `/api`, `/artwork`, `/docs`, `/openapi` (those get a genuine 404, per #204's design).
**KEPT** (not removed): the OIDC/JWT/API-key SERVICE registrations (inert unless configured; real auth is
#197), `ConditionalIptvAuthorizeFilter` (`/iptv/*`), and `ApiKeyAuthorizationFilter` (mutating `/api/*`) —
per the #206 auth-posture sign-off (deleting the Blazor page challenged nothing beyond phase (a)).
**LegacyUiRedirects.** Added redirects for all 14 `/media/sources/*` routes → their `/app/libraries/*`
SPA screens (7 Tier-1 exact + 7 Tier-2 `{id}` patterns; the last Section-2 rows in
`blazor-route-parity.md`), and LIFTED the #204-era `/media/sources` forbidden-prefix guard (its Blazor
pages were replaced by #202's SPA screens). The forbidden-prefix guard now covers only `/api`, `/artwork`,
`/docs`, `/openapi`, `/iptv`, `/app`.
Also removed the now-dead ersatztv#25 razor-Sonar `<NoWarn>S6966;S3267;…</NoWarn>` line from
`ErsatzTV.csproj` — those Sonar rules only needed suppression in `.razor` `@code`; on `.cs` they run at
`suggestion` via `.editorconfig`, so removal is safe (closes part of #25's burn-down).
**Rollback.** The tag `blazor-final` was cut on the pre-removal `main` commit as the first step (see the
2026-07-11 "Pre-removal Blazor rollback tag `blazor-final` (#205)" entry above for the exact command +
restore path). Not a `v*` tag → no prod release build.
@@ -0,0 +1,17 @@
---
key: channel.origin-marker
title: 2026-07-23 — Channel origin is immutable creation-provenance, stamped at insert, not a health signal (#414)
status: active
since: '2026-07-23'
supersedes: none
superseded-by: none
rule: A new `Channel.Origin` (`ChannelOrigin` enum — `Unknown`/`UserCreated`/`AutoTuned`) records how a channel row was created and is stamped exactly once at insert (`AutoTuned` in `CreateChannelFromLineupHandler`, `UserCreated` in `CreateChannelHandler`), and is never mutated on a later edit. It is surfaced as a raw `origin` field on `ChannelResponseModel`; the SPA badges only `AutoTuned`. Rows predating the column read `Unknown` — provenance is **not** back-filled.
signals: 'channel origin, auto-tuned vs user-created channel, ChannelOrigin enum, immutable creation provenance, Origin column stamp at insert, do not back-fill origin, Channel Lineups playlist-group heuristic rejected, auto-generated then user-edited stays AutoTuned · paths: `ErsatzTV.Core/Domain/ChannelOrigin.cs`, `Channel.Origin`, `CreateChannelFromLineupHandler.BuildChannel`, `CreateChannelHandler`, `ChannelResponseModel`, `web/src/screens/ChannelsScreen.tsx`, `/app/channels` · issues: #414, #415, #72'
mechanics: '`CreateChannelHandlerTests` (UserCreated stamp), `CreateChannelFromLineupHandlerTests` (AutoTuned stamp), `ChannelsScreen.test.tsx` (badge only on AutoTuned); dual-provider migration `Add_Channel_Origin`'
---
This is #72 scope item (a), deferred in `api.channel-health-signal` because "no honest signal exists": `ChannelPlayoutSource.Generated` is a *playout-strategy* value that SPA-created blank channels also carry, so it would mislabel them, and a join through the `"Channel Lineups"` system playlist group was rejected as a heuristic that breaks the moment a user edits the channel. The fix is a dedicated `Origin` column — a fact, not a derivation.
**Immutable provenance, not a mutable "still managed" flag.** `Origin` records how the row was *born* and a later user edit never changes it, so "auto-generated then user-edited" stays `AutoTuned`. This deliberately avoids reviving the fragile "detect when it's been edited away" heuristic the issue rejected. A future "has diverged from its auto-tune template" signal, if wanted, is a *separate* concern owned by the #383/#384 auto-tune arc (which knows the template), not this column — mirroring the `api.channel-health-signal` reasoning that kept health a raw fact rather than freezing a policy enum.
**`Unknown = 0` is the honest legacy default.** A new non-null int column defaults existing rows to `0`; making that `Unknown` (rather than `UserCreated`) means pre-migration rows say "we never recorded this" instead of asserting a provenance we cannot know. The SPA badges only `AutoTuned`, so `Unknown` and `UserCreated` both render unbadged. Enum (not `bool IsAutoTuned`) so a future origin (e.g. `Imported`) is additive without a wire-contract break. Stamped in `CreateChannelFromLineupHandler.BuildChannel`, which is the single channel-construction primitive `CreateAutoTunedChannelsHandler` delegates to, so both the lineup endpoint and bulk auto-tune are covered by one stamp site. Empty-schedule and broken-source fault detection remain deferred to #415.
@@ -0,0 +1,21 @@
---
key: ci.batch-pushes-no-cancel-route
title: '2026-07-21 — Batch your pushes: there is no agent-side cancel route on Gitea 1.25.4 (#542)'
status: active
since: '2026-07-21'
supersedes: none
superseded-by: none
rule: Hold review fixes, doc corrections and format fixes locally and push **once** — a superseded run cannot be cancelled from the agent side and holds a runner slot until it finishes.
signals: 'cancel run 404 · Gitea 1.25.4 · `POST /api/v1/.../actions/runs/{id}/cancel` · MCP `actions_run_write` `cancel_run` · session+CSRF · `dispatch_workflow` · orphaned run · 4-slot runner · paths: n/a · issues: #542'
mechanics: Gitea Actions REST + MCP `actions_run_write`; operator-only cancel in the browser UI.
---
Cancellation is impossible from the agent side on this Gitea (**1.25.4**):
`POST /api/v1/.../actions/runs/{id}/cancel` returns **404**, MCP `actions_run_write`'s `cancel_run`
returns **404**, and the web-UI route needs a session + CSRF that does not script (login 303s with no
session cookie). **Only the operator can cancel, in the browser** — so if you must supersede a live
run, say so explicitly instead of leaving it burning. (`dispatch_workflow` is a different route and
still works for re-triggering a **main** run.)
This corrects the older "superseded runs drain on their own" framing: they do finish, but they hold
one of the 4 runner slots while doing it, which is a real cost with several parallel sessions.
@@ -0,0 +1,30 @@
---
key: ci.build-once-rejected
title: '2026-07-18 — CI build-once was measured and rejected; keep the #420 tree-skip'
status: active
since: '2026-07-18'
supersedes: none
superseded-by: none
rule: 'CI build-once (a shared compile artifact across jobs) was implemented, measured, and rejected for a 40-85% wall-clock regression; keep the #420 cross-run tree-identity skip instead.'
signals: 'CI build-once, artifact tar/transport cost, tree-identity skip · paths: `docs/ci-cd.md` → Cross-run tree-identity skip · issues: #420, #398, #455'
mechanics: 'docs/ci-cd.md → Cross-run tree-identity skip; PR #455 measurement'
---
Build-once (a `compile` job producing a single artifact, consumed by `test`/`migrations`/
`functional-e2e` via `--no-build`) was fully implemented and went **green on CI** (PR #455, run
830), then **rejected on measurement**: it traded a ~12% slot-occupancy saving for a ~40–85%
**per-run wall-clock regression**.
- **Why it regressed.** The `bin`+`obj` artifact is 2.5 GB raw / 972 MB gz; tar alone costs ~82s CPU
plus ~180s transport, consuming most of the ~465s the shared compile was meant to save. Worse,
`compile` serializes **before** `migrations`' long ef-replay, which is pure DB work a shared build
cannot shorten — the bottleneck was never the redundant compiles.
- **Incidental finding worth recording.** `actions/upload-artifact@v4` does not work on this Gitea
instance — it throws `GHESNotSupportedError`, because the `@actions/artifact` v2 client library
rejects any non-`github.com` host. `@v3` is required for any future artifact use here.
- **Kept: the #420 cross-run tree-identity skip** (`docs/ci-cd.md` → Cross-run tree-identity skip).
It is independent of build-once — it only *skips* redundant work on identical-tree main pushes, at
zero wall-clock cost, rather than trying to share a build across jobs. Don't re-attempt build-once
unless the runner's artifact storage or network changes materially.
Refs: #398 (closed), #420, PR #455.
@@ -0,0 +1,21 @@
---
key: ci.cancelled-is-not-a-verdict
title: 2026-07-21 — `cancelled` is not `failure`; a cancelled run is no verdict (#542)
status: active
since: '2026-07-21'
supersedes: none
superseded-by: none
rule: Treat a `cancelled` conclusion as "no verdict" — never as pass or fail — and report FAILED and CANCELLED counts separately in any CI monitor.
signals: 'conclusion cancelled · run-level vs job-level conclusion · pre-cancel genuine failure · CI monitor state != pending · phantom failure · paths: n/a · issues: #542'
mechanics: Gitea Actions run/job API; monitor logic, e.g. `fail=[j for j in jobs if j['conclusion']=='failure']; canc=[j for j in jobs if j['conclusion']=='cancelled']`.
---
The operator cancels runs by hand — they are the only party who can (see
`ci.batch-pushes-no-cancel-route`) — and a run-level `conclusion: cancelled` means the run produced
**no verdict** about your diff. Two traps follow. First, a run whose *overall* state is `failure` may
hold a **genuine job failure that happened before the cancel**: check job-level `conclusion` plus
timestamps rather than attributing the red to the cancel. Second, a cancelled run tells you nothing,
so never claim green on one.
A monitor that only asks "is state != pending" will report a cancelled run as a failure and send the
next session debugging a phantom. Split the two counts explicitly.
@@ -0,0 +1,51 @@
---
key: ci.decisions-edit-trailer
title: 2026-07-25 — The rationale-edit marker is a git trailer, not a substring anywhere in the commit range (#609)
status: active
since: '2026-07-25'
supersedes: none
superseded-by: none
rule: The body-diff exemption is armed by an affirmative `Decisions-Edit:` **git trailer** (`yes`/`true`/`1`, case-insensitive, read with `unfold`) on some NON-MERGE commit in the PR's merge-base range — never by a substring search over the message text. A non-affirmative value (`no`) does not arm it, the retired `[decisions-edit]` substring arms nothing (the validator emits a `::warning::` nudge when it sees one without a trailer), and a git error leaves the guard ON.
signals: 'decisions-edit, Decisions-Edit trailer, rationale-prose edit, body-diff guard, decisions_validate, vacuous gate, green no-op · paths: `scripts/decisions_validate.py`, `scripts/tests/test_decisions_validate.py` · issues: #609, #603, #521'
mechanics: '`git log --no-merges --format=''%(trailers:key=Decisions-Edit,valueonly,unfold)'' <mb>..<head>`; `dv._edit_trailer_armed`'
---
**The guard could disable itself by being described.** The original check was
`EDIT_TOKEN.lower() in git log --format=%B mb..head`, so *any* commit message containing the literal
string anywhere set `token = True` and suppressed all three rewrite comparisons (active survivors,
active→archive laundering, archive survivors). It fired live in PR #605: a commit message explaining
*why no token was needed* contained the bracketed token, and armed it. The result was a **green
`--base/--head` run that was vacuous on the body-diff dimension**, in the one PR that hand-resolved a
merge conflict inside the corpus the guard exists to police. No content was damaged, but the gate
reported success while checking nothing — the "a gate can merge green yet be a no-op" class.
**Why a trailer and not an own-line match.** Requiring the token alone on its own line fixes the
observed instance, but not the class: this repo's docs commits routinely *quote example commit
messages*, and the commit introducing this very record does so. An own-line matcher would arm on the
example. Git recognises a trailer only in the message's final paragraph, so a `Decisions-Edit: yes`
line quoted mid-body followed by more prose parses as nothing at all (verified against git 2.55).
Requiring an *affirmative value* closes the same trap one level up: `Decisions-Edit: no`, the natural
way to record a deliberate non-edit, must not read as consent.
**Three exactness requirements the naive trailer read still gets wrong** (all found in review, all
regression-tested). `--no-merges`: on a `pull_request` event `actions/checkout` lands on a synthetic
merge commit whose body the forge composes from the PR *description*, so without it a description
ending in an example marker arms a guard no author armed. That exclusion is not free: merging main
into a PR branch is discouraged but not mechanically blocked (`prepush-rebase-check.sh` only refuses a
branch that is *behind* main, and a merge makes main an ancestor), so an author who marks ONLY such a
merge commit has a legitimate rewrite rejected — a LOUD failure costing one extra commit, deliberately
preferred over silently disabling the guard. `unfold`: a folded `Decisions-Edit: no` + continuation
` yes` otherwise yields two lines, and the continuation arms on its own — inverting the value the
author wrote. And resolving the marker **fails closed** on a git error, the one deliberate exception
to this module's fail-open posture: a fail-open marker lookup is #609 through a different door.
**The residual, stated rather than papered over:** a quoted example that is the *final* paragraph of
an ordinary commit message is, by git's own grammar, a trailer — and will arm. That is inherent to any
trailer scheme; what the change buys is that merely *mentioning* or *discussing* the marker, which is
what actually happened in #605, can no longer disable anything.
**The marker's scope is unchanged** — still narrow, still only for a rationale-prose edit to a
surviving or archived record (`docs.decision-lifecycle`); only its form changed. It coexists with the
`Co-Authored-By:` trailer the `commit-msg` hook already mandates, so armed commits carry a
multi-trailer block. The retired form is left detectable on purpose: a contributor with the old habit
gets a warning naming the form change, not a bare "prose changed" failure.
@@ -0,0 +1,22 @@
---
key: ci.decisions-lifecycle-flake
title: '2026-07-21 — A lone `decisions lifecycle` red is a known infra flake: do nothing (#542)'
status: active
since: '2026-07-21'
supersedes: none
superseded-by: none
rule: When `decisions lifecycle` is the **only** red job, do not investigate and do not create a new run to clear it — no rebase, no `--amend`, no no-op push; the operator reruns that single job from the Gitea UI.
signals: 'decisions lifecycle red · append-only gate · known flake · operator selective rerun · no-op push · convincing local explanation · paths: `docs/decisions.md` · issues: #542, #473, PR #479'
mechanics: Gitea Actions `decisions lifecycle` job; operator-driven single-job rerun in the web UI.
---
Operator-stated, 2026-07-19. Report it as a known flake and carry on; only if *other* jobs are red too
does the run deserve diagnosis. Same family as the killed-job rule — a spurious single-job red is
cleared by the operator's selective rerun, never by pushing, and pushes cannot be cancelled anyway.
**The trap is that a convincing local explanation is always available.** On #473/PR #479 the job went
red just after `main` landed its own `decisions.md` entry, so "mine is no longer at EOF, I must
rebase" looked airtight. The rebase happened — and the job went red **again** on a head whose diff was
a verified pure EOF append with zero deleted lines. A rebase that provably satisfied the gate's stated
rule did not turn it green, which is the proof that the red was never about the diff. Check this rule
*before* theorising about a cause.
@@ -0,0 +1,25 @@
---
key: ci.docs-only-detect-shallow-safe
title: '2026-07-17 — Docs-only detect must be shallow-checkout safe: FETCH_HEAD + two-dot, not origin/main + three-dot (#416 follow-up)'
status: active
since: '2026-07-17'
supersedes: none
superseded-by: none
rule: 'The docs-only detect script must diff against `FETCH_HEAD` (always resolves after `git fetch`, even shallow) using a two-dot tree diff — not `origin/<base>` with three-dot — because a `fetch-depth: 1` shallow clone has no remote-tracking ref and no merge-base, which silently fails the original detect into `docs_only=false` (full matrix, no functional error). A CI-behavior change must be verified by measuring the effect (job durations), not just a green check.'
signals: 'shallow clone, FETCH_HEAD, two-dot vs three-dot diff, fetch-depth 1 · paths: `scripts/ci-detect-docs-only.sh` · issues: #416, #422'
mechanics: '`docs/ci-cd.md` → "Docs-only skip"; verified via a real shallow `file://` clone reproduction'
---
The #416 docs-only skip shipped (#422) safe but **ineffective**: every docs-only PR still ran the full
matrix. Root cause — `test`/`migrations` check out `fetch-depth: 1`, and in a shallow clone
`origin/<base>` has no remote-tracking ref and there is no merge-base, so the detect script's
three-dot `git diff origin/main...HEAD` errored → `|| true` → empty diff → the fail-safe returned
`docs_only=false` → full matrix. Confirmed in a real shallow `file://` clone (`origin/main` did not
resolve; three-dot errored; `git diff FETCH_HEAD HEAD` returned the changed files correctly).
Decision: the detect diffs against **`FETCH_HEAD`** (always written by `git fetch`, resolves in a
shallow clone) with a **two-dot** tree diff (no merge-base). `api-docs`/`format` were unaffected only
because they use `fetch-depth: 0` — a difference the first cut missed. Meta-lesson reinforced: a CI
gating change can pass every local test and merge green while being a complete no-op in CI; only
real-PR verification that **measures the effect** (job durations, not just a green check) catches it —
which is exactly what #416's Done-when demanded. Fixed in the #416 follow-up PR.
@@ -0,0 +1,44 @@
---
key: ci.docs-only-skip-steps
title: 2026-07-17 — Docs-only CI skip gates STEPS in always-running required jobs, never `if:`-skips them (#416)
status: active
since: '2026-07-17'
supersedes: none
superseded-by: none
rule: 'A docs-only change must still run every required job (`test`, `migrations`) so their commit-status contexts always report; each heavy job runs `scripts/ci-detect-docs-only.sh` first and gates its real STEPS on `if: steps.detect.outputs.docs_only != ''true''`, never `if:`-skips the whole job (an `if:`-skipped job reports `skipped`, not `success`, which branch protection may never unblock on). Detection biases toward running more on any doubt.'
signals: 'docs-only CI skip, required-context branch protection, step-level gating vs job-level `if:` · paths: `.gitea/workflows/docker-build.yml`, `scripts/ci-detect-docs-only.sh` · issues: #416, #418, #420, #398'
mechanics: '`docs/ci-cd.md` → "Docs-only skip"'
---
A change touching only `docs/**` or `*.md` ran the entire `docker-build.yml` matrix (`test`,
`migrations` incl. its `mysql:8.4` service, `functional-e2e`, `format`, `api-docs`) — ~9 min of warm
CI to validate Markdown. `docker-build.yml` had no path filtering.
**Why not `paths-ignore` / an `if:`-skipped job — the trap.** `main`'s branch protection requires
two contexts *by name* (`Build & test (.NET)`, `EF migration integrity (SQLite + MySql)`). If a
docs-only PR produced **no run** for them, those contexts never report and the PR can **never merge**
— the naive fix bricks docs PRs instead of speeding them. A probe (throwaway PR #418) confirmed that
on Gitea **1.25.4** an `if:`-skipped job reports commit-status state **`skipped`** (a distinct state,
not `success`); how branch protection treats a `skipped` *required* context is not something we rely
on.
**The decision.** Each heavy job (`test`, `migrations`, `functional-e2e`, `build`) runs
`scripts/ci-detect-docs-only.sh` as its first post-checkout step (`id: detect`) and gates every real
step on `if: steps.detect.outputs.docs_only != 'true'`. The job **always runs** and reports
`success` in seconds on docs-only — so the two required contexts report unconditionally (safe by
construction). Non-required jobs may skip freely (production proves a `skipped` non-required context
doesn't block merge — `build` is `skipped` on every PR), so `build` skips its image steps on a
**docs-only push to `main`** (docs aren't in the image); tag builds force `docs_only=false` so a
release is never skipped. `api-docs`/`format` already self-short-circuit; `docs-reminder`/
`decisions-guard`/`ci-image-pin` keep running.
**Detection biases toward running MORE.** `docs_only=true` only when *every* changed path is docs;
any code path, a tag build, a non-merge push, or an undeterminable diff → `false` (run everything). A
false `true` would skip real tests on a code change (a correctness bug); a false `false` merely wastes
CI. Trade-off accepted: `migrations`' `mysql` service still starts on a docs-only run (a `services:`
container starts with the job regardless of step `if:`), but the 787-migration replay — the expensive
part — is skipped.
Two adjacent redundancies are deliberately **out of scope**: the whole matrix re-running on a PR and
again on the merge-to-`main` over identical code (#420), and the within-run triple `dotnet build`
(#398). Full mechanism in `ci-cd.md` → "Docs-only skip".
@@ -0,0 +1,40 @@
---
key: ci.format-gate-folder-mode
title: 2026-07-19 — The `format` gate runs `dotnet format whitespace . --folder`, not the full solution format (#469)
status: active
since: '2026-07-19'
supersedes: none
superseded-by: none
rule: The blocking `format` CI job (and matching pre-commit hook) runs `dotnet format whitespace . --folder --include <files>` instead of loading the full MSBuild/Roslyn solution, cutting the gate from ~480s to ~0.5s with unchanged whitespace/charset coverage.
signals: 'dotnet format, folder mode, CI format gate · paths: `.gitea/workflows` format job, `.editorconfig` · issues: #469, #406, #311'
mechanics: '`dotnet format whitespace . --folder --verify-no-changes --include <files>`'
---
The blocking `format` CI job (and the matching Husky pre-commit hook) verify changed `.cs` files with
`dotnet format whitespace . --folder --verify-no-changes --include <files>` instead of the previous
`dotnet format ErsatzTV.sln --no-restore --verify-no-changes --include <files>`.
- **Why.** `--include` narrows *which* files are checked, never what gets loaded. The full recipe loaded
the whole ~10-project MSBuild workspace and built a Roslyn compilation per project before checking a
single line — a fixed cost independent of how few files changed. Measured **~480s** for a whole-solution
`dotnet format` locally (matching the issue's "7+ min"). `--folder` treats the tree as a plain folder of
files and skips MSBuild/Roslyn entirely: **~0.5s**, and it needs no `dotnet restore`, so the job's
NuGet-cache + Restore steps were deleted. It also drops the job's ~3.95 GiB Roslyn heap (the #406
memory note about `format` not shrinking is now moot).
- **Coverage is unchanged, not merely "good enough".** Folder mode reads `.editorconfig` and enforces
exactly the two things this gate exists for — **whitespace** (indent/EOL/trailing/final-newline) and
**charset** (no UTF-8 BOM). Proven non-vacuous: exits non-zero with `error WHITESPACE` on an injected
trailing-whitespace line and `error CHARSET` on a prepended BOM; exits 0 on a clean file. What it drops
is the style/analyzer pass — but the *full* gate never enforced that either: a probe injecting a
`warning`-severity naming violation (`local_constants` not `ALL_UPPER`) **passed** the full solution
format (exit 0): the only `.editorconfig` rule above `:suggestion`/`:none` severity is that one naming
rule, and naming violations have no `dotnet format` batch code-fixer, so `--verify-no-changes` reports
no change regardless of severity. The analyzers that must block (`NU1904`,
`S3981`) are enforced at compile time via `WarningsAsErrors` in `Directory.Build.props`, never by this
job.
- **Fix command for a violation:** `dotnet format whitespace . --folder --include <files>`. The full
`dotnet format ErsatzTV.sln --include <files>` is a superset (also applies style) and still works, so
existing muscle memory and the #311 lore's `dotnet format --include` guidance are not broken.
- **Lane left on `ubuntu-latest`.** The job is now seconds-long and low-memory, so it could move to a
lighter lane, but that re-touches the per-lane memory-cap accounting (#406/#604) and is a
server-management capacity call — deliberately out of scope here.
@@ -0,0 +1,39 @@
---
key: ci.functional-e2e-harness
title: '2026-07-16 — Functional-E2E CI harness: advisory curl-contract job over an app booted from source (#299)'
status: active
since: '2026-07-16'
supersedes: none
superseded-by: none
rule: 'The `functional-e2e` CI job boots the PR''s own code from source via `dotnet run` (`scripts/e2e-local.sh`) and runs deterministic assertions (`scripts/e2e-functional.sh`) as an advisory (non-blocking) job, not a `build` dependency or required check. Originally curl-only; since #445 the same job carries a second, headless-browser step for the contracts curl cannot express — see `ci.ui-e2e-harness`.'
signals: 'staged rollout precedent (`migrations` job), racy/interactive flows deferred · paths: `scripts/e2e-local.sh`, `scripts/e2e-functional.sh` · issues: #299'
mechanics: '`scripts/e2e-functional.sh`'
---
The manual live-E2E curl flows sessions had been re-running by hand (and leaving only as PR/issue
comments) are now a CI regression net. Two decisions shaped it:
**Boot from source + `dotnet run`, not the built image.** The only "E2E" in CI before this was the
smoke test in the `build` job, which runs against the *pushed* image — so it exists only on `main`/`v*`
(the image isn't built on PRs) and would test a stale image, not the PR's code. To gate PRs on the PR's
own code, the `functional-e2e` job builds the SPA + solution and launches `dotnet ErsatzTV.dll` via the
same `scripts/e2e-local.sh` used locally (parameterized with `ETV_BUILD_CONFIG=Release`). The assertions
live in `scripts/e2e-functional.sh`, so the identical harness runs by hand and in CI — which is the
point of the issue (stop re-deriving the flows each session).
**Advisory, not blocking — separate job, not a `build` dependency, not a required check.** Per the
issue's "a functional-E2E flake must not block the unit-test gate." A boot-the-app job has more moving
parts (background process, port, readiness wait) than a pure unit test, so it starts advisory and gets
promoted to a required check / `build` dependency once proven reliable — the same staged rollout the
`migrations` job used. SQLite is the default provider, so it needs no DB service container.
**Scope is curl-only and deterministic; the racy/interactive flows are explicitly deferred.** The first
cut asserts the legacy→SPA redirect sweep (+ `/api`/`/artwork` never-redirect exemption), the
auth/CSRF/security-stamp flow, the library-scan status contract (404/202/`scan-status`), and the
`If-Match`/412 round-trip — all exercisable without seeded media, ffmpeg-transcode, or a browser (an
empty local library still enqueues `202`; an empty collection drives the concurrency editor). The 409
"already-scanning" re-trigger (needs a long-running scan to be non-racy), the playout-build lock 409,
and the genuinely UI-interactive Playwright flows are deferred as #299 follow-ups rather than shipped
flaky. (All three have since landed: the two 409s in #363/#444, the Playwright flows in #445 —
`ci.ui-e2e-harness` — as a second step of this same job.) Assertions were written against a real running instance, not the source — which caught that
`/artwork/*` returns `400` (not the `404` a static read suggested); extend the harness the same way.
@@ -0,0 +1,25 @@
---
key: ci.gitea-milestone-filter-noop
title: 2026-07-21 — Gitea's `?milestones=` issue filter silently no-ops on names containing `:` or `+` (#542)
status: active
since: '2026-07-21'
supersedes: none
superseded-by: none
rule: Never filter issues with the server-side `?milestones=<name>` parameter — fetch all open issues once and filter LOCALLY on each issue's `.milestone.title`.
signals: '`GET /issues?milestones=` · milestone name filter · returns the whole open-issue list · `:` or `+` in a milestone title · mis-tiered issues · count looks like "all open issues" · paths: `scripts/select-queue.sh` · issues: #542, #77, #72'
mechanics: '`?state=open&type=issues&limit=50`, then filter in the client, e.g. `[i for i in issues if (i[''milestone''] or {}).get(''title'') == NAME]`. `scripts/select-queue.sh` already does this; the trap is for anyone writing a *new* query.'
---
The filter does not error on a name it cannot round-trip — it returns the **entire open-issue list** as
though every issue matched. Milestone titles here routinely contain the offending characters (e.g.
`Scheduling: refactor + distribution`), so the failure is the normal case, not an edge case.
Anything trusting that response mis-tiers issues: during #77 selection it made unmilestoned #72 look
like a member of the milestone and hid #77's true sibling set. **The tell is a result count suspiciously
equal to the total number of open issues** — if a filtered query returns everything, the filter silently
failed; re-derive membership locally rather than reasoning about the result.
The dependencies API is unaffected: `POST /issues/{n}/dependencies` with `{"owner","repo","index"}` sets
blocked-by correctly, though the bare `{index}` form returns 201 without reliably attaching — verify
with the GET. (That last sentence is carried forward from an earlier revision of the handoff doc,
commit `f93458c7`, where it was dropped by a later prune rather than disproved.)
@@ -0,0 +1,26 @@
---
key: ci.infra-shaped-red-under-load
title: 2026-07-21 — An infra-shaped red under host load is not a code failure (#542)
status: active
since: '2026-07-21'
supersedes: none
superseded-by: none
stale-after: '2027-02-15'
rule: When a job dies inside a setup/cache step before your code compiles, check the runner host's load before diagnosing the diff, and never file a CI bug off one sample under pressure.
signals: 'load average 243 · `Setup .NET` 11ms failure · `remove /root/.cache/act/…/.gitignore: no such file or directory` · buildx `DeadlineExceeded: failed to compute cache key` · `Build & push image` · `EF migration integrity` · `FactoryServer-L` game server · phantom CI bug · paths: n/a · issues: #542'
mechanics: '`uptime` on the runner host (bumblebee); Gitea Actions job step logs.'
sources: 2026-07-17, bumblebee at load average 243 — `EF migration integrity` dead in ~11ms inside `Setup .NET`, main's `Build & push image` dead on buildx `DeadlineExceeded`; both green on re-run at normal load (#542)
---
On 2026-07-17, with bumblebee at load average **243**, two unrelated jobs died in ways that look like
code bugs but weren't: `EF migration integrity` failed in ~11ms inside **`Setup .NET`** with
`remove /root/.cache/act/…/.gitignore: no such file or directory` (act's shared cache), and main's
`Build & push image` died on buildx `DeadlineExceeded: failed to compute cache key` after ~210s of
retries. Both re-ran green at normal load, and the migration job had no model change to test in the
first place.
The diagnostic tell is the **location** of the failure: a setup or cache step, before your code
compiles. One sample under load is not evidence of a systemic problem — this nearly got filed as #390
lane-rebalance fallout, which the evidence did not support, and would have sent the next session
chasing a phantom. Note also that a game server (`FactoryServer-L`) shares that host with the
runners, so high load is not always CI's own doing.
@@ -0,0 +1,27 @@
---
key: ci.killed-job-triage
title: '2026-07-21 — A killed CI job reports `conclusion: failure`; read the log tail before diagnosing the diff (#542)'
status: active
since: '2026-07-21'
supersedes: none
superseded-by: none
rule: Never trust a job's `conclusion` field alone — read the log tail and require an `❌ Failure - Main …` marker before treating a red as a real failure.
signals: 'killed job · runner restart · conclusion failure · log truncated mid-step · `❌ Failure - Main` marker · `Unable to pull refs/heads/v4` · act cache noise · UTC vs UTC+2 · semantically-null diff · paths: n/a · issues: #542'
mechanics: Gitea Actions job logs + runner container uptime on the runner host.
---
When the operator restarts the runners (a retune, a reboot), in-flight jobs die and Gitea marks them
**`failure`**, not `cancelled`. The tell is that the log **stops mid-step with no error and no
`❌ Failure - Main …` marker** — a real failure always leaves that marker. On run 1006, `EF migration
integrity` and `Functional E2E` both "failed" on a **BOM-removal-only** diff (6 files, one line each,
zero content change) that could not possibly break them; the logs simply truncated mid-`dotnet build`
at 12:08 UTC and both runners showed `Up About an hour` — the retune had killed them.
**Log timestamps are UTC; the host is UTC+2.** Convert before correlating, or the restart looks two
hours off and a correct theory gets wrongly discarded.
Corollary: a diff that *cannot* cause a failure is evidence the failure isn't yours — when a job that
passed on the previous head fails on a semantically-null delta, suspect the environment and go read
the log rather than re-litigating the diff. Beware warnings that look fatal: `Unable to pull
refs/heads/v4: …` is act refreshing its `/root/.cache/act` action cache and is followed by `Cloned …`
— it is noise, not a cause. Grep for the failure marker, not for the word "error".
@@ -0,0 +1,53 @@
---
key: ci.monitor-armed-at-pr-open
title: 2026-07-21 — Arm the CI monitor at PR-open, via the commit-status endpoint (#542)
status: active
since: '2026-07-21'
supersedes: none
superseded-by: none
rule: Arm a CI monitor on the PR head sha the moment the PR opens, polling the commit-status endpoint — not at the end of the work.
signals: 'arm monitor at PR open · commit status endpoint · head sha · red run sat unnoticed · MySQL-apply flake · `skipped` misread as red · `Build & push image (amd64)` skipped · monitor classification · `!= "success"` filter · combined `.state` · paths: n/a · issues: #542, #216, #583'
mechanics: '`GET /api/v1/repos/timothy/ersatztv/commits/{sha}/status` for the combined verdict (`.state`); `…/statuses?limit=50` only when you need per-context detail.'
---
CI runs concurrently with the review and E2E work that follows a push, so the cost of a late-armed
monitor is entirely wasted wall-clock. In the #216 session three PR runs sat red for roughly an hour
on a CI-only flake while review and E2E ran to completion — the reds were only discovered afterwards.
**Classify per-context states correctly, or prefer the combined `.state`.** A monitor that enumerates
contexts and treats anything `!= "success"` as red is WRONG on this repo: `Build & push image (amd64)`
is `if:`-gated at the JOB level on `github.event_name != 'pull_request'`, so it reports **`skipped` on
every PR**, by design and regardless of content (images are built only on push-to-main and tags — see
`ci.docs-only-skip-steps`, which records the same fact from the branch-protection angle: "`build` is
`skipped` on every PR").
Such a monitor cries "NOT all green" on a perfectly green PR. Note this is *not* the docs-only skip:
the docs-only mechanism deliberately gates individual STEPS so required jobs still report `success` in
seconds — misattributing the image job's skip to docs-only is a plausible-sounding wrong diagnosis
(#583 session, 2026-07-25).
Three distinct non-`success` states, three meanings — do not collapse them:
- **`skipped`** — deliberately not applicable. Settled, and *not* red. Gitea's combined `.state` already
treats it as non-blocking (a PR with a skipped `build` reports `overall=success`), which is why the
combined endpoint is the safer thing to gate on.
- **`failure`** — a real red; diagnose it (but first check `ci.killed-job-triage` and
`ci.infra-shaped-red-under-load`).
- **`cancelled`** — no verdict at all; see `ci.cancelled-is-not-a-verdict`.
Working filter when you do enumerate — verified silent on a green PR carrying a skipped `build`, and
verified to still report a genuinely unfinished run (i.e. proven able to go dirty, per
`process.bom-format-detection-recipe`'s "verify your detector" rule):
```bash
curl -s -u "$ETV_GITEA_BASICAUTH" ".../commits/$SHA/statuses?limit=50" \
| jq -r '[.[]|{c:.context,st:.status}]|group_by(.c)|map(.[0])|.[]
|select(.st!="success" and .st!="skipped")|"NOT-GREEN: \(.c) = \(.st)"'
```
**Mind the renamed key.** The first draft of this snippet said `select(.status != …)` after the
pipeline had already renamed `.status` to `.st`, so the comparison ran against `null`, passed
*everything*, and reported a fully green PR as nine failures. Report `failure` and `cancelled` in
separate counts.
Related context for interpreting an early red: the old MySQL host-port 3306 collision is fixed on main
(`ef8915f1`), so a lone MySQL-apply red now indicates the known infra flake and warrants a rerun
rather than diagnosis.
@@ -0,0 +1,19 @@
---
key: ci.no-host-health-gating
title: 2026-07-21 — Do not gate or throttle pushes on host health (#542)
status: active
since: '2026-07-21'
supersedes: none
superseded-by: none
rule: Push when your work is validated — never SSH to bumblebee to sample load/RAM first, and never hand-schedule around other sessions' runs.
signals: 'trust the build queue · runner retune · don''t sample load before pushing · queueing is the queue''s job · paths: n/a · issues: #542'
mechanics: Gitea build queue (two runners, 4 slots).
---
The runners were retuned for stability (operator, 2026-07-17); queueing is the queue's job, not the
agent's. Sampling host load before a push is both unreliable and unnecessary, and hand-scheduling
around other sessions produces coordination that no one can verify.
Batch your pushes (see `ci.batch-pushes-no-cancel-route`) because orphaned runs cannot be cancelled —
**not** because the host needs protecting. The two rules have the same action and different reasons;
conflating them leads to load-watching behaviour that this record forbids.
@@ -0,0 +1,46 @@
---
key: ci.peak-anon-measurement
title: 2026-07-19 — CI `test` job reports a sampled true peak-anon, not cache-inflated `memory.peak` (#412)
status: active
since: '2026-07-19'
supersedes: none
superseded-by: none
stale-after: '2027-03-15'
rule: The `test` job's headline memory figure is a sampled high-water mark of cgroup `anon`, produced by `scripts/ci-peak-anon.sh`; `memory.peak` and the end-of-job `anon`/`file` split are kept only as a cache-inflated reference.
signals: 'CI memory measurement · paths: `scripts/ci-peak-anon.sh`, `.gitea/workflows/*.yml` · issues: #412, #411 (prose-only predecessor, no standalone record — its `memory.peak`-headline approach is superseded by this record)'
mechanics: '`scripts/ci-peak-anon.sh` header; `docs/ci-cd.md` → CI build memory'
sources: '`scripts/ci-peak-anon.sh` (the sampler itself) · cgroup v2 `memory.peak` vs `memory.stat` `anon` accounting, measured on the bumblebee runners #412'
---
**Decision.** The `test` job's memory instrument (added in #411) now reports a **sampled high-water
mark of the cgroup's `anon` memory** as the headline figure, produced by `scripts/ci-peak-anon.sh`
(a `start` step before the dotnet Build/Test/Coverage, a `report` step last). `memory.peak` and the
end-of-job `anon`/`file` split stay in the output as a cache-inflated ceiling and a reference.
**Why not just `memory.peak`.** `memory.peak` is the high-water mark of `memory.current`, which
charges reclaimable **page cache** to the cgroup alongside anon. A build does heavy
NuGet/npm/obj/bin/coverage I/O, so cache can dominate the peak — and page cache is *reclaimed* under
a tighter cap, not OOM-killed. Sizing a per-job cap (server-management#604) off `memory.peak`
therefore **inverts the decision**: a big, mostly-`file` peak reads like "the cap must stay high"
when it isn't. The OOM-forcing quantity is peak **anon**. The kernel exposes `memory.peak` but has
**no peak-anon counter**, and the end-of-job `anon` is the composition *then*, not at the peak
instant (a job that peaks mid-`dotnet test` then frees reports a misleadingly low `anon`) — so it
must be **sampled**. Details + the sampler's robustness rationale: `scripts/ci-peak-anon.sh` header
and `docs/ci-cd.md` → "CI build memory".
**Implementation note (do not "simplify" back to `memory.peak`).** The sampler is a detached
`nohup` poller that survives step-boundary re-execs (reparents to the container's PID 1) and is
reaped at container teardown; a TERM trap + `sleep & wait` stops it at once on `report`. Both steps
are `continue-on-error` with a fail-open script, so the instrument can never redden a green build.
Validated on bumblebee: it catches a transient 2.5 GiB anon spike that the end-of-job snapshot
reports as 0.
**Compiler-server A/B verdict (refines the #406 entry's "premise looking dead" read).** Measured
in the CI image, swap-off, sampled peak-anon, n=2 interleaved: **OFF (the CI config) ≈ 5.84 GiB,
consistent; ON (defaults) 6.3–7.6 GiB, always higher, + a ~3 GiB resident `VBCSCompiler`.**
Disabling the servers is worth it (consistent reduction, no resident server), but OFF sits *right at
6 GiB for the build phase alone* and the `test` job adds test + coverage on top — so #406's premise
("disabling brings peak *well under* 6 GiB → the budget loosens") is **not supported**. Size the cap
off the live test-job peak-anon this instrument now reports, not off the build-only A/B. The older
#411 probe (`anon 7134 MiB`) read higher than these swap-off sampled numbers and is superseded
(swap/read-method move the figure >1 GiB).
@@ -0,0 +1,16 @@
---
key: ci.root-screenshot-guard
title: '2026-07-12 — Root-screenshot guard: pre-commit refuses root-level *.png (#303 H3)'
status: active
since: '2026-07-12'
supersedes: none
superseded-by: none
rule: The Husky `pre-commit` hook refuses a staged root-level `*.png` (belt-and-suspenders with the `.gitignore` rule); nested `*.png` real assets are unaffected.
signals: 'git hooks, screenshot guard, root png · paths: `.husky/pre-commit`, `.gitignore` · issues: #303 (H3)'
mechanics: '`.husky/pre-commit`'
---
Companion guard **H3**: the Husky `pre-commit` hook refuses a staged **root-level `*.png`** (a
review/debug screenshot dropped at the repo root) — belt-and-suspenders with the `.gitignore` rule, so
a forced `git add -f` still can't land one. Nested `*.png` (real assets) are unaffected. Rationale for
both: the methodology review (#303) — make the process rules derivations/hooks, not prose to remember.
@@ -0,0 +1,126 @@
---
key: ci.runner-placement
title: '2026-07-17 — No persistent compiler servers in CI; every `services:` container gets an explicit cap; #390''s small-lane move reversed (#406)'
status: active
since: '2026-07-17'
supersedes: none
superseded-by: none
stale-after: '2027-01-15'
rule: No persistent Roslyn compiler server survives a CI build (`UseSharedCompilation=false` etc., runner env + Dockerfile `ENV`); every `services:` container gets its own explicit `--memory`/`--memory-swap`/`--cpus` cap (it does not inherit the job container's).
signals: 'CI memory/swap thrash · paths: `.gitea/workflows/*.yml`, `docker/Dockerfile` · issues: #406, #390 (prose-reversed, no standalone record), server-management#604, server-management#570'
mechanics: '`docs/ci-cd.md` → CI build memory; `scripts/ci-peak-anon.sh`'
sources: 'the 2026-07-17 bumblebee incident — load 340, 21 GiB swapped, ~238 MiB free, prod down until reboot (server-management#604) · host sizing at the time: 25 GiB / 12 cores'
---
Three CI changes, all downstream of one incident: on 2026-07-17 bumblebee (the **prod media** Docker
host, 25 GiB / 12 cores) hit load **340** with **21 GiB swapped** and ~238 MiB free, taking prod
ersatztv/jellyfin down until reboot. Prod ersatztv itself was healthy at 168 MiB throughout — the
thrash was **CI-induced**, triggered by a burst of parallel merges to `main`. Infra sizing is
server-management#604's boundary; these three levers live in this repo and each does more than any
capacity knob.
**1. No persistent compiler servers.** `VBCSCompiler` is a *persistent* Roslyn server: it outlives
the `dotnet build` that started it and holds its heap for the next one. Measured at **7.8 GB RSS**
live on bumblebee — the single largest consumer on the box, and the actual reason each job needed a
10 GiB cap. In CI it buys **nothing**: each job container is torn down at the end of the run, so
there is never a "next build" to warm. The workflow's top-level `env:` now sets
`UseSharedCompilation=false`, `DOTNET_CLI_USE_MSBUILD_SERVER=0`, `MSBUILDDISABLENODEREUSE=1` —
MSBuild properties set as env vars so they apply to every `dotnet` call without touching each call
site (MSBuild surfaces env vars as properties; `UseSharedCompilation` only defaults to `true` when
empty, so the env var wins). Verified locally: a default build leaves 1 `VBCSCompiler` alive, the
same build under these vars leaves **0**, and `ErsatzTV.sln` still builds clean (0 errors).
**Why the Dockerfile also sets them** — and this is the part the issue's suggestion would have
missed: the workflow `env:` reaches the *runner-side* dotnet jobs only. The `build` job compiles
inside `docker build`, where it does not propagate, so the SDK stage of `docker/Dockerfile` sets the
same three as `ENV`. That is precisely the job server-management#570 measured pegging **5.999/6
GiB** — the one that most needs it. Build-stage only; the final image is `FROM runtime-base`, so
nothing lands in the shipped image or affects runtime.
**Trade-off accepted**: without the shared server each project's `csc` is a fresh process, which
costs some build time. Worth it — the memory spike is what takes prod down, and the cap sizing that
spike forces is what starves the lanes.
**2. `services:` containers do not inherit the runner's cap.** A runner's `container.options`
(`--cpus=4 --memory=10g`) applies to the **job container only**. Verified by inspecting a live
`migrations` job: the job container reported `HostConfig.Memory=10737418240`, its `mysql:8.4`
service reported `mem=0 nanocpus=0` — **unbounded**. Every migrations run was adding an uncapped
MySQL to an already-tight host. Now `--memory=2g --memory-swap=2g --cpus=2`.
**Why `--memory-swap` is not redundant** (cold-review catch, and the sharpest thing in this change):
Docker defaults an unset `--memory-swap` to **twice** `--memory`, so `--memory=2g` alone grants 2g
RAM **plus 2g of swap** — verified live: `--memory=2g` → `memory.max=2147483648` **and**
`memory.swap.max=2147483648`; `--memory=2g --memory-swap=2g` → `memory.swap.max=0`. Capping RAM
while silently permitting swap is close to the worst outcome **on the host whose swap thrash is the
entire reason for the cap**, and a swapping mysqld mid-DDL is exactly the pathology behind the known
`Command Timeout expired` migrations flake — i.e. the naive cap could have made that flake worse.
**Standing rule: prefer a loud OOM over silent swapping.** An OOM is an unambiguous "raise the cap"
signal; swapping just degrades everything and blames something else.
**The same 2× applies to the runners' 10g job slots** — each is really 10 GiB RAM *plus* 10 GiB
swap, so the "60 GiB promise on a 25 GiB host" understates by 2× and is a plausible direct mechanism
for the incident's 21 GiB swapped. That is server-management#604's boundary; reported there.
**On 2g, honestly**: 543 MiB is init+idle, **not** the 787-migration replay (which grows caches idle
never touches), so 2g is a measured *floor* plus headroom, not a measured ceiling — the `migrations`
job going green is what validates it. `--cpus=2` has no measurement behind it at all; 787 sequential
DDL statements on one connection are ~1-core-bound, so it is judgement. Recording that rather than
dressing a guess as measurement — the same failure this entry criticises below.
**Standing rule: any new `services:` container needs its own explicit cap** — it will not inherit
one, and `--memory` without `--memory-swap` silently grants 2× in swap.
**3. #390's `small`-lane move for `api-docs`/`format` reversed.** #390 moved them to dodge a ~29 min
`ubuntu-latest` queue. The queue was real, but the lane was the wrong fix, and **#390's own comment
flagged why**: *"on an API-touching PR this job does a full `dotnet build`, so it is not always a
'small' job; capacity 4 absorbs that."* "Capacity 4 absorbs that" held only because **nothing
enforces the sum** of the lanes' caps — 6 slots × 10 GiB on a 25 GiB host is a 60 GiB promise. These
are not small jobs: a live `docker stats` caught the `format` job container at **3.95 GiB**, which
#604's re-sized 2 GiB `small` lane would OOM-kill outright. #604 grows `ubuntu-latest` to 5 slots
(48 GiB ci-runner at capacity 4 + a bumblebee overflow slot) and fixes the queue at the source. This
one is **order-coupled with #604**: the small lane's caps can't tighten until it lands.
**Measurement is now continuous, not a one-off.** The `test` job's **last** step reports the
cgroup's `memory.peak` plus an `anon`/`file` breakdown (`continue-on-error`, tolerates absence).
#604 sizes both runners' caps on that number, and until now it was *inherited* rather than measured
— the 10g cap traces back to #570 observing a different job entirely. Two things that look like
details but are the whole point: it must run **last** (`memory.peak` read at step N reports the peak
only up to N, so an earlier placement silently excludes the job's later workload), and
`continue-on-error` — not `if: always()` — is what makes it advisory (`always()` controls whether a
step *runs*, not whether its failure fails the job, and `defaults.run.shell: bash` means `-e` is on).
**And then the instrument taught us the lesson twice, both times at our own expense.** The first
reading came back `peak 8305 MiB`. `memory.peak` is the high-water mark of `memory.current`, which
charges **page cache** as well as anonymous memory — proven: a container with `anon=0` that merely
reads an 800 MB file reports `memory.peak=826 MiB`, `file=800 MiB`. Page cache is *reclaimed* under
a tighter cap, not OOM-killed, so a large peak that is mostly `file` is **not** evidence a cap must
stay high. `anon` is what forces an OOM; size caps on it. The step now prints the split (end-of-job,
so indicative rather than peak-instant); a true peak-anon sample is **#412**.
**Then we made the same mistake in the opposite direction.** Having established that peak
*overstates*, the docs (and a report to #604) leaned to "so this number is probably mostly cache."
That was a guess about *magnitude* dressed in a verified fact about *mechanism* — and an independent
probe killed it: a full solution build in the CI image with shared compilation off measured `peak
9457 MiB` / **`anon 7134 MiB`** / `file 421 MiB`. **Anon dominated.** So a 6g cap looks *unsafe*,
#570's "6g proved too tight" is the rule rather than an outlier, and **#406's premise ("if this
brings peak RSS well under 6 GiB, the whole budget loosens") is looking dead** — the 7134 MiB was
measured *with* shared compilation already off. The switches are still right (no persistent 7.8 GB
server between builds); the looser budget they were supposed to buy is not.
**What is NOT established:** there is still no pre-change baseline from this instrument — the 7.8 GB
`VBCSCompiler` figure was measured host-wide across concurrent jobs, not inside one job container —
and the anon figure above is one probe, not the `test` job. #412 covers the real A/B. What *is*
established: no persistent compiler server survives a build, and `migrations` is green with mysql
capped at 2g with swap disabled.
The lesson generalizes, and note it bit *this* change twice — once in the issue's premise and once
in our own instrument: this repo's CI perf work keeps stating numbers from plausibility rather than
measurement (see #390's "2–4min" apt-ffmpeg estimate; real 110s, and not load-bearing). Measuring
the wrong quantity precisely is the same failure wearing a lab coat.
**What this does NOT shrink**: `format`. `dotnet format` loads Roslyn in-process via
MSBuildWorkspace and never spawns `csc`, so its measured 3.95 GiB is untouched by any of this. Do
not size the `small` lane expecting otherwise.
**Not addressed here**: the 12–35 min queue waits (server-management#604) and the redundant
triple-build (#398).
@@ -0,0 +1,36 @@
---
key: ci.small-lane-git-only
title: '2026-07-20 — `runs-on: small` means git-only; the two `docker build` jobs move to `ubuntu-latest` (server-management#639)'
status: active
since: '2026-07-20'
supersedes: none
superseded-by: none
rule: '`runs-on: small` is defined by what a job does (git-only), not its usual runtime; the two `docker build` jobs (docker-build.yml, ci-image.yml) move to `ubuntu-latest` because their worst-case memory, not median runtime, was pinning the small lane''s per-slot cap.'
signals: 'CI lane definition, per-job memory cap, small lane widening, memory cap vs capacity, act setup-phase hang, docker build placement · paths: `.gitea/workflows/docker-build.yml`, `.gitea/workflows/ci-image.yml`, runner config · issues: server-management#639, #406, #604, #574'
mechanics: sum-of-caps rule (#406/#604); second jazz runner at `--cpu-shares=128`; `docs/ci-cd.md`
---
- **The `small` lane is defined by what a job *does*, not by how long it usually takes.** Both jobs
removed from it here were justified as small on a runtime argument that only held in the common case:
`docker-build.yml`'s `build` is a 1-second skip on PR runs (but a real image build on main/tags), and
`ci-image.yml`'s `build` was reasoned about as "docker-only, no toolchain needed — it *builds* the
toolchain", which is true and yet describes the single heaviest job in the lane. The lane's per-job
memory cap is set by its worst member, not its median, so both of these forced `--memory=10g`.
- **That cap, not a capacity decision, is what pinned the lane at one slot.** 10 GiB per slot on a
25 GiB host that also runs prod media permits exactly one — the sum-of-caps rule from #406/#604
(6 slots × 10 GiB on a 25 GiB host produced load 340 and 21 GiB of swap). So "widen the lane" and
"keep the heavy jobs" were never simultaneously available; the earlier note in the runner config had
parked the widening indefinitely behind moving the lane to a different host.
- **Fixing the cap dominates fixing the capacity.** With both builds on `ubuntu-latest`, `small` is a
checkout plus a `git diff`, cappable at 1 GiB, so it widened from 1 slot to **4 across two hosts
while committing less RAM to CI than the single slot did**. A second runner was added on jazz at
`--cpu-shares=128` — CI on a prod media host is only acceptable while it loses every scheduling
contest to the transcoders.
- **The symptom this fixes is not queue wait.** A saturated lane also wedges *dispatched* jobs in act's
setup phase: >10 min `in_progress`, **no log file written at all**, then failure, before Checkout
runs. That produced the standing "`decisions.md` is a known flake, just rerun it" belief — the rerun
works only because it lands after load clears, so a capacity problem read as a bug in the guard. A
job that fails with zero log output is evidence about the runner, not about the job.
- **#574's skip-task queueing does not return** by moving `build` back to `ubuntu-latest`:
`needs: [test, migrations]` means it cannot be dispatched until the jobs it would have queued behind
have already finished.
@@ -0,0 +1,54 @@
---
key: ci.ui-e2e-harness
title: '2026-07-25 — UI-E2E: headless Playwright flows in the existing `functional-e2e` job, browser baked into the CI image (#445)'
status: active
since: '2026-07-25'
supersedes: none
superseded-by: none
rule: 'The UI-interactive E2E flows run as headless Playwright specs (`web/e2e/*.spec.ts`, driven by `scripts/e2e-ui.sh`) in a **second step of the existing advisory `functional-e2e` job**, never their own job; the browser is `chromium-headless-shell` **baked into the CI toolchain image** (`docker/ci/Dockerfile`, `PLAYWRIGHT_VERSION` kept equal to `web/package.json`''s EXACT `@playwright/test` pin), never installed per run; specs are `serial` with `retries: 0` and assert only contracts the curl harness structurally cannot reach.'
signals: 'UI-E2E, Playwright headless, boot-gate/Setup/Login flows, chromium-headless-shell, PLAYWRIGHT_BROWSERS_PATH=/ms-playwright, browser baked not installed, e2e-ui.sh, vitest `e2e/**` exclude, port 8410, no retries · paths: `scripts/e2e-ui.sh`, `web/playwright.config.ts`, `web/e2e/boot-gate.spec.ts`, `docker/ci/Dockerfile`, `.gitea/workflows/docker-build.yml`, `docs/e2e-local.md` · issues: #445, #363, #299'
mechanics: '`scripts/e2e-ui.sh`; `docs/e2e-local.md` -> "UI-E2E harness"; `docs/ci-cd.md` -> "CI toolchain image"'
---
Completes the last #299/#363 follow-up — the UI-interactive flows both prior records deferred.
**Only assert what curl structurally cannot.** The curl harness already covers the auth *HTTP* contracts
(setup-claim 200/409, login 401/200, CSRF 403, stamp rotation); re-asserting them through a browser buys
nothing but flake surface. Scope is the four things curl cannot express: client-side form validation
(the Setup confirm-password gate is pure React state, makes no request), `AuthGate`'s *rendered* states,
the session cookie authenticating the **SPA's own** `/api` XHRs (curl proves the cookie works for curl,
not that the app sends it), and sign-out via `UserMenu`. **This scoping rule is the durable part** —
extend the browser suite only when a contract fails that test.
**Baked browser, not a per-run install.** `chromium-headless-shell` lands in `/ms-playwright` at image
build time, so the job installs nothing — the "jobs install nothing at run time" rule of the shared CI
toolchain image (#390, which has no standalone record — see `ci.runner-placement` and `docs/ci-cd.md`).
Measured on the real base: headless shell **267M** vs full `chromium` **656M** (+171M compressed pull),
and `chromium.launch()` resolves to the shell anyway; the tradeoff is that a *headed* run in the image
fails. Verified rather than assumed: Chromium runs as root in-container with **no** `--no-sandbox`
opt-out. The browser revision is tied to the npm package version, so that pin is EXACT and `e2e-ui.sh`
guards drift by **launching** a browser — not by path, since `executablePath()` reports the
full-chromium path such an image lacks.
**Why that job and not a new one** (`docs/ci-cd.md` has the detail): its `npm ci` + Release build are
already done, so a separate job would duplicate the dominant cost to add ~5s of browser work. The UI step
boots its **own** fresh instance on port 8410 — the first spec asserts the one-shot Setup gate the curl
step has already claimed.
**`serial` + `retries: 0`, and vitest must not collect these files.** Server state is shared and partly
one-shot (the setup-claim), so specs are serial/single-worker; each `test` still gets its own browser
context, which gives the login specs a signed-out browser without a logout dance. Retries are 0 even in
CI — a retry lets a flaky flow merge looking green. Coupling worth knowing: vitest's default `include`
glob would run `web/e2e/*.spec.ts` under jsdom, so `vite.config.ts` excludes `e2e/**` by spreading
`configDefaults.exclude` — not by narrowing `include` to `src/**`, which would silently stop collecting
the real vitest test under `web/scripts/`.
**Lifecycle correctness in `scripts/e2e-ui.sh`** — all four found by adversarial review, **none by a
passing run**; that is the transferable lesson (green runs never exercise the failure/interrupt paths).
(a) Install the cleanup trap *before* boot and have `e2e-local.sh` publish its PID to an opt-in
`ETV_PIDFILE` as it forks, so a mid-boot signal can still reap the server — a "kill whatever LISTENS on
the port" fallback is wrong twice over: it needs `lsof` (absent from the CI image) and it *infers*
ownership instead of proving it. (b) Re-raise signals, so a cancelled run cannot exit 0. (c) Never
`if ! cmd; then status=$?` — under `!` bash sets `$?` to the logical negation, so it reads 0 and a
FAILING run exits 0, silently passing CI. (d) `exec` the app inside the backgrounded subshell, or `$!`
is the SUBSHELL on bash 3.2 (stock macOS) and every PID-based kill targets the wrong process.
@@ -0,0 +1,18 @@
---
key: ci.verify-locally-ci-confirms
title: 2026-07-21 — Build and verify locally, then trust it; CI confirms (#542)
status: active
since: '2026-07-21'
supersedes: none
superseded-by: none
rule: Treat the local build/verify/review pass as the decision point and CI as confirmation — don't idle waiting on a run you have no reason to doubt.
signals: '~9 min warm CI · docs-only PR seconds · CI VM 127 · commit status watch · local gate first · paths: n/a · issues: #542, #416'
mechanics: '`GET /api/v1/repos/timothy/ersatztv/commits/{sha}/status`; CI on VM 127.'
---
A warm full CI run takes roughly **9 minutes**; a docs-only PR completes in seconds since ersatztv#416's
docs-only skip. Neither duration justifies blocking on the run when the same gates already passed
locally.
Watch the run by commit status rather than by polling the UI, and continue working. CI *confirms* the
local verdict; it is not the first line of defence.
@@ -0,0 +1,18 @@
---
key: ci.web-test-per-test-timeouts
title: 2026-07-21 — Heavy-render web tests need explicit per-test vitest timeouts on the CI VM (#542)
status: active
since: '2026-07-21'
supersedes: none
superseded-by: none
rule: Give heavy-render web tests an explicit per-test vitest timeout (e.g. 15s); never raise the global default to fix one slow test.
signals: 'vitest timeout · 5s default · 100+ item grid · CI VM slower than local · run 686 · paths: `web/` tests · issues: #542'
mechanics: per-test timeout argument in the vitest test declaration.
---
The CI VM is materially slower than a dev Mac for render-heavy work. A web test rendering a 100+ item
grid runs in about 1s locally but hit the **5s** vitest default on the CI VM (run 686) and went red
for no code reason.
Bump the timeout on the specific test. Raising the global default hides genuine hangs across the whole
suite in exchange for fixing one known-slow case.
@@ -0,0 +1,51 @@
---
key: concurrency.diff-scalar-fanout
title: '2026-07-11 — #253 PR3: Diff + Scalar concurrency fan-out (Collection / Playout×2 / MultiCollection / RerunCollection)'
status: active
since: '2026-07-11'
supersedes: none
superseded-by: none
rule: The frozen Block optimistic-concurrency recipe (api-conventions §7a) fans out to Collection/Playout×2/MultiCollection/RerunCollection, keeping a guard-returned `PreconditionFailedError` out of any handler's generic `catch(Exception)`→422 mapping, and preserving each aggregate's existing `SaveChangesAsync() > 0` gate semantics under the new unconditional `Version++`.
signals: 'concurrency fan-out, PreconditionFailedError, SaveChangesWithConcurrencyGuard · paths: `api-conventions.md` §7a · issues: #253, #269, #232, #197'
mechanics: '`RootWriterForceVersionTests`-adjacent handler tests; `api-conventions.md` §7a'
---
**Context.** PR3 of the #253 optimistic-concurrency arc fans the frozen Block recipe (api-conventions §7a)
across the five Diff/Scalar aggregates. Three judgment calls beyond the mechanical copy:
**H1 — Playout `catch(Exception)`→422.** The two Playout replace handlers wrap `SaveChangesAsync` in a
`catch(Exception)` that maps any exception to a bare `BaseError` (→ 422). Rather than let the guard's
concurrency failure be reshaped into a 422, the guarded save (`SaveChangesWithConcurrencyGuard`) returns a
`PreconditionFailedError` **Left as a value** and the handler returns it before the post-commit block —
so it never reaches the catch. Proven by the pre-check-subtype tests (a `.Apply` flatten would fail
`ShouldBeOfType<PreconditionFailedError>`) plus a non-vacuous Playout racing-save test.
**M2 — the `SaveChangesAsync() > 0` gates.** RerunCollection and Collection-custom-order run their
playout-refresh **unconditionally** on a successful save (the unconditional `Version++` makes the old gate
always-true; the "nothing changed" branch is dead). MultiCollection is the exception: it saved the name
first specifically so a name-only change wouldn't rebuild playouts, so we bump `Version` on that **first**
save and leave the **second** (items) save's `> 0` gate intact — a name-only edit still bumps + rotates the
ETag but does not rebuild. Enumerating every behavior the gate provided before reworking it (the #232 lesson).
**Sibling-writer scope (deferred).** §7a's config-only boundary says every writer of an aggregate's
editor-visible config bumps `Version`. PR3 ships the five primary endpoints' full contract + the one
design-named bulk writer (`UpdateDefaultDecoHandler`, safe via `.SetProperty`). It **defers** the other
same-root non-bulk config writers (`UpdateCollectionHandler`, `RemoveItemsFromCollectionHandler`,
`UpdatePlayoutHandler`, the `ScheduleFile` handlers) and the repository-mediated `Add*ToCollection` family.
Rationale: the primary endpoints' own bump+guard fully cover the two-tab lost-update the issue targets;
the deferred writers only affect cross-editor ETag *rotation*, and adding an unconditional bump to a handler
that uses plain `SaveChangesAsync` (not the guard) converts a latent lost-update into a **new 500**
(`DbUpdateConcurrencyException`) — doing it safely needs a uniform guard+bump+412 pass of its own, better
done with the #197 contract work. Tracked as a follow-up issue.
**VMs.** `Playout.Version` surfaces via `PlayoutNameViewModel` (required arg); the three collection VMs
(`MediaCollectionViewModel`, `MultiCollectionViewModel`, `RerunCollectionViewModel`) carry `int Version = 0`
(defaulted — 0 for the selection-placeholder constructions, real value from the Mapper projection).
Header-only via ETag, never echoed in a response body (the Block precedent).
**Post-merge addendum (PR3 review, #269).** Activating the `Version` token means EF guards *every* root
UPDATE, so non-participating root-scalar writers that use plain `SaveChangesAsync` (playout settings /
schedule-file / on-demand-checkpoint, collection name) would 500 on a concurrent bump. The realistic
UPDATE writers were fixed in-PR with `ConcurrencyExtensions.SaveChangesForcingVersion` (Phase-1
force-write on conflict: adopt the stored token, retry, never revert the concurrent bump). The deferral
above is re-scoped to the DELETE handlers + repository `Add*` writers only (→ #269).
@@ -0,0 +1,63 @@
---
key: concurrency.etag-rotation-completion
title: 2026-07-12 — Cross-editor ETag rotation completed for Collection/Playout config siblings (#269)
status: active
since: '2026-07-12'
supersedes: none
superseded-by: none
rule: Every handler that mutates a versioned root's editor-visible config state must bump `Version` (rotating the ETag) with no per-aggregate carve-outs, short-circuiting on a genuine no-op before the bump so idempotent re-submits don't fire spurious rebuild fan-out; `SaveChangesForcingVersion` rebases the retry (stored + pending delta), never adopts the stored token verbatim.
signals: 'ETag rotation, no-op idempotence, SaveChangesForcingVersion rebase · paths: `CollectionEtagRotationTests`, `PlayoutScheduleFileEtagRotationTests` · issues: #269, #253, #197, #308'
mechanics: '`docs/api-conventions.md` §7a; `CollectionEtagRotationTests`, `PlayoutScheduleFileEtagRotationTests`'
---
The #253 optimistic-concurrency contract (§7a) had a documented tail: the non-If-Match config-sibling
writers of a versioned root mutated editor-visible state **without** bumping `Version`, so editing through
them did not rotate an open editor's ETag (a cross-editor invalidation gap — never a lost-update or a 500,
which the primary endpoints' bump+guard already cover). #269's first slice (PR #302) removed the 500 exposure
by routing those writers through `SaveChangesForcingVersion`; this slice completes the **rotation**.
Handlers now bumping `Version` (all via `SaveChangesForcingVersion`, since they take no `If-Match` → a
concurrent replace-all bump force-writes, never 412/500): the Collection `Add*ToCollection` family (11
handlers) and `RemoveItemsFromCollectionHandler` bump `Collection.Version`; `UpdateCollectionHandler`
(name/flag), `UpdatePlayoutHandler` (`DailyRebuildTime`), and the three `ScheduleFile` writers
(`UpdateSequential`/`UpdateScripted`/`UpdateExternalJsonPlayout`) — which already force-wrote — now also bump.
Decisions frozen (ratified with Fable before implementation, feeding the #197 contract freeze):
- **Rotate on every editor-visible config change, no per-aggregate carve-outs.** §7a's config-only boundary
("every mutating handler of a root's editor-visible config bumps `Version`") already held for Playlist
`Add*`/schedule item writers; the Collection/Playout siblings were an inconsistency, not a judgment call. A
membership add rotating an open custom-order editor's ETag (→ 412 → reload) is correct: its list is genuinely
stale. Blast radius of the aggressive-but-safe rotation is a reload, never data loss.
- **No-op idempotence — the trap Fable caught.** These handlers gate their reindex/`BuildPlayout` fan-out on
`SaveChanges() > 0`. An *unconditional* bump makes that gate always-true, so an idempotent re-add / same-value
re-submit would fire spurious rebuilds across every playout using the aggregate. Fix: short-circuit a genuine
no-op **before** the bump — the Add handlers by an explicit membership check (which also fixes the latent
duplicate-`CollectionItem` insert on a *sequential* re-add; two *concurrent* same-item adds can still both
pass the check and the loser 500s on the composite-PK unique violation — `SaveChangesForcingVersion` catches
only `DbUpdateConcurrencyException`, not `DbUpdateException`. That race is narrow and pre-existing, deferred
to #308), the scalar writers (`UpdateCollection`, `UpdatePlayout`, the
three `ScheduleFile` writers) by `ChangeTracker.HasChanges()`. A no-op neither bumps nor rebuilds nor rotates
the ETag — which is itself correct (nothing changed).
- **The `Add*ToCollection` family is not repository-mediated.** #269's original framing ("repository-mediated,
shared with the scanner hot path") was wrong: `IMediaCollectionRepository` is read-only; each handler loads
the `Collection` into its own `dbContext` and writes directly. So the rotation bump is a pure API-layer
concern and the scanner's separate membership-write path is untouched — a background scan does **not** rotate
the editor ETag (correct: background indexing is not an editor action).
- **Force-write rebases the bump, never adopts the stored token verbatim (Codex review of this PR).**
`SaveChangesForcingVersion` originally resolved a conflict by setting current=original=stored — which
silently *discarded* a sibling's pending `Version++` when a versioned writer committed in its load→save
window (sibling loads 1, bumps to pending 2, concurrent PUT commits 2 → retry wrote 2, so the concurrent
writer's ETag "2" stayed valid and the rotation was lost under exactly the race it exists for). Fixed in
this PR (it affects all 25 bumpers routed through the helper, including the pre-existing playlist/schedule
ones): the retry now rebases — original = stored, current = stored + (pending current − pending original) —
so a bumper lands at stored+1 and a non-bumper (delta 0, e.g. `ErasePlayoutHistory`) adopts stored unchanged.
The race tests assert the post-race Version (3, not 2) and fail against the verbatim-adopt implementation.
- **No new status codes.** These endpoints take no `If-Match` and force-write, so they never 412; no
`[ProducesResponseType(...412...)]` and no OpenAPI regen (response types unchanged). Only §7a prose changes.
Tests: `CollectionEtagRotationTests` (rotation + no-op-without-bump-or-rebuild + force-write-past-concurrent-bump
for Add/Remove/Update) and `PlayoutScheduleFileEtagRotationTests` (ScheduleFile rotation + no-op-without-refresh),
the no-op guard proven non-vacuous by inverting the membership check. The `#265` RFC-7232 If-Match parser
refinement (valid-but-non-matching/weak/list → 412 not 400) is a **separate** PR (disjoint surface: the shared
parser + `CheckVersion`, not the handler saves). Refs #253 #269 #197 · `api-conventions.md` §7a.
@@ -0,0 +1,57 @@
---
key: concurrency.force-write-non-ifmatch
title: 2026-07-12 (#269 — non-If-Match root writers force-write past a concurrent Version bump)
status: active
since: '2026-07-12'
supersedes: none
superseded-by: none
rule: Any handler that leaves a versioned root `Modified` or `Deleted` but takes no `If-Match` (deletes, item add/remove bumpers, scalar-config writers) must save through `ConcurrencyExtensions.SaveChangesForcingVersion` — force-write past a concurrent `Version` bump rather than throw an unhandled `DbUpdateConcurrencyException` (500).
signals: 'force-write, non-If-Match writers, DbUpdateConcurrencyException · paths: `ConcurrencyExtensions.SaveChangesForcingVersion` · issues: #269, #253, #302, #197'
mechanics: '`RootWriterForceVersionTests`; `docs/api-conventions.md` §7a'
---
**Routing the aggregate delete handlers + `UpdateProgramScheduleHandler` through `SaveChangesForcingVersion`.**
Once #253 made each replace-all root's `Version` an `IsConcurrencyToken`, EF started guarding *every*
UPDATE **and DELETE** of that row with `WHERE Version=@orig` — so any writer that is not part of the
If-Match contract but still saves via plain `SaveChangesAsync` throws an unhandled
`DbUpdateConcurrencyException`→**500** if a replace-all editor bumps the row in its narrow load→save window.
PR3 already force-wrote the exposed *UPDATE* siblings (Playout settings/`ScheduleFile`/checkpoint,
`UpdateCollectionHandler`); a completeness sweep for #269 found the gap was wider than reported —
**18 writers** in total, all on plain `SaveChangesAsync`. **The correct exposure filter is "any handler
that leaves a versioned root `Modified` or `Deleted`", NOT just `Version`-bumpers + deletes** — an early
sweep used the narrower filter and a review of PR #302 caught what it missed (`ErasePlayoutHistory` below):
- the **nine versioned-root delete handlers** (`DeletePlayout`/`DeleteCollection`/`DeleteMultiCollection`/
`DeleteRerunCollection`/`DeletePlaylist`/`DeleteBlock`/`DeleteTemplate`/`DeleteDecoTemplate`/
`DeleteProgramSchedule`) — a DELETE is now token-guarded too;
- `UpdateProgramScheduleHandler` (bumps `Version` then saved plainly — the ProgramSchedule case PR3 only
*suspected*);
- the **seven item add/remove bumpers** that PR2 wired to bump their root but left on plain save —
`AddProgramScheduleItem`/`DeleteProgramScheduleItem` and the five
`Add{Items,Movie,Show,Season,Episode}ToPlaylist` handlers;
- **`ErasePlayoutHistoryHandler`** — modifies Playout root **scalars** (`Seed`/`Anchor`/`OnDemandCheckpoint`)
**without** bumping `Version`, inside an explicit transaction with no try/catch → the one the bumper-only
filter missed; reachable via `POST /api/playouts/{id}/erase-items-and-history`.
All now save through `ConcurrencyExtensions.SaveChangesForcingVersion`.
**Two deliberate boundaries (documented, not gaps):** (1) the background build/time-shift Playout-scalar
writers (`BuildPlayoutHandler` via `PlayoutBuilder`'s `Anchor`/`Seed`; `PlayoutTimeShifter`'s
`OnDemandCheckpoint`) are token-guarded too but **intentionally left on plain save** — they never surface a
request-path 500 (`BuildPlayoutHandler` catches → a build-failure `BaseError`; `PlayoutTimeShifter` runs only
via the background worker), and force-writing would be *wrong*: a concurrent config edit that bumped
`Version` also enqueues a rebuild, so failing the in-flight build and letting the rebuild redo it with fresh
config is correct (force-writing would persist output built from stale config). (2)
Item-add force-write can leave a duplicate/gap `Index` (accepted Phase-1 effect): the handler computes the
new index from its stale child list, so if a concurrent replace-all grew the list the item lands at a
now-colliding index (no unique constraint on `PlaylistItem.Index`/`ProgramScheduleItem.Index`) — non-
corrupting, self-correcting on the next edit, still strictly better than the pre-#269 500; a
reload-and-recompute-on-conflict refinement is a candidate for #197. Decision:
**force-write, not 412** — these endpoints take no `If-Match` (an unconditional DELETE/settings-edit should
win over a concurrent editor), matching the Phase-1 force-write posture. A delete has no ETag to rotate, so
it needs only the force-write, not a `Version` bump. A genuine row-deletion race (two concurrent deletes)
still surfaces as a `DbUpdateConcurrencyException` — accepted (rare, non-corrupting, the resource is already
gone). **Still deferred to #197:** *cross-editor ETag rotation* for the non-bumping config siblings and the
scanner-shared `Add*ToCollection` family (they don't 500 — they insert children / `ExecuteDelete`, neither
of which is token-guarded — they just don't rotate an open editor's ETag). Non-vacuously tested by racing a
bump *through the handler* via a pre-tracked context (`RootWriterForceVersionTests`), plus an explicit
negative control proving the plain-save path throws.
@@ -0,0 +1,42 @@
---
key: concurrency.idempotent-concurrent-add
title: '2026-07-18 — Concurrent same-item add is idempotent, not a 500: catch the unique-violation per provider (#308)'
status: active
since: '2026-07-18'
supersedes: none
superseded-by: none
rule: A concurrent duplicate `Add*ToCollection` that loses the race on the composite-key unique constraint is treated as an idempotent no-op (skip the reindex/rebuild fan-out), not a 500 — detected via a provider-specific `TvContext.IsUniqueConstraintViolation` delegate defaulting to "no".
signals: 'idempotent add, unique-constraint violation, provider error classifier · paths: `TvContext.IsUniqueConstraintViolation`, `SqliteErrorClassifier`, `MySqlErrorClassifier` · issues: #308, #269, #253'
mechanics: '`docs/api-conventions.md` §7a ("Idempotent insert under concurrency"); `ConcurrencyExtensions.TrySaveChangesForcingVersion`'
---
**Decision.** The `Add*ToCollection` family's membership pre-check (#269) is not atomic with the insert, so two
*concurrent* adds of the same item both observe it absent and both stage the `CollectionItem` composite key; the
loser's `SaveChangesForcingVersion` threw a unique/PK-violation `DbUpdateException` (SQLite error 19 / MySQL 1062)
it did not catch → **500**. We now treat that loss as an **idempotent no-op**, not an error: the desired end state
(the item is a member) already holds because the racing winner inserted it, rotated the ETag, and fanned out the
rebuild.
**Mechanism.** A `bool`-returning sibling `ConcurrencyExtensions.TrySaveChangesForcingVersion` wraps
`SaveChangesForcingVersion` and catches *only* a classified unique/PK violation, returning `false`. The 10
single-item handlers return `Unit.Default` on `false` (skip the reindex/rebuild fan-out — the winner did it). The
bulk `AddItemsToCollection` handler cannot no-op — that would silently drop the non-colliding items when a batch
partially overlaps a concurrent add — so it **retries** on a fresh context against recomputed membership (bounded
loop; the common no-collision path runs once).
**Provider seam.** Detection is provider-specific but the Application layer must not reference the provider
packages, so it follows the existing `TvContext` static-provider-config idiom (`IsSqlite`, `LastInsertedRowId`): a
settable `TvContext.IsUniqueConstraintViolation` delegate, pointed at `SqliteErrorClassifier` (extended codes 1555
PK / 2067 UNIQUE) or `MySqlErrorClassifier` (`Number == 1062`) from `Startup.cs`, defaulting to a conservative
"no" so an unwired provider never silently swallows a save failure. Chosen over DI to avoid threading a new
service through 11 handlers, and because the provider discriminator already lives as a `TvContext` static.
**Scope boundary.** `Add*ToPlaylist` is deliberately **untouched**: `PlaylistItem` has its own identity PK and no
unique index on `(PlaylistId, MediaItemId)` — a playlist may legitimately contain the same item more than once, so
there is no constraint to violate.
**Tests.** A negative-control anchor proves the race genuinely throws a classified `DbUpdateException`; the fix's
end-to-end handler tests reproduce a *real cross-connection* race via a shared-cache SQLite harness + a
`SavingChanges` interceptor that inserts the conflicting row on another connection mid-save (the single-connection
in-memory fixture cannot). Every fix-dependent test was verified to fail with the catch disabled. Mechanics:
`api-conventions.md` §7a ("Idempotent insert under concurrency"). Refs #308 #269 #253.
@@ -0,0 +1,43 @@
---
key: concurrency.ifmatch-rfc7232
title: '2026-07-12 — If-Match evaluates per RFC 7232: valid-but-non-matching → 412, only grammar violations → 400 (#265)'
status: active
since: '2026-07-12'
supersedes: none
superseded-by: none
rule: '`ConcurrencyHeaders.ParseIfMatch` is a real RFC 7232 entity-tag/list parser: a syntactically-valid tag that doesn''t strong-match (weak/empty/non-canonical/out-of-range/list) returns 412, and only a genuine grammar violation returns 400.'
signals: 'RFC 7232, If-Match parsing, strong-tag matching · paths: `ConcurrencyHeaders`, `IfMatchCondition`, `VersionedAggregateExtensions.CheckVersion` · issues: #265, #253, #197'
mechanics: '`docs/api-conventions.md` §7a'
---
Closing the last #253 concurrency-contract piece. `ConcurrencyHeaders.ParseIfMatch` previously classified
**any** non-canonical/weak/list `If-Match` value as `Malformed → 400` (a deliberate fail-safe: reject rather
than risk a stale write, deferred from the reference-aggregate PR). That was RFC-incorrect. Per **RFC 7232
§3.1**, a syntactically-valid entity-tag that simply doesn't strong-match must return **412 Precondition
Failed**, and **400** is reserved for a genuine grammar violation.
**What changed.** The parser is now a real RFC 7232 entity-tag/list parser (`If-Match = "*" / 1#entity-tag`).
It **scans** the list (it does *not* `Split(',')` — a comma is a valid `etagc`, so it can appear inside a quoted
opaque-tag: `"3,5"` is ONE tag, and a comma separates members only outside the quotes), trims only RFC OWS
(SP/HTAB — not `string.Trim()`, which would strip NBSP and let `" * "` masquerade as the `*` force-write),
validates each member as `[ "W/" ] DQUOTE *etagc DQUOTE`, and collects the versions of the **strong** members
whose opaque text is the exact canonical decimal we emit. Outcomes:
- **weak** (`W/"3"`), **empty** (`""`), **non-canonical** (`"03"`, `"3.0"`, `"+3"`), **out-of-range**
(`"99999999999999999999"`) → valid tags that contribute no version → **412** (a `Version`-kind with an
*empty* candidate set is a guaranteed no-match).
- **list** (`"3", "5"`) → any strong member that matches proceeds; weak/non-canonical members drop out.
- genuine grammar violations (unquoted `3`, SP inside the tag `" 3 "`, unterminated `"3`, `garbage`, a
separator-only header) → **400**.
**Type reshape.** `IfMatchCondition.ExpectedVersion : Option<int>` → `ExpectedVersions : Option<Seq<int>>`
(`None` = force-write; `Some(set)` = strong-match against the set, empty ⇒ always 412), and
`VersionedAggregateExtensions.CheckVersion(Option<int>)` → `CheckVersion(Option<Seq<int>>)` = set membership.
This threads through all 10 replace/update commands + handlers + request mappers + 9 controllers uniformly; no
wire-contract change (400 and 412 were already declared on every PUT; the field is header-derived and internal,
so no OpenAPI/DTO change).
*Why now, not #197:* it is the shared parser all replace-all PUTs copy, and the 412-vs-404 ordering the issue
worried about was already correct (each handler loads/validates → 404 before `CheckVersion`). *Why safe:* the
first-party SPA only ever echoes the single canonical strong tag we emit, so no shipped client changes behavior;
the change only makes a hand-written/tooling `If-Match` get the RFC-correct status. Docs: `api-conventions.md`
§7a. Refs #265 #253 #197.
@@ -0,0 +1,42 @@
---
key: concurrency.replace-all-contract
title: '2026-07-11 — Optimistic-concurrency contract for replace-all PUTs (#253 PR1: infra + Block reference)'
status: active
since: '2026-07-11'
supersedes: none
superseded-by: none
rule: Replace-all aggregate PUTs carry a uniform plain `int Version` concurrency token (EF `.IsConcurrencyToken()`), checked pre-save and enforced by the EF UPDATE guard, returning 412 (not 409) on a stale `If-Match`.
signals: 'ETag, If-Match, Version token, 412 Precondition Failed · paths: `api-conventions.md` §7a, `ConcurrencyHeaders`, `ApiResults.ToErrorResult` · issues: #253, #197, #265, #259'
mechanics: '`docs/api-conventions.md` §7a; `SaveChangesWithConcurrencyGuard`'
---
Replace-all aggregate PUTs had **no** optimistic concurrency — a stale second tab silently overwrote a
fresher edit (200, no signal) across ~10 aggregate surfaces. PR1 lands the shared contract on the Block
reference aggregate; PRs 2–4 fan it out. The full ratified design + independent-review hardening is
[#253#issuecomment-8472](http://192.168.1.95:3000/timothy/ersatztv/issues/253#issuecomment-8472);
the mechanics live in `api-conventions.md` §7a. Decisions frozen here:
- **Token = uniform plain `int Version`** on each root implementing `IVersionedAggregate`, EF-mapped
`.IsConcurrencyToken()`, one dual-provider migration (`AddAggregateVersions`, `defaultValue: 0`). **Not**
a reused `DateUpdated` (tick-collision, SQLite TEXT precision, couples UI cosmetics to correctness) and
**not** a MySQL-native rowversion (portability over provider-native).
- **412 Precondition Failed**, not 409 — 409 stays the §3a EntityLocker "build in progress" guard;
distinct codes → distinct SPA UX. New `PreconditionFailedError : BaseError` → 412 in `ApiResults.ToErrorResult`.
- **Pre-check AND EF token both required.** The handler pre-check (a standalone `Either` introduced AFTER
the validation pipeline — never via `Apply`, which `Join()`-flattens the subtype to 422) gives a clean
412; the unconditional `root.Version++` + `IsConcurrencyToken` UPDATE-guard + a `SaveChangesWithConcurrencyGuard`
backstop closes the residual load→save TOCTOU (`DbUpdateConcurrencyException` → 412).
- **Unconditional bump** (not "only when a child changed"): EF writes the root row only when a scalar
differs, so a no-op PUT-back must still bump to fire the token and rotate every other client's ETag.
- **Config-only aggregate boundary**: every mutating handler of a root's *editor-visible config state*
bumps `Version` (incl. bulk `ExecuteUpdate/Delete` writers via `.SetProperty`); regenerated build output
(playout items/history) is outside the token — neither bumped nor guarded.
- **Header-only ETag**, strong tag of the decimal `Version`; parsed/emitted by `ConcurrencyHeaders`. The
successful PUT returns the new ETag (else a same-tab second save 412s against its own write).
- **Phasing**: Phase 1 (this arc) = a missing `If-Match` force-writes (zero breakage) while the SPA starts
echoing; Phase 2 (a later PR) flips missing → **428** after every editor echoes and one release soaks.
`If-Match: *` stays the scripted force-write escape hatch.
- **Child stable-identity is OUT of #253** (the "moved fill-group item inherits the wrong slot's state"
concern on the positional reconcile) — root-anchored versioning is orthogonal to it; split to **#259**.
- **If-Match status semantics** (non-canonical/weak/list → 400) are fail-safe; the stricter RFC 7232
"valid-but-non-matching → 412" refinement is deferred to #197 (**#265**).
@@ -0,0 +1,33 @@
---
key: concurrency.schedule-item-child-identity
title: '2026-07-11 — Stable child identity for schedule-item replace (#259, split from #252/#253)'
status: active
since: '2026-07-11'
supersedes: none
superseded-by: none
rule: '`PUT /api/schedules/{id}/items` reconciles by an optional round-tripped child `Id` (null/absent/0 ⇒ new item), never by array position, so fill-group/shuffle state follows the logical item across reorders; an unknown or duplicate id is rejected 422 (checked after the §7a `CheckVersion`, so 412 precedes 422).'
signals: 'stable child identity, schedule-item replace, id-based reconcile · paths: `api-conventions.md` §7c · issues: #259, #252, #253, #197'
mechanics: '`docs/api-conventions.md` §7c'
---
`PUT /api/schedules/{id}/items` now reconciles by an optional round-tripped child id, not by array
position, so an item's persisted fill-group/shuffle state (`PlayoutScheduleItemFillGroupIndex`, FK
`OnDelete(Cascade)`) follows the logical item across reorders/inserts instead of being inherited by
whatever previously held its new slot. Contract + rules in **api-conventions §7c**. Key decisions:
- **`ScheduleItemRequest.Id` (`int?`)**: null/absent/`0` ⇒ new item (controller normalizes `0`→null so the
handler is two-state). Any id present ⇒ id-based reconcile; a fully id-less payload keeps the verbatim
positional fallback (legacy; retires with the §7a Phase-2 `If-Match`→428 flip).
- **Unknown or duplicate id ⇒ 422, nothing persisted**; the guards live in the handler **after** §7a
`CheckVersion`, so **412 precedes 422** — a client that is both version-stale and id-stale gets the reload
signal, not a payload-bug signal. Rationale for reject-not-insert on an unknown id: under Phase-1
force-write a stale id is a live lost-update signal, so silently inserting-as-new would duplicate the item
and return a different id than the client sent (the exact class §7a exists to surface). This is also the
correct #197 posture — never honor an unrecognized identifier.
- **Scope = schedule items only.** Blocks/templates/deco-templates/playlists stay positional: their children
are stateless config rows (no FK'd state to misattribute; #3/#4 have no GET child id). Child ids are added
only where a child row anchors server-side state; the contract can be retrofitted per-endpoint later
(field stays optional) — so this is not #197 ossification pressure.
- **TPT subtype change at a matched id** stays delete+insert (EF can't retype in place); state resets and a
new id is returned, so the SPA must re-seed item state from the PUT response (a stale id on a second save
now 422s).
@@ -0,0 +1,24 @@
---
key: docs.convention-docs-session-start
title: 2026-07-07 — Convention docs read at session start, updated in-PR
status: active
since: '2026-07-07'
supersedes: none
superseded-by: none
rule: 'Docs-first, not source-first: conventions (api-conventions, spa-conventions, e2e-local, blazor-route-parity, domain-model, decisions, README) are read from docs, not reverse-engineered from code, via `docs/README.md`''s task-signal map — only the sections it points to for the task at hand, not the whole set. Each doc is updated in the same PR that changes what it documents, replacing deferred/follow-up doc updates.'
signals: '`docs/README.md` index, `ApiControllerSecurityTests` drift · paths: `docs/README.md`, `docs/api-conventions.md` §6 · issues: #184, #185. Session-start *reading order* is now the task-signal map — see `startup.parallel-orientation`.'
mechanics: '`docs/README.md`'
---
`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.
@@ -0,0 +1,55 @@
---
key: docs.decision-lifecycle
title: 2026-07-21 — Decision records carry a lifecycle schema, validated by a script; append-only-by-diff is retired (#521)
status: active
since: '2026-07-21'
supersedes: docs.append-only-guard@2026-07-12
superseded-by: none
rule: 'every decision `##` record (active or archived) carries a 5-field metadata block (`key`, `status`, `since`, `supersedes`, `superseded-by`) checked by `scripts/decisions_validate.py`; a record is never deleted or line-edited to reverse a call — it is moved to `docs/decisions/archive/` with `status: superseded`/`retired` and a reciprocal `superseded-by`/`supersedes` key pair to its replacement.'
signals: 'decision lifecycle, supersession, archive · paths: `scripts/decisions_lib.py`, `scripts/decisions_validate.py`, `scripts/build_decisions_catalog.py`, `docs/decisions/archive/` · issues: #520, #521'
mechanics: '`scripts/decisions_validate.py` (`PYTHONPATH=. python3 scripts/decisions_validate.py`); catalog regen `scripts/build_decisions_catalog.py`; `docs/decisions/migration-map.md`'
---
**This replaces `docs.append-only-guard` (ersatztv#303 H9), not just extends it.** The old guard treated
`docs/decisions.md` as a single append-only text file: any line-delete was blocked by a Husky
`commit-msg` hook + CI job, and a "supersession" was a hand-written `> **Superseded …**` banner
prepended to the old entry plus an `(superseded)` Index tag — prose conventions an agent had to
remember and apply correctly by hand, with no machine check that the banner, the tag, and the new
entry's back-reference actually agreed with each other. That is exactly the failure mode this arc
(#520/#521) set out to fix: state should be derived and checked, not asserted in prose (the same
throughline as #303 H6's `## Done-when`).
**The new model.** Every decision record — whether still in force or not — gets a `key` (dotted,
stable identity independent of its heading text or which file it lives in), a `status`
(`active`/`superseded`/`retired`/`legacy-unmigrated` for anything not yet carrying the schema), a
`since` date, and a reciprocal `supersedes`/`superseded-by` pair of keys. `scripts/decisions_lib.py`
parses the schema; `scripts/decisions_validate.py` enforces: at most one `active` record per key;
`superseded`/`retired` records MUST live under `docs/decisions/archive/`, never in an active file;
an `active` record MUST NOT live under `archive/`; every `supersedes`/`superseded-by` key must
resolve to a record that actually exists (active or archived); and — replacing the append-only
guard's line-delete detection — a record's rationale prose (the schema strips only the contiguous
metadata block before comparing) must not change between a PR's base and head unless the commit
carries the rationale-edit marker (`Decisions-Edit: yes`, a git trailer since #609 — see
`ci.decisions-edit-trailer`), and a record that disappears from the active set without a matching
archive copy is flagged as an unlogged removal. `scripts/build_decisions_catalog.py` regenerates
`docs/decisions/README.md`, a compact router listing only `active` records by key, so an agent
doesn't have to read the full chronological log (or the archive) to find the current rule.
**Why this is a strictly better enforcement of the same spirit, not a loosening.** The old guard's
actual invariant was never "no line may change" — it was "history is never silently rewritten to
erase *why we changed our mind*." A hand-prepended banner satisfied that invariant by convention; a
validator that checks reciprocal keys, archive placement, and rationale-prose stability satisfies it
by construction, and additionally catches classes of mistake the old guard could not (a supersession
banner added without moving the entry out of the active read-path; a `superseded-by` reference to a
key that doesn't exist; two `active` records quietly claiming the same key after a copy-paste).
The rationale-edit marker is kept, narrowed to its one remaining legitimate use — an actual
rationale-prose edit (fixing a factual error) — since routine lifecycle moves (a status flip, an
archive relocation) are now marker-free and machine-checked instead.
**This PR is the first to exercise the new regime, on itself.** `docs.append-only-guard` (the H9 half
of #303's combined H9/H3 entry; H3's root-screenshot guard is untouched and stays active) is marked
`status: superseded` and relocated to `docs/decisions/archive/release-ci-governance.md`, with this
record as its `superseded-by` and this record's `supersedes` pointing back — the same-PR supersession
pattern the new rule itself mandates for every future reversal. `docs/decisions/migration-map.md`
tracks the legacy-heading → key → status → location mapping as the rest of the log migrates
(ersatztv#520/#521, ongoing — most existing headings remain `legacy-unmigrated` until touched).
@@ -0,0 +1,54 @@
---
key: docs.decision-one-file-per-record
title: 2026-07-25 — Decision records are one YAML-frontmatter file per record, filed by key (#610)
status: active
since: '2026-07-25'
supersedes: none
superseded-by: none
rule: Each decision record is its own file at `docs/decisions/records/<area>/<topic>.md` (archived ones at `docs/decisions/archive/<area>/<topic>.md`) with YAML frontmatter; the filename IS the key, so one-active-record-per-key is a filesystem property rather than a validator check, and supersession is a `git mv`.
signals: 'one file per record, split decisions.md, YAML frontmatter, records/<area>/<topic>.md, filename is the key, migrate_decisions_split, equivalence harness, dual-format parser, moved index, Records formerly in this file · paths: scripts/migrate_decisions_split.py scripts/decisions_lib.py scripts/tests/test_migration_equivalence.py docs/decisions/records/ · issues: #610, #603'
mechanics: 'scripts/migrate_decisions_split.py (one-shot); scripts/decisions_lib.py → has_frontmatter/_parse_frontmatter; docs/README.md → task-signal map'
sources: 'trial migrations against disposable worktree copies; the migration-time proof — 168 records before and after, zero lost, zero gained, zero field mismatches, zero rationale bodies differing'
---
**The monolith was the problem, and it was a concurrency problem before it was an aesthetic one.**
A 3,900-line append target made every parallel session collide: PR #605 and PR #614 both hit
append-vs-append conflicts in `docs/decisions.md` during routine rebases, and resolving those by
hand inside the corpus is exactly the operation the rationale-rewrite guard exists to police. One
file per record makes concurrent record-adds conflict-free by construction.
**Identity stays the key; the path is derived from it.** That is the difference between this and
adopting OKF wholesale (see `docs.decision-optional-provenance`, which rejected OKF as a format):
OKF's stable identity is the file path, which is precisely what the breadcrumb rule tells agents not
to trust. Here the filename is *generated from* the key and the validator enforces they agree, so
the two can never drift — and "exactly one active record per key" stops being a check the validator
performs and becomes a thing the filesystem cannot represent.
**The migration was made provable rather than reviewable.** A ~170-file diff cannot be meaningfully
read. So the parser was taught BOTH formats first, dispatching on a leading `---`: the body-diff
guard parses the old form at the merge-base and the new form at head, both keyed on `key`, so the
migration validates itself with no bypass and no one-time escape hatch. Correctness rests on a
field-level equivalence harness — every record survives with its key, every metadata field
round-trips, every rationale body is byte-identical — not on anyone reading the diff. Reviewers
should scrutinise the harness, because it is the actual evidence.
**Three things measuring caught that reading would not have.** (a) ~500 lines of the corpus sit
*outside* any record — `decisions.md`'s lifecycle schema and each topic file's preamble, mostly the
only copy of that narrative — so source files are kept and stripped, never deleted; they also cannot
be filed per-area, since topic files hold several areas and 4 of 23 areas span several files.
(b) Archive discovery was a non-recursive glob, which after the split found *zero* archived records
and surfaced as four bogus "supersedes points to unknown key" errors rather than an obvious failure.
(c) ~32 live docs point into the corpus *by date* (`see decisions.md 2026-07-10`), which the split
would dangle; each stripped file therefore ends with a generated "Records formerly in this file"
index, which also rescues the identical breadcrumbs in historical issue comments that cannot be
rewritten at all.
**Keys were preserved verbatim, warts included.** `sched` (12 records) and `scheduling` (1) are the
same concept under two prefixes, and seven areas hold a single record — now visible as directories.
Renaming a key is not a move: it changes a record's identity, breaks the equivalence proof, and
invalidates MemPalace's per-key drawers. Taxonomy normalisation is separate work.
**The line budget now counts prose, excluding frontmatter.** Stated plainly because it flatters the
number: this is a change of *metric*, not a consolidation. Frontmatter is the structured restatement
of what used to be one dense backtick line, so counting it would inflate the figure without any new
knowledge being added. The consolidation work is still outstanding.
@@ -0,0 +1,43 @@
---
key: docs.decision-optional-provenance
title: 2026-07-25 — OKF evaluated and rejected as a replacement; two of its optional fields adopted (#603)
status: active
since: '2026-07-25'
supersedes: none
superseded-by: none
rule: 'Decision records gain two OPTIONAL fields — `stale-after: YYYY-MM-DD` on the metadata line and a `**Sources:**` line in the metadata block; the Open Knowledge Format (OKF) itself is NOT adopted as the record format.'
signals: 'OKF, Open Knowledge Format, knowledge-catalog, okf-skills, stale-after, staleness, Sources, provenance, re-confirmation, review due · paths: scripts/decisions_lib.py scripts/decisions_validate.py scripts/build_decisions_catalog.py · issues: #603'
mechanics: docs/decisions.md → header schema; docs/decisions/README.md → Review due
sources: https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf (SPEC.md v0.2) · https://github.com/scaccogatto/okf-skills
---
**OKF is not a replacement for this system, and the reason is structural, not stylistic.** Its
conformance rules are deliberately permissive exactly where ours are deliberately strict: a consumer
MUST NOT reject a bundle for broken cross-links, unknown `type` values, or missing optional fields,
and its `status: deprecated` is a bare flag with nothing pointing at the successor. The single
catastrophic failure this corpus exists to prevent is *a superseded rule read as current*, and what
prevents it is the validator — one active record per key, reciprocal `supersedes`/`superseded-by`,
no record vanishing from the active set without an archive copy, catalog-drift detection. OKF has no
equivalent of any of those. It also has no `key`: its stable identity is the file *path*, which is
precisely what the breadcrumb rule tells agents not to trust. Adopting it would additionally mean a
second ingestion format alongside MemPalace for the same corpus.
**What it does have that we lacked is lifecycle and provenance metadata.** Records that encode
measured facts about hosts, runners or upstream behavior drift silently — nothing marked them due for
re-confirmation. `stale-after` fixes that, and `Sources:` gives the *evidence* a record rests on a
home other than buried prose. Both are additive: no format migration, and nothing changes for a
record that adopts neither. Adopt them opportunistically, on records that actually assert an external
fact — the three seeded here (`ci.runner-placement`, `ci.infra-shaped-red-under-load`,
`ci.peak-anon-measurement`) are the ones encoding measured host behavior, with dates staggered so
they don't all come due in the same week.
**The staleness check is non-blocking, and the generated catalog is clock-free — both on purpose.**
A calendar date turning an unrelated PR red, or silently drifting `docs/decisions/README.md` into a
`--check` failure with no commit touching the corpus, is a self-inflicted CI outage. So evaluating
`today >= stale-after` lives only on the validator's `::notice::` path, and the catalog's Review-due
section renders the date itself, never a stale/fresh verdict.
**OKF's `verified: {by, at}` trust tiers were considered and deferred**, not rejected. Given how
much of this corpus is agent-authored, distinguishing agent-inferred from human-confirmed-after-
measuring is real signal; it just needs its own actor convention and wasn't worth coupling to this
change.
@@ -0,0 +1,38 @@
---
key: docs.tracker-comment-retrofit
title: 2026-07-21 — Check the worked issue before the decision corpus; a closed tracker's comments need no retrofit (#524)
status: active
since: '2026-07-21'
supersedes: none
superseded-by: none
rule: 'When the knowledge exporter flags an over-cap tracker issue and excludes it from ingestion, triage its comments instead of assuming a retrofit is owed — and for each decision-shaped item check the **worked issue first**, because a tracker session comment is by construction a précis of the fuller closing record posted on the issue it narrates. Applied to #237 (111 comments) this yielded **zero** records, so server-management#642''s "a fact found only in a #237 comment" retrieval row has no valid subject and its interim target (an already-migrated record) is permanent.'
signals: 'tracker retrofit, over-cap issue exclusion, worked-issue-first, tracker-is-not-a-knowledge-store, #237 comment history · paths: `docs/tracker-retrofit-triage-237.md` · issues: #524, #237, #520, #521, server-management#642'
mechanics: '`docs/tracker-retrofit-triage-237.md` — method, per-comment classification table, totals, and the one candidate that was raised and disproved'
---
**Why the worked-issue-first ordering is the load-bearing part.** The session protocol that produced
#237's log required each session to post its full closing record on the issue it actually worked, and
*then* summarize across issues on the tracker. The tracker entry is therefore the lossy copy. A
comment that looks like a unique source is nearly always a précis of a primary the exporter already
indexes — so the instinct "the tracker is excluded, therefore its knowledge is orphaned" inverts the
real dependency. Checking the corpus first and the worked issue second wastes the effort; the reverse
order settles most items in one lookup.
**Worked example.** Comment [106/111] recorded that #497's music-video metadata reconcile deliberately
excludes Guids (not eager-loaded by `GetOrAdd`, so reconciling would duplicate-insert every scan) and
Directors (not add-persisted for `MusicVideoMetadata`). Genuinely decision-shaped, and genuinely
absent from the decision corpus — it survives a corpus-only check and looks like a retrofit target.
Issue #497's own closing comment states both exclusions with fuller reasoning, and the exporter
ingests it as `497.md`. One worked-issue lookup disposes of it.
**Scope of the claim — deliberately narrow.** The coverage test was applied to **decision-shaped
items only**. Items classified as cross-cutting lore were classified but *not* coverage-checked, and
that bucket is **not** empty: two facts from comment [109/111] were found to have no home anywhere —
`scripts/e2e-local.sh`'s readiness probe hanging on a reused config dir, and the troubleshooting
playback API's inability to exercise channel branding. Both were swept into the handoff lore by this
PR. So the correct statement is "no *decision-shaped* orphans," not "no orphans"; a tracker triage
that skips the lore bucket will leave real knowledge on the floor.
**A zero result is a legitimate outcome**, not evidence the triage was done wrong. What would change
the answer is a tracker whose sessions did *not* also write per-issue closing records — one where the
tracker genuinely was the primary rather than the narration layer.
@@ -0,0 +1,63 @@
---
key: ffmpeg.external-logo-graphics-engine
title: 2026-07-20 — External-URL channel logos pass through to the graphics engine; never `File.Exists`-gated, never ffmpeg-native (#502)
status: active
since: '2026-07-20'
supersedes: none
superseded-by: none
rule: External-URL channel logos pass through to the graphics engine like any other watermark source; `WatermarkSelector` must never gate them on `File.Exists` (always false for a URL) and never route them through the ffmpeg-native overlay shortcut.
signals: 'external-URL logo, WatermarkSelector, graphics engine · paths: `WatermarkSelector`, `FFmpegLibraryProcessService`, `ImageElementBase.LoadImage` · issues: #502, #67, #1, #510, #511'
mechanics: '`WatermarkSelectorChannelLogoTests`; `ChannelLogoWatermarkOptions` helper'
---
A channel whose logo is an **external URL** never rendered an on-screen bug, even with a
`ImageSource = ChannelLogo` watermark attached. `WatermarkSelector` resolved the URL correctly and then
existence-checked it on the filesystem — `File.Exists("https://…")` is always false — so all three
precedence levels (playout item, channel, global) logged *"Channel logo no longer exists"* and returned
`None`. The channel editor advertises the URL as winning over an uploaded logo, which is true for the
guide listing and was silently false for the bug.
**External artwork passes through; it is not downloaded into the image cache.** That is already the
codebase-wide convention — `ChannelPlaylist` (M3U), the XMLTV template data and `Channels/Mapper`
(SPA JSON) all emit the raw URL and let the client fetch it. No fetch→`SaveArtworkToCache` glue exists
anywhere, and adding it here would have invented a second convention for one consumer. The render path
needs no such glue: `ImageElementBase.LoadImage` already detects an `http(s)` path, fetches it with
`HttpClient`, and decodes it with ImageSharp for real pixel dimensions.
**A remote-URL watermark is therefore forced onto the graphics engine, not the ffmpeg-native path.**
`FFmpegLibraryProcessService` normally shortcuts a single permanent watermark into a `WatermarkInputFile`
handed to ffmpeg as a bare `-i` argument (and to `ffprobe` for animation detection). ffmpeg would likely
open an http URL itself, but that puts an unbounded network fetch *inside stream startup*, with no
timeout, redirect or auth handling under our control, and with dimensions left as a `FrameSize(1, 1)`
placeholder. The graphics engine is the path actually built for remote images, so the shortcut now
additionally requires a non-URL path.
**Scope held deliberately narrow — two adjacent defects were left alone:**
- The **generated-initials fallback** (no logo artwork ⇒ `ChannelLogoGenerator.GenerateChannelLogoUrl`,
which hardcodes `localhost`, issue #1) is *also* killed by the same `File.Exists`. Reviving it is
deferred by an earlier entry in this file, so it stays ignored — now behind an explicit comment and a
scope-guard test rather than as an accident of the existence check.
- The **deco path** (`OptionsForWatermarks` → the private `GetWatermarkOptions`) has always returned its
resolved path unchecked, so #502's `File.Exists` defect never reached it and its *resolution* is unchanged.
Aligning its missing-file and no-artwork behavior with the three precedence levels is a behavior change in
its own right, tracked as **#510**.
**The routing change is NOT scoped that way, deliberately.** `CanUseFFmpegNativeWatermark` keys off the
resolved `WatermarkOptions.ImagePath` alone, and `SelectWatermarks` puts deco-derived options into the same
list — so a *deco* watermark resolving to a URL is rerouted to the graphics engine too, including the
generated-initials `http://localhost:…/iptv/logos/gen` URL that the deco path does still pass through.
Routing by provenance instead of by what the path actually is would mean deciding the same thing twice and
letting the two drift; the URL-aware path is the right one for any URL. Worth knowing when picking up #510:
that fallback plausibly rendered through ffmpeg before and now composites through ImageSharp, which this
change's live-E2E did not cover.
**Accepted cost asymmetry.** Graphics-engine compositing is per-frame ImageSharp work rather than ffmpeg's
`overlay` filter, so two channels with visually identical bugs now transcode at different cost based only on
whether the logo is a URL. The rejected fetch-once-into-the-image-cache alternative would have avoided that;
if **#511** (remote-fetch hardening: timeout, size cap, pooling, caching) adds such a cache, this goes with it.
The three gated levels now share one `ChannelLogoWatermarkOptions` helper so they cannot drift apart
again — the duplication is what let the defect exist in triplicate. Covered by
`WatermarkSelectorChannelLogoTests`, which pins the external-URL fix, both preserved regressions
(cached local path, missing local file ignored) and the generated-fallback scope guard.
@@ -0,0 +1,71 @@
---
key: ffmpeg.hls-cold-start-burst
title: 2026-07-20 — HLS cold start is fixed with `-readrate_initial_burst`, not by raising the work-ahead limit (#350)
status: active
since: '2026-07-20'
supersedes: none
superseded-by: none
rule: HLS cold-start latency is fixed with a bounded `-readrate_initial_burst` (gated on FFmpeg ≥6.1 capability detection), not by raising `work_ahead_limit`, which would remove the concurrency guarantee it exists for.
signals: 'HLS cold start, readrate, work_ahead_limit, HlsSessionWorker, FFmpegKnownOption capability gate · paths: `HlsSessionWorker`, `SetRealtimeInput`, `FFmpegPlaybackSettingsCalculator`, `FFmpegKnownOption`/`HasOption` · issues: #350'
mechanics: '`SetRealtimeInput` readrate-burst option; `FFmpegKnownOption.HasOption` version-capability gate'
---
> **Correction (2026-07-21, #529):** the claim below that the burst is "bounded" is true only in
> *seconds of input* — it is **not** a bound on memory or hardware surfaces. `-readrate` was also
> incidentally bounding how fast decoded frames enter the filter graph, and removing that on a QSV
> pipeline whose profile stores `qsvExtraHardwareFrames: 0` exhausts the upload pool: the graph fails
> with `-12 (Cannot allocate memory)`, `h264_qsv` never opens, and zero segments are written. The
> burst is **not** the root cause (a work-ahead start takes no `-readrate` at all and was already
> failing the same way in production), but it removed the throttle on every realtime session and so
> made the failure near-deterministic. See `ffmpeg.qsv-extra-hw-frames-floor`; the decision recorded
> here still stands, with that floor in place.
> **Correction (2026-07-21, #536):** the bullet below stating that "every concurrent tune-in falls
> back to the throttled path" described the *intent*, not the behaviour. The slot check and its
> increment straddled an `await`, so N simultaneous tune-ins all read `0 < limit` and all started
> unthrottled; the "one winner, two throttled" observation held only because those tunes were
> effectively staggered. The measurement and the conclusions drawn from it stand — slot availability
> *was* the variable behind the bimodality — but the guarantee itself was not enforced until
> `ffmpeg.work-ahead-slot-atomic`.
- **`-readrate` throttles from the first read, so it sets a floor on time-to-first-segment.** The
realtime playback path pins input reading to 1.05× wall clock so a channel behaves like live TV.
Since `OutputFormatHls.SegmentSeconds` is 4 and the segmenter serves the playlist only once the
first segment exists, the playlist cannot appear sooner than ~4/1.05 ≈ 3.8 s. Measured on real
media: time-to-first-playlist 5369/5344 ms with `-readrate 1.05`, 648/649 ms with an initial burst.
- **The cold-start bimodality was never about the media.** `HlsSessionWorker` grants an unthrottled
`SeekAndWorkAhead` start only while `_workAheadCount < ffmpeg.segmenter.work_ahead_limit` (prod: 1);
every concurrent tune-in falls back to the throttled path. Three concurrent tunes on prod: the one
that won the slot reached `firstGop` in 866 ms, the other two in 3845 ms and 6357 ms. This is why
the earlier rounds found no correlation with subtitle burn-in, GOP length, or source file — the
variable was slot availability, and the same channel could differ 7.2× between tunes.
- **Two earlier hypotheses are falsified, not deferred.** Accurate-seek decode-discard (the issue's
ranked #1 driver) costs 30–100 ms on real prod media, and capping `-probesize`/`-analyzeduration`
buys 20–50 ms. Neither can account for seconds. Recorded here so they are not re-proposed.
- **Burst rather than a bigger work-ahead budget.** Raising `work_ahead_limit` would fix latency by
deleting the guarantee that limit exists for — it caps how many unthrottled transcodes N viewers can
start at once. The burst is bounded (`SegmentSeconds * 2` = 8 s of input, enough for the first
segments at the default `InitialSegmentCount` of 1), after which live pacing resumes. An operator
who raises `InitialSegmentCount` above 2 gets less of the benefit; that is a deliberate trade.
- **The burst is per ffmpeg process — i.e. per playout item — not per session.** `SetRealtimeInput`
runs on every pipeline build and `HlsSessionWorker` spawns a process per item, so each item boundary
bursts too; this is *not* only the session's cold start. Two consequences, both accepted: item
transitions get the same head start (a benefit), and on a channel whose items are shorter than the
burst every item transcodes unthrottled, so the instantaneous-concurrency guarantee that
`work_ahead_limit` provides is weaker than before — weaker, not absent, because `HlsSessionWorker`'s
`transcodedBuffer <= 1min` gate still stops the loop at a 60 s buffer, leaving average CPU
unchanged. Making the burst strictly cold-start-only would mean plumbing a "first process of this
session" flag through `FFmpegState`; that complexity was not judged worth a bounded peak.
- **Still images are excluded.** Their video input is paced by the realtime *filter* and takes no
readrate at all, so a burst would only run the audio input ahead of the video for songs and offline
filler, with no cold-start gain to show for it.
- **Non-HLS realtime outputs (`TransportStream`, HLS-Direct) burst too**, since
`FFmpegPlaybackSettingsCalculator` makes them unconditionally realtime. That is untested by the
benchmark, which was segmenter-only; it is kept because the same first-read throttle delays those
clients identically, and the outer `WrapSegmenter`/`Concat` processes still pace at `readrate 1.0`
with no burst.
- **Gated on runtime capability, not on a parsed version.** `-readrate_initial_burst` needs FFmpeg
≥ 6.1, and `FFmpegKnownOption`/`HasOption` already existed for exactly this (its `AllOptions` list
had simply been empty). Detection parses `ffmpeg -h long`, so an older binary silently keeps
today's behavior instead of failing to start — the same fail-safe posture as the other capability
gates, and cheaper to reason about than the version-string parsing in `NvidiaHardwareCapabilities`.
@@ -0,0 +1,49 @@
---
key: ffmpeg.qsv-decode-encode-split
title: 2026-07-20 (#498) — QSV decode is split from QSV encode via a single `QsvPreferNativeDecoder` bool
status: active
since: '2026-07-20'
supersedes: none
superseded-by: none
rule: QSV decode is decoupled from QSV encode via a single `FFmpegProfile.QsvPreferNativeDecoder` bool (default ON, Linux-only), so a QSV encode profile can decode with the more tolerant native VA-API decoder instead of the QSV decoder, mirroring Jellyfin's hybrid decode/encode toggle instead of a general decode-family enum.
signals: 'QSV, native VA-API decode, Dolby Vision passthrough, hybrid decode/encode, HDR tonemap fallback · paths: `FFmpegProfile.QsvPreferNativeDecoder`, `QsvPipelineBuilder.SetTonemap`, `QsvPipelineBuilder` · issues: #498, #505'
mechanics: '`docs/superpowers/specs/2026-07-20-qsv-native-decode-design.md`; migration `HasDefaultValue(true)` on the nullable `bool?` column; `QsvPipelineBuilder` decoder-mode branch'
---
`FFmpegProfile.HardwareAcceleration` picked one pipeline builder for **both** decode and encode, so an
Intel QSV profile decoded with the QSV decoder — which is materially less tolerant of imperfect H.264 than
FFmpeg's native VA-API decoder and cannot carry Dolby Vision metadata. Jellyfin, on identical hardware,
avoids this by decoding with VA-API and encoding with QSV; that combination was not expressible in
ErsatzTV. Full design: `docs/superpowers/specs/2026-07-20-qsv-native-decode-design.md`.
- **Chosen: a single, QSV-scoped `FFmpegProfile.QsvPreferNativeDecoder` boolean**, mirroring Jellyfin's
"Prefer OS native DXVA or VA-API hardware decoders" checkbox exactly — Jellyfin itself deliberately
collapses this to one default-on toggle rather than a decode-family picker, and copying the reference
tool's granularity avoids over-building past a problem it has already solved.
- **Default ON.** Native VA-API decode is strictly more tolerant of imperfect streams than the QSV decoder
wrapper, and Dolby Vision passthrough requires it — so the working configuration *is* the default for a
reliability fix. Existing QSV profiles adopt the hybrid on upgrade via the migration's
`HasDefaultValue(true)` (SQLite `INTEGER`/MySQL `tinyint`, **nullable with `DEFAULT 1`** — the column
is `bool?` so an explicit `false` persists, while `NULL`/absent reads as ON via `!= false`; a
non-nullable `bool` + store default would make EF silently drop a create-with-`false`), no opt-in required.
- **Rejected: a general `DecodeHardwareAcceleration` enum column** decoupling decode and encode hardware
families entirely. More configurable than Jellyfin's own control, but nothing in the codebase or the
reported failure needs that generality yet — `FFmpegState` already separates
`DecoderHardwareAccelerationMode` / `EncoderHardwareAccelerationMode` internally (used today for the
software-decode-plus-hardware-encode error-loop fallback), so the seam to grow into a real enum exists
if a second asymmetric-decode case ever shows up on another hardware family. Deferred rather than
built now, on YAGNI grounds.
- **Contained to the QSV builder.** The flag does not touch `PipelineBuilderFactory`'s single-builder
dispatch per profile, and does not repurpose the existing decode/encode-mode fields into a general
cross-family selector — that repurposing is exactly the rejected enum option, just introduced through
the back door.
- **Accepted trade-off — HDR tonemapping runs in software on the native path.** With native decode ON the
decoder mode is `Vaapi`, so `QsvPipelineBuilder.SetTonemap` (which only selects `TonemapQsvFilter` for
`DecoderHardwareAccelerationMode == Qsv`) falls to the software `zscale`/`tonemap` chain for HDR content.
Output is correct but costs CPU on the realtime path. Accepted for now because software tonemap is
correct-but-slower while an unvalidated GPU-tonemap graph could be worse (needs the Intel host to
verify), and the escape hatch covers it: HDR-on-QSV users who don't need the tolerant decoder set the
flag OFF to keep GPU tonemap. Optimizing the native path to `tonemap_qsv` is tracked in **#505**.
- **Native decode is Linux-only.** Guarded with `!OperatingSystem.IsWindows()` in the QSV builder —
FFmpeg has no `vaapi` hwaccel on Windows (and Windows QSV capabilities are over-reported), so on Windows
a QSV profile keeps QSV decode regardless of the flag.
@@ -0,0 +1,70 @@
---
key: ffmpeg.qsv-extra-hw-frames-floor
title: 2026-07-21 — QSV hardware-frame headroom is a floor, not an operator preference (#529)
status: active
since: '2026-07-21'
supersedes: none
superseded-by: none
rule: a QSV upload never emits `extra_hw_frames` below `FFmpegState.MinimumQsvExtraHardwareFrames` (64); a stored `0` or negative value is treated as "no pool configured" rather than honored literally, because with no headroom any unthrottled read exhausts the pool and the transcode writes nothing at all.
signals: 'QSV, extra_hw_frames, hwupload, hardware frame pool, ENOMEM, "Could not open encoder before EOF", readrate as an incidental allocation bound · paths: `FFmpegState`, `QsvPipelineBuilder`, `HardwareUploadFilter`, `ScaleQsvFilter`, `DeinterlaceQsvFilter` · issues: #529, #350, #516, #519'
mechanics: '`FFmpegState.MinimumQsvExtraHardwareFrames`; `FFmpegState.QsvExtraHardwareFrames`; `QsvPipelineBuilder` logs once when a configured value is raised'
---
- **`extra_hw_frames=0` is not a valid pool size; it is a dead channel waiting for an unthrottled
read.** `FFmpegState.QsvExtraHardwareFrames` honored a stored `0` literally, so
`hwupload=extra_hw_frames=0` reached FFmpeg with no headroom for frames in flight through the
filter graph. Measured against the deployed FFmpeg 8.1.2 on one real logged command (software
mpeg4 decode → `hwupload` → `vpp_qsv` → `h264_qsv`): the graph fails with `-12 (Cannot allocate
memory)`, `h264_qsv` reports "Could not open encoder before EOF", and **zero segments are
written**. It now clamps to `MinimumQsvExtraHardwareFrames` (64), which is also the value
`IfNone` already used for an unset profile and the seeded profile default.
- **Input throttling was the only thing hiding it, which is why this looked like a #350
regression.** Truth table, same command, same binary, only the marked tokens differing:
| readrate | extra_hw_frames | result |
|---|---|---|
| `1.05`, no burst | 0 | 14 segments, exit 0 |
| `1.05` + burst 2 / 4 / 8 | 0 | **ENOMEM, 0 segments** |
| no readrate at all | 0 | **ENOMEM, 0 segments** |
| `1.05` + burst 8 | 64 | 14 segments, exit 0 |
| no readrate at all | 64 | 14 segments, exit 0 |
So the defect predates #350's burst: any work-ahead start (which takes no `-readrate`) on a
pipeline that uploads to QSV was already failing on a profile with `0`. The burst did not
introduce it — it removed the throttle on *every* realtime session, converting an intermittent
failure into a near-deterministic one, which is how it finally got noticed.
- **`-readrate` was doing load-bearing work nobody had written down.** Its stated job is live-TV
pacing; it was *also* incidentally bounding how fast decoded frames enter the filter graph. This
is why #350's FFmpeg-level benchmark and #516's argument-generation tests were both green and
neither could see it: the burst is bounded in **seconds of input**, which is not a bound on
**memory or hardware surfaces**. Corrects the #350 entry above, which records the burst as
bounded and safe and does not mention hardware frame pools.
- **A floor, not a clamp-to-default — and the floor is wider than the evidence.** Values above 64
are honored unchanged; values below it are raised. We measured only `0` (fails) and `64` (works),
so `1..63` are **untested, not known-bad**: we raise them rather than trust them, because the
failure they risk is a channel that serves nothing at all. That is a deliberate over-reach, and
it is not free — `extra_hw_frames` allocates *additional* surfaces (64 NV12 1080p surfaces ≈
190 MiB, ≈760 MiB at 4K), so an operator who deliberately set a small pool on a memory-constrained
iGPU silently gets a larger one. `QsvPipelineBuilder` therefore **logs a warning** naming both the
configured and the applied value, so the override is discoverable rather than silent. If a smaller
pool is ever measured safe, lower the floor rather than removing it.
- **Fixed at `FFmpegState.QsvExtraHardwareFrames`, the single point every QSV upload site reads.**
`HardwareUploadFilter`, `HardwareUploadQsvFilter` and `WatermarkHardwareUploadFilter` read it
directly; `ScaleQsvFilter` and `DeinterlaceQsvFilter` take values `QsvPipelineBuilder` passes down
from it. One guard covers them all rather than five call sites that can drift apart. (A sixth
formatter, `SubtitleScaleQsvFilter`, also emits `extra_hw_frames` but is currently dead code — no
construction site exists in the solution — so it is *not* covered by this guard and would need the
same value threading if it is ever revived.)
**Accepted residual:** the floor is applied at render time only, so a stored `0` keeps displaying as
`0` in the SPA and over `GET /api/v1/ffmpeg/profiles/{id}` while FFmpeg receives 64 — the config no
longer literally describes the behavior. Render-time was chosen deliberately: it fixes every existing
deployment with no DB edit and no migration, which matters because this bug is already failing
transcodes in production. New and updated profiles are normalized on save so stored rows converge on
the truth, and the warning log closes the discoverability gap for rows that predate it; a backfill
migration for old rows was judged not worth the dual-provider cost. Two consequences to know about:
the save-time normalization is **unconditional on `hardwareAcceleration`**, so a non-QSV profile's
stored value moves too (harmless — only the QSV path ever reads it — but it is a stored-state change
on a field the user didn't touch); and a machine client that `PUT`s `0` gets a `200` and then reads
back `64`, which is a silent transform of a submitted value that the OpenAPI description does not
advertise.
@@ -0,0 +1,143 @@
---
key: ffmpeg.remote-image-fetcher-bounded
title: 2026-07-20 — Remote graphics-engine images are fetched through a bounded, pooled `IRemoteImageFetcher`; re-fetched per element init, not cached (#511)
status: active
since: '2026-07-20'
supersedes: none
superseded-by: none
rule: remote graphics-engine images are fetched through `IRemoteImageFetcher` with a pooled `HttpClientFactory` client, a body-covering deadline, a wire-transfer size cap, and a decoder-enforced `DecoderOptions.MaxFrames` bound re-verified post-decode — never cached, re-fetched per element init.
signals: 'remote image fetch, decompression bomb, MaxFrames, decode budget vs retention budget, SSRF accepted risk · paths: `IRemoteImageFetcher`, `HttpRemoteImageFetcher`, `ImageElementBase.LoadImage`, `DecoderOptions.MaxFrames`, `WatermarkElementRemoteImageTests` · issues: #511, #502, #289'
mechanics: '`HttpRemoteImageFetcher` (Infrastructure) over `IHttpClientFactory`; `EnsureDecodeAffordable`/`EnsureScaledFramesAffordable` pure-function budget checks'
---
`ImageElementBase.LoadImage` fetched `http(s)` images with a throwaway `new HttpClient()` and
`GetStreamAsync`. That is unbounded in three directions at once — no timeout override (the 100s
`HttpClient` default), no response size cap, and a new connection pool per element — and it runs
*inside stream startup*, while ffmpeg waits on the pipe. #502 routed ordinary channel-logo
watermarks onto that path, which is what made a pre-existing weakness worth hardening.
The fetch now lives behind **`IRemoteImageFetcher`** (`HttpRemoteImageFetcher`), deliberately
modelled on the neighbouring `IRemoteStreamProber`: a Core interface, an Infrastructure
implementation over `IHttpClientFactory`, and a `CancellationTokenSource.CreateLinkedTokenSource` +
`CancelAfter` deadline.
**The deadline covers the body, not just the headers.** The named client is registered with
`Timeout = InfiniteTimeSpan` and the linked token is threaded into `GetAsync` *and* every stream
read, because under `HttpCompletionOption.ResponseHeadersRead` the body read falls outside
`HttpClient.Timeout` — a slow-drip host would otherwise hang forever. Same mechanic as #289.
**The size cap is enforced during the copy, not from `Content-Length`.** The advertised length is
only a cheap early reject; it can be absent or a lie, so the byte counter in the copy loop is what
actually bounds transfer and buffering. (It bounds the *wire*, not the decode — see below.) Both
paths are covered by tests, and both were negative-controlled (disabling the checks fails exactly
those two tests).
**The byte cap does not bound decoding, so there are two further budgets — and the decode bound is
imposed on the DECODER, not read from the header.** A decompression bomb is by definition small on
the wire: a 4 KB PNG can declare 30000x30000 (~3.6 GB to decode), and a 60 KiB GIF can declare
2500x2500 across 600 frames (~14 GiB). All of that passes `Content-Length`, the content-type check
and the 10 MiB copy cap in milliseconds.
Getting this right took three attempts, and the two failures are the interesting part:
1. The first version checked dimensions (50 MP) and frame count (600) **independently**. The
2500x2500 x600 GIF above passes both while costing ~14 GiB. *Independent caps do not compose
into a budget* — the bound has to be on the PRODUCT.
2. The second version checked the product, but sourced the frame count from
`Image.IdentifyAsync`. **Measured on ImageSharp 3.1.12: an APNG reports
`FrameMetadataCollection.Count == 0` while the decoder produces every frame.** A 4000x4000 x600
APNG is ~134 KiB on the wire, is charged as ONE frame (16 MP, comfortably inside the budget) and
decodes to ~36 GiB — 2.5x worse than the bomb that version was written to stop. Header-derived
limits are advisory; a limit the decoder does not enforce is not a limit. (GIF, WebP and TIFF
report honestly; PNG/APNG is the sole divergence, which is exactly why trusting the header is
untenable — you cannot audit every format.)
So `DecodeRemoteImage` now: checks the header **dimensions** (which are trustworthy — a GIF whose
image descriptor exceeds its logical screen is clamped by the decoder, verified); derives how many
frames of that size the budget affords; passes that to **`DecoderOptions.MaxFrames`**, which the
decoder enforces regardless of what the header claimed; and then **re-verifies the real
`image.Frames.Count`** after decoding, disposing and rejecting if it is over. `MaxFrames` was
measured as honored by every animated decoder in play (APNG, GIF, WebP, TIFF), which is what makes
it a real bound rather than another advisory one. The code asks for `affordable + 2` so an
animation exactly at the limit still decodes in full while anything over it is visible to the
post-decode check; the slop is at most two frames, since `MaxFrames = N` yields `N` frames for
GIF/WebP/TIFF but `N-1` for APNG — the exact count varies by format, so only the upper bound is
relied on.
- **Decode budget** — `width x height x frames <= 50 MP`, verified against the decoded image.
- **Retention budget** — `frames x scaledWidth x scaledHeight <= 200 MP` (~800 MB at 4 bytes/px),
checked once the scale is known. Independent of the decode budget in both directions: a 100x100
source is trivial to decode but retains ~5 GB of `SKBitmap` at 600 frames scaled to 1920x1080,
because `LoadImage` clones and resizes every frame to output resolution and keeps them.
**The enforced peak is up to 3x the nominal decode budget, and that is deliberate.** Detecting
"over the limit" requires actually decoding more frames than the limit allows, so the ceiling is
`(affordable + 2) x perFrame`. In the pathological case — one frame that alone fills the budget,
so `affordable = 1` — that is 150 MP (~600 MB at Rgba32, ~1.2 GB for a 16-bit TIFF at Rgba64)
rather than 50 MP. Bounded and survivable, against the ~36 GiB it replaces, and the alternative
(tightening the single-frame allowance to budget/3) would reject legitimate 8K stills at 33 MP.
Stated here because the previous three versions of this entry each claimed a bound the code did
not actually enforce.
Other caveats: the budgets are in pixels, but a 16-bit PNG decodes to `Rgba64` (8 B/px), so the
byte cost doubles; the retention budget is per element, with no global ceiling across concurrent
streams; and 200 MP caps a full-frame 1080p animated overlay at ~96 frames (~3.2s at 30fps), which
is the one limit here that could plausibly bite a legitimate user rather than an attacker.
**A workaround rides along with the header pre-pass.** `Image.IdentifyAsync` is called with
`MaxFrames = 1` — not as a limit, but because a *default* `Identify` throws
`InvalidImageContentException` on most APNGs (measured: 13 of 16 shapes, including files
ImageSharp's own `PngEncoder` wrote) that `Image.Load` reads back perfectly. Adding the pre-pass
without it would have silently disabled every animated-PNG logo that worked before this change —
a functional regression introduced *by* a hardening change, caught only because the reviewer swept
shapes rather than trusting the one the tests happened to use.
Both budgets are enforced by pure functions (`EnsureDecodeAffordable`, `EnsureScaledFramesAffordable`)
so the arithmetic is tested at every boundary without materializing multi-gigabyte images, and both
call sites have wiring coverage (deleting either one fails a test). The APNG case is pinned by a
regression test that asserts the header under-reports *and* that the decode is rejected anyway.
Local images are deliberately exempt: those are files an operator put on disk, not bytes an
arbitrary host returned.
**Content type is checked permissively.** A positively-not-an-image type (an HTML error page) is
rejected before the decoder sees it, but a *missing* type and `application/octet-stream` are
allowed — hosts omit the header and static file servers default to octet-stream often enough that
strictness would break working logos, while buying little: ImageSharp decodes by magic bytes, so
the size and dimension caps are what actually protect the decoder.
**Redirects stay enabled but capped at 3** (the default is 50). Logo hosts and CDNs legitimately
redirect, so disabling them would break real configurations.
**Not cached — re-fetched on each element initialization, i.e. per playout item.** #502's entry
noted that a fetch-once-into-the-image-cache design would also erase its per-frame cost asymmetry,
so caching was considered here and rejected *for now*: with pooling, a 10s ceiling and a 10 MiB cap,
one small GET per item transition is not a cost worth a cache's invalidation policy (when does an
admin's logo change take effect?) and lifetime questions. It also keeps the codebase-wide
"external artwork passes through, it is not downloaded into the image cache" convention #502
established intact. Revisit only with a measurement showing the re-fetch actually costs something.
**SSRF is accepted, not mitigated.** `ChannelValidations.ValidateLogo` still only checks the scheme,
so an admin-set logo URL remains a request origin inside the container's network, and the redirect
cap bounds hop count, not destination — an approved host can still redirect hop three to
`169.254.169.254` or `127.0.0.1:<port>`. This is deliberate, and the load-bearing reason is that
the primitive is **blind**: the response body is never returned to any user, only decoded and
composited into a video frame, and failures surface only as a log line. Combined with the
capability being admin-only — and with LAN-hosted logos being a legitimate, common setup here, so a
private-IP denylist would break working installs — the exposure does not justify the breakage.
Revisit if logo URLs ever become settable through a lower-privilege path, or if any fetch result
becomes readable by a caller; note that "admin-only" is the weaker half of this argument and
blindness is the stronger.
Failures are surfaced as exceptions rather than a failure value, because both call sites
(`WatermarkElement`, `ImageElement`) already wrap initialization in a catch that sets
`IsFinished` — so a dead, slow or oversized URL degrades to "element disabled" and never kills the
stream. `WatermarkElementRemoteImageTests` pins that contract, including that a local path never
touches the fetcher.
The fetcher distinguishes its own deadline (rethrown as `TimeoutException`) from caller
cancellation (propagated as `OperationCanceledException`) via an exception filter on
`cancellationToken.IsCancellationRequested`. Be aware this distinction is currently **observationally
inert**: the elements' pre-existing blanket `catch (Exception)` swallows both one frame up, so a
shutdown mid-tune logs a spurious per-element warning. The filter is kept because it makes the
fetcher correct on its own terms and the log message names the real cause; making the elements
re-throw cancellation is a separate, pre-existing concern.
@@ -0,0 +1,41 @@
---
key: ffmpeg.work-ahead-slot-atomic
title: 2026-07-21 — Work-ahead slots are claimed atomically by the caller, released by the transcode it hands them to (#536)
status: active
since: '2026-07-21'
supersedes: none
superseded-by: none
rule: '`workAheadSegmenterLimit` is enforced by a single compare-exchange claim on a shared `WorkAheadSlots` pool taken by the *caller* of `Transcode`, which then passes ownership in and gets the release in `Transcode`''s `finally` — never a `Volatile.Read` compare in one place and an `Interlocked.Increment` in another.'
signals: 'work-ahead slot, workAheadSegmenterLimit, check-then-act across an await, unthrottled tune-in, TOCTOU · paths: `ErsatzTV.Application/Streaming/WorkAheadSlots.cs`, `HlsSessionWorker.Run`/`Transcode`, `ErsatzTV.Core.Tests/Streaming/WorkAheadSlotsTests.cs` · issues: #536, #350, #529, #231, #250'
mechanics: '`WorkAheadSlots.TryAcquire(limit)` CAS loop; `Transcode(bool ownsWorkAheadSlot, …)`'
---
**The defect was structural, not a missing `Interlocked`.** The write side already used
`Interlocked.Increment`, which is why the code read as thread-safe. The read side was a separate,
earlier `Volatile.Read(ref _workAheadCount) < await GetWorkAheadLimit(...)` in `Run`, and the
increment happened later inside `Transcode` — with at least one `await` (a DB-backed config read) in
between. `Interlocked` on one half of a check-then-act buys nothing. Three simultaneous tune-ins on
prod with `workAheadSegmenterLimit: 1` all observed `0 < 1` and all ran with no `-readrate`.
**Why the caller claims and the callee releases.** Moving the whole acquire/release pair inside
`Transcode` would be more symmetric, but `Run` needs the outcome *before* the call: it sets
`_state = SeekAndWorkAhead | SeekAndRealtime`, and `Transcode` reads that state on entry
(`wasSeekAndWorkAhead`) to decide whether the item starts at `DateTimeOffset.Now` or at
`_transcodedUntil`. Folding acquisition inward would have silently changed that branch. Instead the
parameter was inverted — `Transcode(bool ownsWorkAheadSlot, …)` with `realtime = !ownsWorkAheadSlot`
derived on the first line — so the contract is stated in the signature rather than implied by a
double negative, and there is exactly one release site guarded by the same flag.
**Compare-exchange rather than increment-then-back-off.** The issue proposed
`Interlocked.Increment` followed by a decrement when the post-increment value exceeds the limit.
That is correct on holder count, but the counter transiently overshoots, so a concurrent reader of
`Count` can observe a value above the limit. The CAS loop never publishes a state that violates the
invariant, which matters because the QSV hardware-frame pool sizing (`ffmpeg.qsv-extra-hw-frames-floor`)
is derived from that bound.
**Testing shape is inherited from #231/#250.** A single `Barrier(N)` + `Task.WhenAll` round does not
reliably collide on this hardware; the tests hammer 8 threads × 20 000 rounds with a per-round
barrier that validates the winner count and resets the pool. The negative control is documented in
the test file: reinstate the check-then-act body (**not** `if (true)`, which trips CS0219 under
warnings-as-errors and leaves `--no-build` running a stale, still-fixed dll). Verified: with the
pre-fix shape, 15 912 of 20 000 rounds over-claimed.
@@ -0,0 +1,19 @@
---
key: ffmpeg.work-ahead-slot-release-never-negative
title: 2026-07-21 — `WorkAheadSlots.Release()` clamps before decrementing and reports unbalance in-band (#539)
status: active
since: '2026-07-21'
supersedes: none
superseded-by: none
rule: '`Release()` reads the count and compare-exchanges `current - 1` only when `current > 0`; a release against an empty pool records an unbalanced release and returns `false` **without ever writing a negative value**. It never decrements first and clamps afterward. The single caller (`HlsSessionWorker.Transcode`''s `finally`) logs a warning on the `false` return.'
signals: 'work-ahead slot release, unbalanced release, negative slot count, over-admit at limit 1, phantom work-ahead room, UnbalancedReleases counter · paths: `ErsatzTV.Application/Streaming/WorkAheadSlots.cs`, `HlsSessionWorker.Transcode`, `ErsatzTV.Core.Tests/Streaming/WorkAheadSlotsTests.cs` · issues: #539, #536, #529, #231, #250'
mechanics: '`bool Release()` CAS loop guarded by `current <= 0`; call site `if (ownsWorkAheadSlot && !_workAheadSlots.Release()) _logger.LogWarning(...)`'
---
**Why never-negative, given the release is already unreachable-when-unbalanced.** Today `HlsSessionWorker` has exactly one release site, guarded by `ownsWorkAheadSlot`, so an unbalanced release cannot happen — these were three **Low** findings from the #536 re-review, filed against the day someone adds a second release site. The pre-#539 shape decremented first (`0 → −1`) and clamped afterward, leaving a reachable interleaving where a concurrent `TryAcquire(limit)` reads the `−1`, sees phantom room, and admits a holder the budget doesn't have (a second acquirer then reads `0` and admits another) — two unthrottled transcodes at limit 1, re-opening the #529 QSV pool exhaustion. Because `TryAcquire` is the sole, CAS-guarded increment path, a count proven never-negative is exactly what forecloses that over-admit. Clamping *before* the decrement also records the breakage **synchronously on the offending thread**, instead of the decrement-first shape where the blame lands on a later, innocent release.
**`Release()` returns `bool` so the breach is visible in the logs, not only to tests.** #536's motivation was "a silently inflated budget with nothing in the logs to find it by," yet `UnbalancedReleases` was reachable only from a debugger or a test. `WorkAheadSlots` has no logger by design (it is a leaf primitive), so rather than plumb one in, `Release()` returns `false` on an unbalanced release and the caller — which already holds an `ILogger` — logs the warning. This is the one in-band signal that the ownership contract broke.
**`UnbalancedReleases` can under-count, and that is documented rather than fixed.** It only increments when a release finds the pool already empty. An over-release while the count is positive — e.g. one cancelling out a coexisting leak — decrements a real-looking slot and is never recorded, so the two bugs hide each other. There are no false positives (non-zero still means the contract broke), but zero does not prove correctness. Exact accounting would need per-owner tokens, which the #536 "ownership is a discipline, not a token" decision deliberately avoids; the docstring now states the limitation instead.
**Negative control (inherited from #231/#250).** A dedicated test hammers unbalanced releases on an empty pool while reader threads sample the count; none may ever observe a value below zero. Reinstating the pre-#539 decrement-first body makes it fail (`sawNegative > 0` — the readers catch the transient `−1`); verified. As with the #536 tests, break the primitive by reverting the real body, **not** `if (true)` (CS0219 under warnings-as-errors leaves `--no-build` running a stale, still-fixed dll).
@@ -0,0 +1,51 @@
---
key: graphics.channel-level-attachment
title: 2026-07-22 — Channel-level graphics-element attachment + seeded On Now/Next text element (#74)
status: active
since: '2026-07-22'
supersedes: none
superseded-by: none
rule: A channel can attach `GraphicsElement`s directly via a new `ChannelGraphicsElement` join table (a base layer under deco/playout-item elements), and a built-in text element (`on-now-next.yml`) is seeded once per database so the On Now/Next overlay works out of the box.
signals: 'ChannelGraphicsElement, Channel graphics attachment, GraphicsElementSelector base layer, on-now-next seeded element, GraphicsElementDefaults.OnNowNextFileName, builtIn discriminator · paths: `ErsatzTV.Core/Domain/ChannelGraphicsElement.cs`, `ErsatzTV.Core/FFmpeg/GraphicsElementSelector.cs`, `ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementSeeder.cs`, `ConfigElementKey.GraphicsOnNowNextSeeded`, `GraphicsElementResponseModel.BuiltIn` · issues: #74'
---
#74 asked for a transient "On Now / Next" text bug burned onto the transcoded stream at each
program transition. The rendering and EPG-template-data infrastructure already existed (upstream
graphics engine + our #502/#511 remote-image/graphics-engine work); the gap was that graphics
elements had **no channel-level attachment** — only `PlayoutItem`/`ProgramScheduleItem`/`BlockItem`/
`Deco` joins existed — and there was **no seeded/built-in graphics element**, unlike watermarks.
**Decision: add a 5th join table, `ChannelGraphicsElement`, rather than reuse the watermark FK.**
Watermarks and graphics elements are separate parallel systems; a channel already has exactly one
`WatermarkId`, already spent on the #67 logo bug, and multi-line EPG text is a poor fit for the
single-image watermark model. `ChannelGraphicsElement` is structurally identical to the existing
four joins (composite key `{ChannelId, GraphicsElementId}`), added via a dual-provider migration
(`scripts/add-migration.sh Add_ChannelGraphicsElement`).
- `GraphicsElementSelector.SelectGraphicsElements` appends channel-level elements at the **final
fall-through**, alongside `playoutItem.PlayoutItemGraphicsElements` — a **base layer**. A deco in
`Merge` mode composes with it; a deco in `Override`/`Disable` mode returns earlier and so
suppresses it (decos are allowed to override channel defaults, a deliberate rule). One more
suppression path: on a **filler** item, a deco whose graphics-elements section is not set to run
during filler (`UseGraphicsElementsDuringFiller` false) clears the result and returns for `Merge`
and `Override` alike, so the channel base layer is dropped there too.
`HttpLiveStreamingDirect` continues to return empty (ErsatzTV isn't transcoding, so there is no
frame pipeline to draw into).
- **Seeded built-in element**, mirroring the `iptv.logo-drives-bug-preset` (#67) pattern:
`GraphicsElementSeeder.SeedOnNowNext` writes `on-now-next.yml` into the graphics-elements
templates folder (only if the file is absent — operator edits are never clobbered) and ensures a
`GraphicsElement` row exists for it, guarded by the `graphics.on_now_next_seeded` `ConfigElement`
marker so it runs once per database, not once per file-absence (the same reasoning as #67: the
seeder runs at every startup, so a name/file-only guard would resurrect a deliberately deleted
preset).
- The API needed a way for the SPA to find the built-in element without a fragile name-match — the
direct #67 lesson (`WatermarkResponseModel.imageSource`). `GraphicsElementResponseModel` gained a
server-derived `BuiltIn` bool, computed by comparing the row's `Path` filename to
`GraphicsElementDefaults.OnNowNextFileName` rather than trusting the element's editable `Name`.
- The channel editor's Branding-tab "Show On Now / Next overlay" switch follows the exact pattern
of the existing logo-bug toggle: on adds the built-in element's id to `graphicsElementIds`, off
removes it; disabled (with an explanatory caption) when the channel is HLS-Direct.
**Accepted trade-off:** all channels that enable the toggle share one seeded element's geometry/
content; per-channel customization means editing the shared YAML or attaching a different element
(the join is general, not restricted to the seeded one).
@@ -0,0 +1,60 @@
---
key: graphics.channel-logo-caching
title: 2026-07-21 — External channel-logo URLs are downloaded and cached at save time; the render path never fetches a logo (#525)
status: active
since: '2026-07-21'
supersedes: none
superseded-by: none
rule: An external `http(s)` channel-logo URL is fetched, decode-budget-validated, and stored in the image cache under a content-hash name at SAVE time — becoming byte-identical to an uploaded logo — so the render path never fetches a logo over HTTP; a bad URL fails the save with a 422 (BaseError → ValidationProblemDetails).
signals: 'channel logo url · watermark on-screen bug · paths: `ErsatzTV.Infrastructure/Images/RemoteLogoCacher.cs`, `ErsatzTV.Core/Images/RemoteImageDecodeBudget.cs`, `ErsatzTV.Application/Channels/Commands/*ChannelHandler*.cs`, `ErsatzTV/Services/RunOnce/ExternalLogoMigratorService.cs` · issues: #525, #511, #502'
mechanics: '`docs/channels.md` → Channel logo & on-screen bug; `docs/api-conventions.md` → error mapping'
---
`ImageElementBase.LoadImage` used to fetch an external-URL logo over HTTP *inside stream startup*,
once per playout item. #511 bounded that fetch (timeout, wire cap, redirect cap, decode budgets) but
left it in the render path, where a dead/slow/oversized/non-image URL surfaces only as a render-time
log line — invisible to the operator who typed it, and re-paid every playout-item transition.
**A URL is now an input method, not a storage format.** On save, `IRemoteLogoCacher` fetches the URL
(reusing #511's hardened `IRemoteImageFetcher`), validates it against the shared
`RemoteImageDecodeBudget` (via `IRemoteImageValidator`), and writes the bytes through
`IImageCache.SaveArtworkToCache`, storing the returned MD5 content-hash name in `Artwork.Path`. After
a successful save the logo is indistinguishable from an uploaded one, so **every downstream consumer
is unchanged** — M3U, XMLTV and the SPA mapper all resolve `Artwork.Path` to an `/iptv/logos/…` URL,
and the render path finds a local cached file. To refresh a changed remote image the operator
re-enters the URL; there is deliberately no refresh button and no staleness/ETag tracking (the
content hash makes a re-add of unchanged bytes a natural no-op and of changed bytes a natural new
name).
**Content-hash name, not a GUID.** `SaveArtworkToCache` already returns an opaque MD5-of-bytes name
identical to the upload path, so there is one cache convention rather than two — and dedup + change
detection fall out for free. A GUID would deviate for no benefit.
**The decode budget now guards uploads too.** The byte/wire cap does not bound decoding, so the same
`RemoteImageDecodeBudget` (product of `width × height × frames ≤ 50 MP`, `≤ 600` frames, enforced on
the decoder via `DecoderOptions.MaxFrames` and re-verified against the decoded image — header frame
counts lie, see #511) is applied at BOTH the URL-download path and `UploadArtworkHandler`. One rule:
anything entering the logo cache is budget-checked, however it arrived. This closes a pre-existing
gap that this feature would otherwise have widened (a URL logo becoming an unchecked upload).
**Narrows `ffmpeg.remote-image-fetcher-bounded` (#511) and `ffmpeg.external-logo-graphics-engine`
(#502), does not reverse either.** #511's `IRemoteImageFetcher` bounded-fetch primitive and its "not
cached, re-fetched per element init" statement REMAIN active for operator-authored YAML `image:`
graphics elements, which still legitimately fetch a URL at render time — only channel logos moved to
save-time caching. #502's "external artwork passes through, it is not downloaded into the image
cache" still describes the CLIENT-facing consumers (M3U/XMLTV/SPA emit whatever `Artwork.Path`
resolves to) — now a cache URL rather than the raw external URL, because the row no longer holds a
URL. So neither predecessor is superseded (both stay `active`); this is a new decision layered on
top, hence `supersedes: none` — not a keyed supersession.
**Existing rows migrate at startup, fail-open.** `ExternalLogoMigratorService` (a run-once
`BackgroundService`, after the schema migrator + DB cleaner) downloads existing URL logo rows into
the cache; a row whose download fails is left exactly as-is with a warning naming it, and
`WatermarkSelector.ChannelLogoWatermarkOptions` degrades such a leftover URL to "no on-screen bug"
(a warning, never a render-time fetch). The migration is idempotent by construction — a converted
row's path is no longer a URL, so a second pass selects it out — and all-or-nothing on cancel (a
single trailing `SaveChangesAsync`).
**Accepted residual:** the SPA can render a not-yet-migrated external-URL logo as an `<img>` preview
that looks working while the server-side bug won't resolve until the row is re-saved/migrated — a
narrow transitional-state cosmetic mismatch, since the startup migration eagerly converts old rows.
+55
View File
@@ -0,0 +1,55 @@
---
key: iptv.base-url
title: 2026-07-16 — Optional advertised IPTV base URL (`iptv.base_url`) resolved centrally in the two generators (#340)
status: active
since: '2026-07-16'
supersedes: none
superseded-by: none
rule: An optional advertised base URL (`iptv.base_url`) is resolved centrally via a pure Core helper (`AdvertisedBaseUrl`) inside the two IPTV generation handlers (M3U + XMLTV); unset/malformed values fall back byte-identical to the request-derived host, and it's a new `iptv` settings group distinct from `ETV_BASE_URL` and out of scope for HDHomeRun.
signals: '`AdvertisedBaseUrl.TryParse`/`Resolve`, `ConfigElementKey.IptvBaseUrl`, request-derived-host symptom (Gitea #1) · paths: `ErsatzTV.Core/Iptv/AdvertisedBaseUrl.cs`, `docs/m3u-xmltv.md` · issues: #340'
mechanics: '`ErsatzTV.Core/Iptv/AdvertisedBaseUrl.cs`, `GET`/`PUT /api/v1/settings/iptv`'
---
ErsatzTV's absolute IPTV URLs (M3U stream/logo/guide URLs, XMLTV `<icon>`/artwork) were always
derived from the incoming request's `Scheme`/`Host`/`PathBase`, so any client that fetched with a
host downstream consumers can't resolve (the historical Gitea #1 `localhost:8409` symptom) baked
that host into the output. #340 adds an **optional** advertised base URL to pin those URLs to a
fixed public origin. Several deliberate decisions shaped it:
**(a) Resolve the override centrally in the two generation handlers, via a pure Core helper — not
in the controller.** A new `ErsatzTV.Core/Iptv/AdvertisedBaseUrl.cs` exposes `TryParse(string) →
Option<(scheme, host, baseUrl)>` and `Resolve(configured, requestScheme, requestHost,
requestBaseUrl)`. `GetChannelPlaylistHandler` (M3U, via `ChannelPlaylist`) and
`GetChannelGuideHandler` (both XMLTV `{RequestBase}` substitution sites) call `Resolve` and use its
result instead of the raw request values. Keeping the logic in a pure, allocation-free Core helper
(not the thin controller) keeps controllers dumb, makes the parse/validate/resolve rules unit-testable
in isolation, and — because the fallback path returns the exact request-derived values — leaves the
M3U/XMLTV golden tests (`ChannelPlaylistGoldenTests`, `ChannelGuideGoldenTests`) untouched.
**(b) Validation rules, with blank/invalid → request-derived so "unset" is byte-identical.**
`TryParse` accepts only an **absolute http(s)** URL with **no credentials, query, or fragment**; it
**preserves the port and any path prefix** and **normalizes a trailing slash** off. Anything failing
these rules → `None`, and `Resolve` then falls back to the request-derived scheme/host/base. So an
unset or malformed value produces output byte-for-byte identical to today's request-derived behaviour
— the override is strictly opt-in and can never silently corrupt the default path.
**(c) A NEW `iptv` settings group, not folded under `xmltv`.** The base URL affects **both** the M3U
playlist and the XMLTV guide, so it does not belong under the existing XMLTV-only settings. `GET`/`PUT
/api/v1/settings/iptv` on `SettingsController` (tier `[RequiresAuthentication]`) with body `{ baseUrl }`,
backed by `GetIptvSettings`/`UpdateIptvSettings` handlers + `IptvSettingsViewModel` /
`IptvSettingsResponseModel` / `UpdateIptvSettingsRequest`, and a new "IPTV" section on the SPA Settings
screen. Follows the existing settings GET/PUT pattern — no new API convention.
**(d) Blank clears the key; non-blank malformed → 422.** A blank/whitespace `baseUrl` on PUT
**deletes** the `ConfigElement` (reverting to request-derived); a non-blank value that fails
`AdvertisedBaseUrl.TryParse` is rejected with **422** rather than being silently stored and ignored at
generation time — the error surfaces at the point of configuration.
**(e) Scoped to M3U + XMLTV, deliberately NOT HDHomeRun.** #340 covers only the M3U playlist and XMLTV
guide generators. The HDHomeRun lineup URLs still echo the request host; extending the override there
was explicitly out of scope.
**(f) Distinct from `ETV_BASE_URL`.** The `ETV_BASE_URL` environment variable only sets the ASP.NET
Core `PathBase` (request routing/prefix); it does not advertise a scheme+host. `iptv.base_url` is the
separate, DB-stored (`ConfigElementKey.IptvBaseUrl`, **no EF migration**) advertised origin for IPTV
output. See `docs/m3u-xmltv.md` → "IPTV base URL (#340)".
@@ -0,0 +1,49 @@
---
key: iptv.logo-drives-bug-preset
title: 2026-07-20 — One logo drives the bug via a shared ChannelLogo preset, not new schema (#67)
status: active
since: '2026-07-20'
supersedes: none
superseded-by: none
rule: One uploaded channel logo drives both the listing logo and the on-screen bug via a shared, seeded `ChannelLogo`-sourced watermark preset (`Channel Bug`), not new per-channel schema.
signals: 'channel logo, watermark bug preset, ChannelWatermark seeding, ChannelLogo imageSource, seed/adopt semantics, quick-add · paths: `ChannelWatermarkImageSource.ChannelLogo`, `DbInitializer.Initialize`, `watermark.channel_bug_seeded`, `WatermarkResponseModel` · issues: #67, #286, #502'
mechanics: '`DbInitializerChannelBugWatermarkTests`; `WatermarkResponseModel.imageSource`; `DbInitializer` seeded `Channel Bug` watermark + `watermark.channel_bug_seeded` ConfigElement marker'
---
#67 asked that one uploaded image drive both the listing logo and the on-screen bug, separably
overridable, with preview. Most of it already existed: `ChannelWatermarkImageSource.ChannelLogo`
resolves the channel's own logo artwork at render time at all three watermark precedence levels, and
`Custom` already provides the independent override. Production had already been running exactly this
pattern by hand — 43 channels pointing at one hand-made `Channel Bug` preset.
**Decision: seed that preset rather than add per-channel bug columns.** A watermark is a shared named
entity, so per-channel geometry would need either a dual-provider migration or one watermark row per
channel (under a unique-name index). Since `ChannelLogo` resolves per channel at render time, a single
shared row already delivers the user-visible behavior with no schema change.
- The seed **adopts** an existing `Channel Bug` row untouched, so an operator's tuned geometry is never
overwritten, and a `ConfigElement` marker (`watermark.channel_bug_seeded`) makes it run once per
database rather than once per name-absence — `ChannelWatermark` has no `IsSystem` flag and
`DbInitializer.Initialize` runs at every startup, so a name-only guard would resurrect a deliberately
deleted preset forever. Covered by `DbInitializerChannelBugWatermarkTests`.
- `WatermarkResponseModel` gained `imageSource` (additive under the frozen `/api/v1`, #286) so clients
identify logo-driven presets generically instead of matching a user-editable name. Note the limit:
once a *second* logo-driven preset exists, `imageSource` identifies the class but not *the* default,
so `findLogoBugWatermark` prefers the seeded name as a deterministic tiebreak.
- The default is applied by the SPA's quick-add **creation** path, not by inferring "newness" in the
editor — quick-add creates through the API and then navigates to the editor, so the editor only ever
loads an existing row. The editor toggle reflects the **referenced** watermark's `imageSource`; an
earlier draft searched the list instead, which (because `getWatermarks()` sorts by name) would have
silently repointed channels bound to a non-first logo-driven preset. Caught in independent review
and pinned by a regression test.
- The builder flow is covered on **fresh installs only**, by stamping the preset onto the system
channel templates the seed itself creates; existing installs' templates are never mutated.
- Server-side create defaulting was rejected: making an omitted `watermarkId` mean "give me a
watermark" would surprise machine clients of the frozen API. `POST /api/v1/channels/auto-tune` is
therefore unchanged.
- **Not fixed here:** external-URL logos never render a bug (`WatermarkSelector` `File.Exists`-checks a
URL). Pre-existing, lands in the FFmpeg render path, tracked as **#502**. This change only stops the
preview from promising it.
**Accepted trade-off:** every channel on the shared preset shares one geometry; per-channel tweaks mean
creating a second preset on the Watermarks screen.
@@ -0,0 +1,43 @@
---
key: locking.entitylocker-atomic-flags
title: '2026-07-11 — EntityLocker: atomic flags + single-owner release discipline, no owner tokens (#231)'
status: active
since: '2026-07-11'
supersedes: none
superseded-by: none
rule: '`EntityLocker` uses `Interlocked.CompareExchange`-guarded atomic flags plus a documented single-owner-release discipline (no owner tokens/leases); `Unlock*` on an already-unlocked slot returns `false` and logs a Warning rather than throwing.'
signals: 'six `bool`→`int` flags, `TraktController.EnqueueWithTraktLock`, `unlock: last` tail pattern · paths: `ErsatzTV.Infrastructure/Locking/EntityLocker.cs`, `IEntityLocker` · issues: #231, #232, #234, adversarial-reviewer#20/F5'
mechanics: '`ErsatzTV.Infrastructure/Locking/EntityLocker.cs`'
---
`EntityLocker` (process-wide singleton, `ErsatzTV.Infrastructure/Locking/EntityLocker.cs`) is the
advisory "operation on entity X is in progress" mutex layer. Adversarial review (#20/F5, →#231)
found three defects: six plain-`bool` flags with a non-atomic check-then-set (two threads could both
acquire and both return `true`), tokenless `Unlock*` letting any caller release another owner's lock
(e.g. `BuildPlayoutHandler` ignored `LockPlayout`'s return then unconditionally unlocked in
`finally`), and one batch taking a single `LockLibrary` released after the *first* of two enqueued
scan units (so the second ran unlocked).
**Model chosen: tokenless atomic flag + documented single-owner release discipline.** The six bools
became `int`s guarded by `Interlocked.CompareExchange` (0/1), so `Lock*`'s `bool` return is now a
reliable "this call won the transition" signal and the change event fires exactly once per
transition. The contract (XML-doc'd on `IEntityLocker`): a `true` from `Lock*` confers ownership of
exactly one release — performed either in the acquiring scope (`finally`, gated on the captured
bool) or by the single designated releaser the acquirer hands off to (the consumer of the message
enqueued while holding the lock, with a compensating unlock if the enqueue throws — the established
`TraktController.EnqueueWithTraktLock` / scheduler `unlock: last` tail pattern). Callers must never
release a lock they did not acquire. `Unlock*` on an already-unlocked slot returns `false`, fires no
event, and logs a Warning — the loud tripwire for double-release bugs; it does not throw or
`Debug.Assert`, because an advisory flag must stay safe to release in `finally` paths. The three
`ConcurrentDictionary`-backed kinds (Library/Playout/RemoteMediaSource) were already atomic
(`TryAdd`/`TryRemove`) and kept their semantics (the redundant `ContainsKey` pre-checks were dropped
as tidy-up); the interface signature is unchanged across its ~40 call sites.
**Rejected:** owner tokens/leases — the acquirer and releaser for Library/Trakt/Plex/Collections
locks are different code correlated only by entity id across in-memory `Channel<T>` queues, so a
token would have to travel inside ~8 background-request message types for a defect that discipline
plus the now-trustworthy atomic return value already prevents. Counted/reentrant locks — wrong
semantics: these are exclusive in-progress flags; two holders is the failure mode, not a feature.
Call-site fixes this model prescribes land separately: #232 (scan lifecycle — enqueue only after a
successful lock, compensating unlock on enqueue failure, one release per acquisition in batches) and
#234 (BuildPlayout/subtitles gate their `finally` unlock on the captured acquire result).
@@ -0,0 +1,37 @@
---
key: mcp.server-foundation
title: '2026-07-20 — MCP server (`ErsatzTV.Mcp`) built fresh over frozen `/api/v1`: read + cautious writes (#58)'
status: active
since: '2026-07-20'
supersedes: none
superseded-by: none
rule: '`ErsatzTV.Mcp` is a fresh stdio JSON-RPC server wrapping frozen `/api/v1` with explicit narrow per-endpoint tools, read-only-by-default enforced at runtime (`ERSATZTV_ALLOW_WRITES`), machine-key auth, and opt-in `If-Match`.'
signals: 'MCP server, tool catalog, read-only default, ERSATZTV_ALLOW_WRITES, X-Api-Key, If-Match round-trip · paths: `ErsatzTV.Mcp`, `docs/mcp.md` · issues: #58, #286, #197, #76, #289, #63, #64, #65, #66, #67, #68'
mechanics: '`docs/mcp.md`; `ErsatzTV.Mcp` tool executor and catalog'
---
The MCP server (issue #58, gated on #286 route-freeze + #197 security review, both closed) is built
**fresh** as `ErsatzTV.Mcp` — a stdio JSON-RPC server wrapping `/api/v1` — superseding the closed
read-only PR #76 rather than rebasing it. Full doc: `docs/mcp.md`. Decisions frozen:
- **Explicit, narrow tools over a generic HTTP passthrough.** Each tool maps to one OpenAPI-backed
endpoint; there is no "call any URL" tool. Each declared argument routes to exactly one place —
path `{param}`, an explicit query parameter, the reserved `ifMatch` header, or (write verbs only)
the JSON body — and `additionalProperties:false` rejects anything undeclared before a request is built.
- **Read-only is the default, enforced at runtime, not just by catalog shape.** The executor refuses
any non-GET tool unless `ERSATZTV_ALLOW_WRITES=true`, so one wrong catalog entry cannot mutate. This
is the PR #76/#289 posture carried forward verbatim, alongside the JSON-RPC DoS guards
(`-32700/-32600/-32602/-32603`, bounded stdin line reader), the per-request CTS covering headers
**and** the streamed body (the `ResponseHeadersRead` gotcha, #289), the response-size cap, and the
reverse-proxy prefix preservation.
- **Machine-key auth, CSRF-exempt.** The server sends `X-Api-Key` on every request and no `X-CSRF`
header (§9: key-authed requests are CSRF-exempt). `ERSATZTV_API_KEY` is effectively required.
- **`If-Match` is opt-in per call, and ETags are surfaced, not managed.** Only the replace-all PUTs
honor `If-Match` (here `update_collection_custom_order`); a response `ETag` header is appended to the
tool result as `[etag: "N"]` so an agent can round-trip it. All other writes force-write (§7a), so
no ETag handshake is needed — notably `add_collection_items`, whose re-add is an idempotent no-op.
- **Channel create/update is exposed** (28-field DTO): only `name`/`number`/`ffmpegProfileId` are
required, enums take the enum name and are API-validated, and `get_channel` makes the shape/current
values discoverable. **Deferred as too-large for a cautious v0.1** (not a contract gap): the ~40-field
schedule-item / playout replace-list writes. Redesign workflow tools (#63–#68) stay deferred until
their backend endpoints exist. No `/api` endpoint was added, so no OpenAPI regen.
@@ -0,0 +1,58 @@
---
key: media.lastscan-null-boundary
title: 2026-07-18 — Never-scanned `LastScan` surfaces as null at the API boundary, not the 0001-01-01 MinValue sentinel (#409)
status: active
since: '2026-07-18'
supersedes: none
superseded-by: none
rule: A never-scanned `LastScan` surfaces as `null` at the API/MCP boundary, not the `0001-01-01` MinValue sentinel — enforced by an ongoing read-boundary coercion plus a one-time data migration cleanup.
signals: 'LastScan, MinValue sentinel, API-boundary honesty · paths: `GetAllMediaSourcesForApiHandler.NormalizeLastScan`, `MediaSourceRepository` · issues: #409, #460'
mechanics: '`NullOutNeverScannedLastScan` migration; `LibrariesScreen.tsx` `hasScanned` removal; #460 write-side `null`'
---
`Library.LastScan` / `LibraryPath.LastScan` are `DateTime?`; a never-scanned library is `null` at
runtime for a freshly-created row. But the `0001-01-01 00:00:00` MinValue sentinel appeared in the
DB from **two** sources — both now closed:
1. Old `Reset_*` migrations wrote it via raw SQL (`UPDATE Library SET LastScan = '0001-01-01 00:00:00'`).
2. Live code wrote it on the Plex/Jellyfin/Emby remove-and-recreate (disable-sync) flows in
`MediaSourceRepository`. **Closed by #460**: those three writes now set `null`, so normal use no longer
produces new sentinel rows. Safe because every remaining `LastScan` reader either coalesces
(`LastScan ?? SystemTime.MinValueUtc`) for its own non-nullable scan-comparison, or is the read-boundary
coercion below — scan-comparison behavior is unchanged.
`GetAllMediaSourcesForApiHandler` projected `l.LastScan` straight onto its `DateTime?` DTO, so that
sentinel leaked to API/MCP clients as a fake midnight timestamp — the SPA papered over it with a
client-side year<1900 heuristic (#409 first pass). Decision: the API is the right place to be honest, so
**never-scanned reports as null** for API/MCP parity with the UI, via two layers:
- **Read-boundary coercion — the load-bearing guard** (provider/history-independent):
`GetAllMediaSourcesForApiHandler.NormalizeLastScan` maps any `< 2000-01-01` value to null. It stays
*permanent* even after #460 closed source #2: it is what makes the contract hold for sentinel rows this
codebase is not going to rewrite — a restored or hand-edited DB, and any row written by source #2
between #409's cleanup migration and #460 — and a migration-only fix would have regressed the next time
a user toggled a library's sync off.
- **Data migration** (`NullOutNeverScannedLastScan`, dual-provider): a one-time cleanup of the historical
residue — `UPDATE Library/LibraryPath SET LastScan = NULL WHERE LastScan IS NOT NULL AND LastScan <
'2000-01-01'`. Data-only (empty `Up`/`Down` otherwise, both `TvContextModelSnapshot.cs` byte-identical).
The `< '2000-01-01'` predicate matches the `0001-01-01` sentinel robustly on both providers (ISO-text
compare on SQLite, whatever the out-of-range zero date stored on MySQL — where no `Reset_*LastScan`
migration ever ran, so it's a safe no-op there) without depending on the exact stored bytes; no real
scan predates ErsatzTV. `Down` is a no-op — the original sentinel is unrecoverable and worthless.
`GetAllMediaSourcesForApiHandler` is the **only** API/MCP-facing consumer of `LastScan` (grepped
`LastScan` under `ErsatzTV.Application/**/Queries` and `ErsatzTV/Controllers`); the other reads are
internal scanner code that coalesces to `SystemTime.MinValueUtc` for its own non-nullable
`DateTimeOffset` scan-comparison needs and never serializes it to a client. The DTO field was already
`DateTime?`, so the OpenAPI schema is unchanged (no regen). The SPA's `hasScanned` heuristic in
`LibrariesScreen.tsx` was removed — both call sites revert to a plain null/truthy check now that the API
is honest.
**#460 (write side, 2026-07-25):** the follow-up this record deferred is done — the three disable-sync
writes set `null` instead of `MinValue`, so **no new** sentinel rows are created. Deliberately ships
**no** second cleanup migration: rows written by source #2 between #409's `NullOutNeverScannedLastScan`
and this change still hold the sentinel at rest, and the read coercion above (not a migration) is what
keeps the contract honest for them. Regression-tested per provider in
`MediaSourceRepositoryDisableSyncTests`. `LibraryPath.LastScan` is deliberately left untouched — the
disable-sync flows re-add `Paths` with their original values, and the only readers of the path-level
column are the local-library scan handlers, so it has no API surface and no remote-scan effect. This
*extends* the rule rather than reversing it: the read-boundary coercion is unchanged and authoritative.
@@ -0,0 +1,44 @@
---
key: media.remote-stream-probe-externaljson
title: '2026-07-20 — External-JSON playout channels now probe the remote-stream URL too, closing the #473 scope gap (#480)'
status: active
since: '2026-07-20'
supersedes: none
superseded-by: none
rule: 'External-JSON playout channels'' `StreamRemotely` now probes the remote-stream URL through the same `IRemoteStreamProber` seam as the generated-playout path, closing the #473 scope gap for a channel kind with no DB `PlayoutItem` rows.'
signals: 'external-JSON channels, remote-stream probe, scope-gap follow-up · paths: `ExternalJsonPlayoutItemProvider.StreamRemotely` · issues: #480, #473'
mechanics: '`ExternalJsonPlayoutItemProviderTests`'
---
The #473 fix (PR #479, the entry above dated 2026-07-19) probed Plex/Jellyfin/Emby remote-stream URLs in
`GetPlayoutItemProcessByChannelNumberHandler.ValidatePlayoutItemPath`, but explicitly scoped itself to the
**generated-playout** path and flagged `ExternalJsonPlayoutItemProvider` as the surviving hole (tracked as
#480). That provider builds its own `/media/plex/{server}/{plexFile}` URL in `StreamRemotely` and its result
is assigned in the handler *without* passing through `ValidatePlayoutItemPath`, so for external-JSON channels
a media item gone from the server still 404'd under ffmpeg (exit 8) and the same dead item was re-selected
for its whole slot. This closes that gap.
- **Fix location: the provider, not a new handler choke point.** `StreamRemotely` now probes the URL via the
same `IRemoteStreamProber` seam before returning it, mirroring the three generated-playout branches. The
handler-side single-choke-point option (route *every* `PlayoutItemWithPath` through one validator) was
rejected: the external-JSON provider constructs a **synthetic** `MediaItem`/`PlayoutItem` (from the JSON +
Plex API) whose path is already a `/media/plex/...` URL, so it does not fit `ValidatePlayoutItemPath`'s
local-path-then-per-provider-switch derivation without contortion. Per-provider probing keeps the blast
radius to one method; the fail-open **policy** (only a *redirected* 404 fails closed) lives entirely inside
`IRemoteStreamProber`, so the second call site duplicates only the *decision to probe*, not the policy.
- **Probe before the Plex metadata round-trip.** The URL depends only on `server.Id` + `program.PlexFile`,
not on the constructed `MediaItem`, so the probe runs *before* `GetPlexEpisode`/`GetPlexMovie`. A gone item
therefore also skips the Plex API metadata call (which would otherwise `throw NotSupportedException` on a
`Left`).
- **Error type + card sizing.** An unavailable stream returns `PlayoutItemNotAvailableFromMediaServer`, which
the handler already maps (case added in #473) to a real-error card rather than "Channel is Offline". One
behavioural difference from the generated path is accepted: external-JSON channels have **no DB
`PlayoutItem` rows** (their schedule is a JSON file), so the handler's `maybeNextStart` query returns none
and the error card falls to the existing 1-minute work-ahead clamp instead of spanning to the next item.
The worker then re-tunes and advances by time. That is repeated 1-minute error cards within a long dead
slot rather than one long card — strictly better than the exit-8 loop, and sizing to the next JSON program
would require parsing the schedule here (deferred, out of scope).
- **Tests.** `ExternalJsonPlayoutItemProviderTests` pins both directions (unavailable → `Left`
`PlayoutItemNotAvailableFromMediaServer` and no metadata call; available → `Right` with the `/media/plex`
URL). The fail-open contract itself stays pinned by `HttpRemoteStreamProberTests`. Proven non-vacuous by
neutralizing the probe guard and watching the unavailable assertion flip to `Right`.
@@ -0,0 +1,69 @@
---
key: media.remote-stream-probe
title: '2026-07-19 — Media-server remote-stream URLs are probed before use: a redirected 404 fails closed, everything else fails open, no toggle (#473)'
status: active
since: '2026-07-19'
supersedes: none
superseded-by: none
rule: '`ValidatePlayoutItemPath` probes the Plex/Jellyfin/Emby remote-stream URL via `IRemoteStreamProber` before returning it; only a redirected 404 fails closed (`PlayoutItemNotAvailableFromMediaServer`), everything else fails open, and there is no toggle.'
signals: 'remote-stream probing, fail-open/fail-closed, exit-8 ffmpeg loop · paths: `GetPlayoutItemProcessByChannelNumberHandler.ValidatePlayoutItemPath`, `IRemoteStreamProber` · issues: #473, #350, #480'
mechanics: '`HttpRemoteStreamProberTests` (`Should_Fail_Open_*` / `Should_Fail_Open_On_404_That_Was_Not_Redirected`)'
---
`GetPlayoutItemProcessByChannelNumberHandler.ValidatePlayoutItemPath` now probes the Plex/Jellyfin/Emby
remote-stream URL via the new `IRemoteStreamProber` seam before returning it, and on a 404 **from the media
server** returns the new `PlayoutItemNotAvailableFromMediaServer` error instead of a playable path.
- **The bug this closes.** The method's local branch checked `_fileSystem.File.Exists(path)`, but the
three remote-stream branches returned `http://localhost:{port}/media/{plex,jellyfin,emby}/{id}`
*unconditionally*. When the media was gone from the media server too, validation "succeeded", ffmpeg was
launched against a URL that 404s and died with **exit 8** — landing in `HlsSessionWorker`'s generic
ffmpeg-failure path, which sizes its error card to the failed **44s work-ahead chunk** and then
re-selects the *same* broken item, so a dead item produces a repeating error card for its whole slot
(~22 min for an episode). The fix restores the method's own invariant — every `PlayoutItemWithPath` it
returns has been checked for existence — so the failure now returns an **error** rather than a playable
URL, and the handler's error path sizes the card to run until the **next** playout item.
- **What the new `case` label does and does not do** (a review corrected an earlier draft of this entry):
the skip-to-next-item sizing comes from `maybeDuration`/`finish`, computed *before* the switch — the
`default:` arm already had it. Adding `case PlayoutItemNotAvailableFromMediaServer:` alongside
`PlayoutItemDoesNotExistOnDisk` changes only the **caption** on the card (the real error text instead of
"Channel is Offline"). Worth having, but not where the fix lives. A handler test asserts that message
specifically, so the label cannot silently decay into dead weight.
- **Scope: the generated-playout path only.** All three remote branches of `ValidatePlayoutItemPath` are
covered. `ExternalJsonPlayoutItemProvider` builds its own `/media/plex/...` URL and its result is
assigned *without* passing through `ValidatePlayoutItemPath`, so external-JSON channels keep the old
behaviour — tracked as #480 rather than silently claimed as fixed. The troubleshooting and
subtitle-extraction paths build the same URLs and are deliberately **left unprobed**: troubleshooting
should surface the raw ffmpeg failure, and a subtitle-extraction miss is not a channel outage.
- **Fail-open on everything except a *redirected* 404.** A timeout, a 5xx, an auth error or a transport
failure returns *available* — and so does a **404 that arrived without a redirect**. ErsatzTV's own
`InternalController` returns `NotFound` when the media source is unconfigured or momentarily missing, so
honouring that would fail *closed* for every item on that source; the first draft probed for "any 404",
which made this contract untrue. Only a 404 reached *after* the redirect to the media server is evidence
the item is gone. Pinned by tests (`Should_Fail_Open_*`,
`Should_Fail_Open_On_404_That_Was_Not_Redirected`) so a later refactor can't quietly invert it. Caller
cancellation is **not** swallowed — it propagates, because a shutdown is a genuine signal, not a probe
failure.
- **No `ConfigElement` toggle.** The behaviour strictly dominates the status quo, and the probe is bounded
by a 2s linked-CTS timeout (not `HttpClient.Timeout`, which defaults to 100s). A toggle would be surface
for a switch nobody has a reason to flip. **Not yet measured:** on an install without path replacement
every item selection pays this probe, and a slow media server could cost up to the full 2s. That is a
worst case rather than a typical one, but it has not been measured against #350's cold-start budget —
measure before assuming it is noise.
- **Rejected — resizing the `HlsSessionWorker` retry loop to skip the item on any ffmpeg failure.** It buys
the viewer nothing the above doesn't (the slot is dead either way) and cannot distinguish "this item is
dead" from "this transcoder hiccupped". Prod shows real *transient* channel-wide failures (VAAPI
`hwupload -22` / exit 234), and retrying those is correct; converting them into whole-item blackouts is a
regression. The retry loop is deliberately left intact — it also remains the backstop for the probe's
TOCTOU window (media can vanish between a 200 probe and ffmpeg's own request).
- **Rejected — writing `MediaItemState` from the streaming path.** Only the scanner writes `State`, and
breaking that ownership wouldn't even have fixed this: the failing item is `RemoteOnly`, which
`PlayoutBuilder`'s `PlayoutSkipMissingItems` skip does not exclude. On Jellyfin (`ServerSupportsRemoteStreaming`)
`RemoteOnly` is the *normal* state for every item when path replacement isn't configured, so it carries no
signal about playability — never key availability decisions off it.
- **Deferred — HEAD instead of GET.** The probe sends `GET` with `Range: bytes=0-0`, which on
`/Videos/{id}/stream?static=true` is a real (if minimal) playback request and may register a session or
touch play-state on some media-server versions. `GET` was chosen because HEAD support varies across
media servers — that is a reasonable prior but an *unverified* one. A HEAD-with-GET-fallback would avoid
the side effect; deferred rather than guessed at, since it trades a known-working request for an
untested one.
@@ -0,0 +1,95 @@
---
key: media.source-mgmt-write-api
title: 2026-07-11 — Media-source management REST write API + SPA (#202)
status: active
since: '2026-07-11'
supersedes: none
superseded-by: none
rule: Media-source management (local/Plex/Jellyfin/Emby) is a REST write API + SPA under `/app/libraries/*`, wrapping existing MediatR commands 1:1 with no new commands or DB migration; connection GETs never leak a stored `apiKey`, and each PUT-replace family's identity contract is documented per-family (not assumed uniform).
signals: '`RemoteConnectionResponseModel {hasApiKey}`, per-family identity contracts, Plex pin-flow polling, EntityLocker non-owner-token discipline · paths: `LocalLibrariesController`, `Plex|Jellyfin|EmbyMediaSourcesController` · issues: #202, #197, #231'
mechanics: '`docs/handoffs/` session record, issue #202'
---
Replaced the Blazor `/media/sources/{local,plex,jellyfin,emby}/...` pages (14 routes) with SPA
screens under `/app/libraries/*` over new write controllers (`LocalLibrariesController`,
`Plex|Jellyfin|EmbyMediaSourcesController`), wrapping existing MediatR commands 1:1 (no new
commands, no DB migration). Full design + adversarial-review reconciliation:
`docs/handoffs/` session record and issue #202. The design surfaced and fixed several pre-existing
Application/Infrastructure bugs newly reachable from a programmatic client; each is recorded here
because it changes documented behavior, not just adds a route.
**Secure `apiKey` contract (Jellyfin/Emby connection).** The connection GET
(`RemoteConnectionResponseModel`) returns `{ address, hasApiKey }` — the stored key **never**
leaves the server, closing a leak where the old design would have served the raw key from an
unauthenticated GET under any-origin CORS. On the connection PUT, a blank/omitted `apiKey` means
*retain the existing key*; a non-blank value sets a new one; the key is **required on first
connect** (no existing secret) → 422. Rationale: GETs aren't behind `X-Api-Key`
(`ApiKeyAuthorizationFilter` only guards mutating verbs), so a secret-bearing GET is a real
exposure regardless of how obscure the route is. **Stated explicitly as an input to #197** (the
planned read-side-auth review for secret-bearing GETs) — #197 should treat "does any GET return a
credential" as one of its checks, not just this one instance.
**Three list-replace identity contracts, not one uniform one.** An earlier draft assumed a single
"`Id<1`=add / missing=delete / id-preserved" contract across all three PUT-replace families; source
inspection proved that false for two of them:
- *Remote library sync preferences* (`PUT .../{id}/libraries`) — the command carries no source id
and the handler toggles only the ids present in the body; a row **absent** from the request is
left untouched, not deleted (libraries are sync-discovered, never created via this PUT, so there
are no `Id=0` adds either). The controller validates the submitted id set against
`Get{Family}LibrariesBySourceId(id)` (422 on any id not owned by the route's source — closes a
cross-source hole). Identity is **not stable across a disable**: `Disable{Family}LibrarySync`
removes and re-adds the row with a fresh id, so the SPA keys its draft to `(name, mediaKind)`,
never to `Id`, and refetches after every save (the PUT returns the reloaded list).
- *Path replacements* (`PUT .../{id}/path-replacements`) — id-based (existing `Id`=update,
`Id<1`=add, absent=delete) as documented, **but** the repo UPDATE SQL had no source-id predicate
(`WHERE Id = @id`, no `AND {Family}MediaSourceId = @id`), so a PUT to source A could silently
overwrite source B's row with the same numeric id. Fixed with a handler-level ownership guard
(reject any incoming positive id not in this source's current set → 422, no partial mutation)
**and** the repo SQL predicate itself (defense-in-depth for any other caller of that repo method).
- *Local library paths* (`PUT /api/libraries/local/{id}`) — identity is the **normalized path
string** (full path, trailing-separator/case-insensitive), not `Id`; `Id` in the request is
advisory. Renaming a path is delete-old+add-new under the hood (its `LibraryPath.Id` changes).
Kept as-is (matches the entrenched, tested Blazor behavior and how the SPA edits by value); not
rewritten to id-based identity, which would be a bigger, riskier change out of #202's scope.
**Plex pin-flow as REST: poll until the lock releases, exception-safe non-handoff unlock.** The
SPA polls `GET /api/media-sources/plex` rather than a per-pin status resource (no pin-addressable
server state exists to expose; SSE/push was already rejected, 2026-07-09). Polling contract is
`isLocked && !isAuthorized` = waiting on the user; `isLocked && isAuthorized` = finalizing
(discovering servers — **do not** stop here, the server list is still empty);
`!isLocked && isAuthorized` = success; `!isLocked && !isAuthorized` = timed out/abandoned. This
required fixing a **latent lock-leak bug**: `TryCompletePlexPinFlowHandler` threw
`OperationCanceledException` on its 2-minute timeout instead of returning `false`, and nothing
unlocked on that path — an abandoned sign-in wedged the Plex lock until restart or manual sign-out.
Fix releases `UnlockPlex()` on the timeout-throw, a poll-exception, and an enqueue-exception — but
**deliberately not** in an unconditional `finally`: on success the lock is handed off to
`SynchronizePlexMediaSources`, the sole releaser after server discovery; a blanket `finally` would
double-release and release *before* discovery completes, re-opening the same race the fix closes.
This is the same non-owner-token discipline as the #231 `EntityLocker` model (2026-07-11 entry
above), applied to the pin-flow's handoff-vs-terminal distinction specifically.
**404 comes from the controller pre-check, not the handler.** `Apply`/`ToEitherAsync` both
`.Join()` errors, which flattens any `NotFoundError` inside a joined `Validation` down to a plain
422. So every id-taking endpoint's real 404 is a controller-side pre-check
(`Get...ById(id)`-is-`None` → `ApiResults.NotFoundProblem`, the `TemplateController.DeleteGroup`
pattern), not a handler-level conversion — converting the joined validators to `NotFoundError`
would be dead code, since the join discards the distinction anyway. This is check-then-act (a
delete racing between the pre-check and the command falls through to the handler's own 422, not a
404); accepted and tested against the actual runtime error type rather than a hoped-for handler 404.
**"Scan All" dropped, not implemented.** The disabled SPA header button on the libraries hub was
speculative UI with **no Blazor equivalent** (`Libraries.razor` only ever supported per-library
scan). Removed rather than backed with a new bulk-scan endpoint; per-library scan (#232) and the
new per-source refresh-libraries endpoints (P8/J9/E9) cover the real capability set.
**App-owned popstate for guarded sub-path routes.** `LocalLibraryEditScreen` and the other
`/app/libraries/*` editors are the first screens to both register a dirty-navigation guard *and*
track their own sub-path pathname — the combination `spa-conventions.md` §8 had flagged as
unvalidated. React commits child passive effects before parent ones, so a sub-path wrapper that
self-registers `popstate` would fire (and switch sub-screen) *before* App's guard-restore listener
could veto. Resolution: App owns `popstate` centrally for the `libraries` route and only pushes an
approved sub-path down to the wrapper (which no longer self-registers `popstate`); on a vetoed pop
App re-pushes the pre-pop URL and the wrapper never sees the rejected path. This is scoped to the
`libraries` route only (gated on `activeRoute === 'libraries'`) so unguarded sub-path routes
(Playouts, Media) stay byte-identical. See `spa-conventions.md` §2/§8 for the updated exemplar
list and the resolved caveat text.
@@ -0,0 +1,30 @@
---
key: process.bom-format-detection-recipe
title: '2026-07-21 — BOM/format pre-push detection: use the `xxd` loop, and run `dotnet format --include` under `bash -c` (#542)'
status: active
since: '2026-07-21'
supersedes: none
superseded-by: none
rule: Before any push touching `.cs`, detect BOMs with the `xxd` byte check and verify the format gate with `dotnet format --include` run under `bash -c`, never bare zsh.
signals: 'UTF-8 BOM · `efbbbf` · `dotnet format --verify-no-changes` · `--include` · `mapfile` · zsh vs bash · #311 format gate · detector verification · paths: `ErsatzTV.sln`, `.editorconfig` · issues: #542, #311, #70, PR #402, PR #405'
mechanics: Local pre-push shell; CI's format job recipe (`ci.format-gate-folder-mode`). The BOM *policy* is `release.format-as-you-touch-rebase`; this record is the mechanics only.
---
Detection loop, verbatim:
```bash
for f in $(git diff --name-only origin/main...HEAD -- '*.cs'); do head -c3 "$f" | xxd -p | grep -q "^efbbbf" && echo "BOM: $f"; done
```
**Verify your detector.** An `od -An -c | grep '357 273 277'` check reported all-clean while 19 files
were actually dirty. A detector that can only say "ok" is worse than none — use the `xxd` form or read
the bytes directly, and distrust a clean result you didn't prove can go dirty.
**`dotnet format --include` DOES work here** — an earlier note claiming it silently no-ops was WRONG.
The apparent no-op was **the shell**: CI's recipe uses `mapfile`, which is bash-only, and the default
shell here is zsh → empty array → zero files → exit 0. Working form:
```bash
bash -c 'mapfile -t files < <(git diff --name-only --diff-filter=ACM origin/main...HEAD -- "*.cs")
dotnet format ErsatzTV.sln --no-restore --verify-no-changes --include "${files[@]}"' # exit 0 = gate passes
```
@@ -0,0 +1,16 @@
---
key: process.branch-off-feature-branch
title: 2026-07-21 — Fix onto an unmerged feature branch by branching off it and FF-pushing (#542)
status: active
since: '2026-07-21'
supersedes: none
superseded-by: none
rule: To fix work on an unmerged feature branch, branch off that branch and land by fast-forward push — and after creating a worktree, drive the first Edit/Read from ITS absolute paths and `git status` it before building.
signals: 'unmerged feature branch · FF-push · path leak · sibling worktree recon · wrong tree edits · "0 new tests" · paths: n/a · issues: #542'
mechanics: '`git worktree add -b <fix-branch> <path> <feature-branch>`; land with a fast-forward push to the feature branch. Never touch a sibling worktree that has the branch checked out (see `process.foreign-worktree-plumbing-merge`).'
---
The path-leak trap, hit on 2026-07-12 (#289 → PR#76): reconnaissance by `Read`ing a *sibling* worktree
leaked that tree's absolute paths into subsequent `Edit`s, so the edits landed there rather than in the
fresh worktree. `dotnet` then built the unchanged tree, and the tell was a build reporting "0 new
tests". A `git status` in the new worktree before the first build catches this in one command.
@@ -0,0 +1,20 @@
---
key: process.build-concurrency-limits
title: 2026-07-21 — Bound parallel builds by FREE RAM, never regenerate goldens (#542)
status: active
since: '2026-07-21'
supersedes: none
superseded-by: none
rule: Run at most 3–4 concurrent dotnet/npm builds on this Mac, gate launches on FREE RAM rather than CPU load, and never set `ETV_UPDATE_GOLDENS` / `ETV_UPDATE_PLAYOUT_GOLDENS`.
signals: 'build concurrency · memory_pressure · RAM starvation · fan-out cap · golden regeneration · paths: n/a · issues: #542'
mechanics: '`memory_pressure -Q` — under 20% free, do not launch more build agents; under 10%, pause or stagger. Host is an M4, 10 cores, 16 GB.'
---
Parallelize by default when slices are genuinely disjoint: use up to the client's concurrency limit,
normally 2–3 delegated agents plus the orchestrator. Fan out bounded recon early, but do not invent
artificial slices or exceed a runtime-imposed agent cap. CPU load spikes during builds are benign and
are not a stop signal; the resource that actually runs out is memory — the historic crash was RAM
starvation from an 8–9-way fan-out, which is why 5+ concurrent builds is never acceptable.
Separately: never set the golden-regeneration env vars. Regenerating goldens to make a diff disappear
hides exactly the change the golden net exists to surface.
@@ -0,0 +1,24 @@
---
key: process.codex-cheap-worker-launch
title: 2026-07-21 — Launch Codex cheap workers via `codex exec`, not `spawn_agent` (#542)
status: active
since: '2026-07-21'
supersedes: none
superseded-by: none
rule: For bounded tool-bearing selector/recon work, launch a Codex worker with `codex exec -m gpt-5.4-mini -c model_reasoning_effort=low -s read-only`; `spawn_agent` buys parallelism but no cost savings.
signals: 'codex exec · `--ephemeral` · `spawn_agent` · `gpt-5.4-mini` · `gpt-5.1-codex-mini` HTTP 400 · `model_reasoning_effort` · `~/.codex/models_cache.json` · paths: `~/.codex/models_cache.json` · issues: #542'
mechanics: Local `codex` CLI on this ChatGPT-authenticated host. Tested 2026-07-14.
---
The native `spawn_agent` interface currently exposes no model or effort selector, so it parallelises at
full cost. Launch a separate worker instead:
```
codex exec --ephemeral --color never -m gpt-5.4-mini -c model_reasoning_effort=low -s read-only -C <repo> "<hard-capped scope contract>"
```
GPT-5.4-Mini is the current supported small/cost-efficient profile on this host; the older
`gpt-5.1-codex-mini` guess fails with **HTTP 400**. Keep `low` whenever any shell/MCP/web tool is
exposed; reserve `minimal` for explicitly tool-free synthesis over already-supplied bounded evidence.
On model rollover, inspect the visible entries in `~/.codex/models_cache.json` rather than guessing
names, then update this record once verified.
@@ -0,0 +1,18 @@
---
key: process.consistency-fix-new-code-scrutiny
title: 2026-07-21 — "Make X consistent with Y" review findings are new code and get new-code scrutiny (#542)
status: active
since: '2026-07-21'
supersedes: none
superseded-by: none
rule: Review a "make X consistent with Y" change as new code, not as a mechanical copy — and for any timer or effect involved, ask explicitly "when does this fire?", including on mount.
signals: 'consistency fix · propagating the pattern propagates the bug · mount-debounce · timer/effect fire timing · paths: n/a · issues: #542, #216'
mechanics: n/a — a review-attention rule.
---
Propagating a pattern propagates its bugs. A #216 review fix copied MediaBrowseScreen's mount-debounce
selection-wipe race into SearchScreen instead of catching it — the finding said "be consistent", and
consistency was achieved, including with the defect.
The mount question is the cheap general form of the check: most of this class hides in an effect or
timer that fires once more than the author pictured.
@@ -0,0 +1,19 @@
---
key: process.enumerate-workaround-behaviors-before-deleting
title: 2026-07-21 — Before deleting a "workaround", enumerate every behavior it provided (#542)
status: active
since: '2026-07-21'
supersedes: none
superseded-by: none
rule: When an issue says "delete X", enumerate every behavior X provided before removing it — a workaround often serves a second purpose that outlives the first.
signals: 'remove the workaround · double duty · second purpose · bounded version re-scoped · PENDING_GRACE_TICKS · paths: n/a · issues: #542, #232'
mechanics: n/a — a scoping rule applied when reading a removal issue.
---
#232 said "remove the SPA `PENDING_GRACE_TICKS` grace-tick workaround now the API is honest". The grace
ticks did double duty: compensating the lying-200 (genuinely obsolete) *and* absorbing the inherent
queue→observed-active lag and fast-completion race (still needed). Removing them wholesale wedged the
scan button disabled-until-reload for short scans; both the fork and Codex caught it.
The landing move is usually not "delete" or "keep" but a bounded version re-scoped to the purpose that
still holds.
@@ -0,0 +1,15 @@
---
key: process.foreign-worktree-plumbing-merge
title: 2026-07-21 — Never commit or merge inside a worktree you did not create (#542)
status: active
since: '2026-07-21'
supersedes: none
superseded-by: none
rule: Never commit or merge inside a worktree another session created; land the merge with git plumbing against the branch ref instead.
signals: 'sibling session worktree · branch already checked out · plumbing merge · read-tree · commit-tree · GIT_INDEX_FILE · paths: n/a · issues: #542'
mechanics: '`git read-tree -m <base> <ours> <theirs>` into a temporary `GIT_INDEX_FILE` → `git write-tree` → `git commit-tree -p <ours> -p <theirs>` → push the resulting commit to the branch ref. The owning session then `git pull`s it.'
---
With several sessions on one machine, any given branch may already be checked out in a sibling
session's worktree. Touching that checkout writes into another agent's working state; the plumbing
route mutates only the ref, so the merge lands without disturbing the owner's tree.
@@ -0,0 +1,19 @@
---
key: process.harden-with-runtime-posture-not-clamp
title: 2026-07-21 — Harden a soon-to-grow feature with a runtime posture, not a hardcoded clamp (#542)
status: active
since: '2026-07-21'
supersedes: none
superseded-by: none
rule: When a security fix constrains a capability the roadmap will later want, make the safe state the DEFAULT OF A SWITCH rather than a wall — and read the feature's own issue for its end-state first.
signals: 'runtime posture · feature toggle · `ERSATZTV_ALLOW_WRITES` · read-only default · hardcoded clamp · roadmap end-state · paths: n/a · issues: #542, #289, #58'
mechanics: '`ERSATZTV_ALLOW_WRITES` env var, default `false`. See `mcp.server-foundation`.'
---
The MCP hardening in #289 could have shipped a GET-only clamp. But the MCP's final design (#58) is read
**and** write, so that clamp would have been ripped out within a release — churn plus a window where
the removal, not the original fix, is the risky change.
A runtime read-only posture (`ERSATZTV_ALLOW_WRITES`, default false) gives the same safety today and
grows into the intended design without a revert. The generalisable step is the cheap one: check the
feature's issue for its end-state *before* choosing between a switch and a wall.

Some files were not shown because too many files have changed in this diff Show More