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
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:
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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) |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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,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.
|
||||
@@ -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
Reference in New Issue
Block a user