PR Gates / CI image pin matches docker/ci (pull_request) Successful in 37s
PR Gates / Docs update reminder (pull_request) Successful in 40s
PR Gates / decisions lifecycle (pull_request) Successful in 45s
review-verdict/h10 Awaiting review verdict for fe00e0d
Review verdict / Set review-verdict status (pull_request_target) Successful in 14s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m23s
PR Gates / Script tests (pytest) (pull_request) Successful in 1m19s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m34s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m12s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 21m4s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 24m7s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Review round 5 returned BLOCKED with one High, and it needed no forgery and no #697 — just a branch name. `main)evil` IS A VALID GIT BRANCH NAME (`git check-ref-format --branch 'main)evil'` succeeds). A genuine human verdict earned while head H targeted it is written `(base: main)evil)`. Truncating at the first `)` yields exactly `main`, which matches a PR that has since been retargeted onto `main`, so the verdict is inherited over a completely different diff. I had asserted the opposite in a code comment one commit earlier — that a `)` in a branch name "mismatches — safe direction". That was generalised from `feat/foo)bar`, which does mismatch, and is false for EVERY branch whose name starts with the target base. Two attempts at extracting this value have now been defeated (`##` last-marker by an appended marker, `#` first-marker by this), so the lesson is the shape, not the off-by-one: do not parse a value out of user- or attacker-influenced text when you can compare against the exact expected literal instead. The description must now END with the literal `(base: <this PR's base>)` AND contain exactly ONE marker — the marker count kills the append trick without having to decide which occurrence is authoritative. Pure shell (`${#}` arithmetic), no truncation to abuse. Verified across all six shapes, including a PR that legitimately targets `main)evil` (accepted) and `(base: )` (rejected). Absent markers remain accepted, since verdicts predating #632 carry none. Mutation-verified: restoring the truncating parse reddens only the new paren test, while the appended-marker, matching-base and legacy tests stay green. 385 tests pass. Note for the record: pytest has never executed inside the review sandbox in any of the five rounds, so the suite has only ever been run here. Refs: #698 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
83 KiB
83 KiB
Active decisions — catalog / task router
The compact current view of settled decisions. Each row is an active record; follow
the link for rationale. Superseded/retired history lives in archive/. Regenerated by
scripts/build_decisions_catalog.py.
| 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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
api.mediatr-passthrough |
The REST API is thin controllers over existing MediatR handlers, with no new service/business-logic layer. | 2026-06 | link |
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 |
api.paging-zero-based |
pageNum is 0-based across the entire /api/v1 surface and every wrapper of it (MCP tool catalog, SPA hooks, docs); the page offset is always derived from the EFFECTIVE (bounded) pageSize, never the requested one, so a pageSize above an endpoint's cap narrows the page without widening the offset. The cap itself is per-endpoint (100 typical, 200 auto-tune members, 1000 search/all-items) and must not be documented as one number. A paging parameter description that omits or contradicts "0-based" is a defect. |
2026-07-25 | link |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
api.search-field-values-sources |
GET /api/v1/search/fields/{name}/values?q=&limit= returns distinct WHOLE values from the database for a narrow allow-list of catalog fields (never the Lucene term dictionary — analyzed TextFields store lowercased word tokens, e.g. "Science Fiction" → science/fiction, useless as a suggestion), 404 for an unknown field, a non-text field, or a text field with no distinct-value source (title, show_title only); limit clamped to [1, 50] (default 50). The FINAL filter, dedup and ordering applied to the response are ORDINAL (OrdinalIgnoreCase / StringComparer.Ordinal), never current-culture, because UseRequestLocalization makes the culture caller-controlled — scoped to the in-memory stages on purpose: a field sourced by a plain EF query is filtered and truncated by the DATABASE collation first (SQLite's LOWER() is ASCII-only), which ordinal semantics downstream cannot undo (ersatztv#668). A field whose values live in an EF primitive collection (one JSON array per row in a single column: SongMetadata.Artists, SongMetadata.AlbumArtists) is served, not 404'd, as bounded best-effort, and its rows are read by a keyset page carrying NO RESIDUAL predicate — SELECT Id, <col> AS Payload FROM SongMetadata WHERE Id > @AfterId ORDER BY Id LIMIT @Batch, no LIKE, no LOWER, not even IS NOT NULL. The cursor is itself a predicate, but a SEEKABLE one on the ordering key: it positions the scan and never discards a row. A RESIDUAL predicate discards rows the engine already produced, and LIMIT truncates only the survivors — so with one present it bounds the OUTPUT rather than the row count. All selectivity is in memory. The guarantee is scoped: at most 20,000 LOGICAL rows returned/materialized and at most 10 round trips (11 for artist) — NOT bounded physical work and NOT bounded bytes, because MySQL traverses deleted-but-unpurged index records and the TEXT/longtext payload width is unrestricted. The walk pages 2,000 rows at a time, stopping on the first of enough distinct matches, a short page, or the ceiling. |
2026-07-26 | link |
api.search-field-values-unicode-fold |
The EF-sourced facet fields (genre, show_genre, studio, director, writer, actor, tag, network, collection, video_codec, album, and artist's entity half) reach stored values whose prefix carries an uppercase non-ASCII character, on BOTH providers, with no row budget and no accepted loss. The defect was SQLite-only and ONE-SIDED: SQLite's LOWER() folds ASCII only (lower('Édith') is 'Édith' unchanged), so the predicate UNDER-matched, which no later stage can repair. MySQL was already correct — its LOWER() is Unicode-aware, so LOWER('Édith') really is 'édith' and the existing predicate reaches the row unaided. The fix is a SECOND, ADDITIVE query taken only when isSqlite && q contains a non-ASCII character: raw Dapper SQL SELECT DISTINCT <col> AS Value FROM <table> WHERE [<discriminator> AND] etv_upper(<col>) LIKE @Pattern ESCAPE '\' ORDER BY <col> LIMIT @Limit, where etv_upper is a SqliteConnection.CreateFunction scalar implementing ToUpperInvariant. Every other case — all-ASCII q, and MySQL for all q — runs today's EF query BYTE-IDENTICALLY. Keeping selectivity in SQL here is NOT the refuted family from api.search-field-values-sources: those four attempts bounded a walk around a predicate that could not be made correct over JSON escape text, whereas this is a correct fold on a plain column in an ordinary LIMITed query. It narrows that record's "Known limitation inherited, not introduced" clause; everything else it settles still holds. |
2026-07-27 | link |
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 |
api.selection-projection-include-chain |
Every handler that projects an aggregate carrying a tagged-union selection loads it through ONE shared <Aggregate>QueryExtensions include chain — RerunCollectionQueryExtensions.IncludeSelectionDetails(), joining the existing ProgramScheduleItemQueryExtensions.IncludeScheduleItemDetails() — called by the paged-list handler and the by-id handler alike, so the two cannot drift. The media-item flattening switch is likewise ONE shared helper, MediaCollections.Mapper.ProjectMediaItemToViewModel, covering all ten selectable media types including RemoteStream, whose named projection is MediaItems.Mapper.ProjectToNamedViewModel (it cannot be an overload of ProjectToViewModel(RemoteStream), which already exists returning the unrelated RemoteStreamViewModel; C# will not overload on return type). That switch NEVER ends in _ => null: a null MediaItem is the legitimate not-a-media-item case, while an unrecognized non-null subtype keeps its id and takes a conspicuous [unsupported media type: X] name. Fail-soft is deliberate — throwing would fail an entire paged GET over one unreadable row. Finally, every metadata navigation inside MediaItems.Mapper is read through Optional(...).Flatten() and degrades to the "???" placeholder, because those projections are reached from handlers whose include chains differ and a bare x.Season.Show.ShowMetadata is a latent 500 on some other caller GET. |
2026-07-28 | link |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
ci.exemption-provenance |
The three inputs the exemption decision rests on must each be bound to something the judged PR cannot mutate. (1) BASE — scripts/pr-changed-files.sh takes the expected base BRANCH as a REQUIRED 5th argument and re-reads it before and after paging, because /pulls/{n}/files diffs against the PR's live base and retargeting moves the answer without moving the head sha; the workflow passes github.event.pull_request.base.ref from the pull_request_target payload, which a retarget cannot rewrite. (2) BOT EXEMPTION — an author match is necessary but never sufficient: pull_request.user.login is the PR's immutable CREATOR while its head is not, so the exemption additionally requires EVERY changed path to be a dependency manifest (Directory.Packages.props or .config/dotnet-tools.json, and ONLY those — the npm manifests are excluded because package.json scripts are executed by CI). (3) INHERITED SUCCESS — the never-overwrite short-circuit fires only for a status POSITIVELY identified as a human verdict for THIS base, meaning a non-null .creator.login AND a Review-verdict: description AND, when that description records a base ((base: …), release.verdict-status-check), a base matching the PR's — tested by requiring the description to END with the exact literal (base: <base>) and to contain exactly ONE such marker, never by extracting a value (see below); a present-but-different base is rejected, an absent one is not, since verdicts predating that convention carry none; every other shape, including any unrecognised one, is re-derived rather than trusted. The bot and docs-only exemptions are evaluated as INDEPENDENT predicates and the decision made afterwards, never as an elif chain. edited is in the workflow's types: so a retarget reclassifies — which gives DETECTION, not atomicity: status writes are not serialized, so a stale run can still post over a fresher one (residual, #706). Path predicates are evaluated by COUNTING with grep -c, never | grep -q (SIGPIPE inversion) and never a here-string (temp-space failure) — see ci.grep-q-pipefail-inversion. |
2026-07-29 | link |
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 |
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 |
ci.gate-trigger-base-resolved |
The workflow that writes the branch-protection-required review-verdict/h10 status triggers on pull_request_target with branches: [main], never on plain pull_request. Gitea resolves a pull_request workflow DEFINITION from the PR's own head commit, so under that trigger a PR editing .gitea/workflows/review-verdict.yml ran its own rewritten copy and could post h10=success for itself; pull_request_target resolves the definition from the base instead. The branches: [main] filter is part of the rule, not a refinement of it: base resolution only relocates the rewrite from the head to the base, so without the filter a PR opened into an attacker-pushed base branch runs that branch's gate. pull_request_target is safe HERE only because this job never checks out or executes head-supplied code — it checks out base.sha and runs only that tree's scripts (ci.shared-pr-file-enumeration); reintroducing a head checkout under this trigger would be worse than the bug it fixed. This closes the rewrite route through THIS workflow and does NOT close the class: Gitea injects a write-capable GITEA_TOKEN into EVERY job, so any ref-resolved workflow — and a collaborator's own API token, since branch protection binds the context and not its issuer — can still forge review-verdict/h10. Tracked in #697; the exemption path has its own separate defects in #698. |
2026-07-28 | link |
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 |
ci.grep-q-pipefail-inversion |
In any script running under set -o pipefail, a security or classification predicate of the form producer | grep -q… is FORBIDDEN: grep -q exits at its first match, the producer then takes SIGPIPE and exits 141 once the data exceeds the pipe buffer (~64K), so pipefail reports the pipeline as FAILED even though grep MATCHED — inverting the predicate exactly when the input is large. A here-string (grep -q… <<< "$data") is ALSO forbidden: bash materialises a large here-string via temporary storage, so it fails when temp space is full or unwritable, and inside an if/! that failure flips the predicate the same way. COUNT instead — n=$(printf '%s\n' "$data" | grep -cE "$re") — because grep -c drains stdin (no early exit, no SIGPIPE) over an ordinary pipe (no temp file). Read grep's status honestly: exit 1 means a zero count and is a legitimate answer, anything >1 is a real error. Evaluate the counts ONCE at TOP LEVEL, never inline inside an if/elif condition: inside $( ) an exit leaves only the subshell and set -e does not fire, so an error silently reads as "no match". Validate that each result is numeric and fail closed if not. This applies to both the enforced gate .gitea/workflows/review-verdict.yml and the advisory hook .claude/hooks/pretooluse-merge-consent.sh. |
2026-07-29 | link |
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 |
ci.jq-version-contract |
Every shell gate that shells out to jq is authored to the jq 1.6-compatible subset, because the CI runner ships jq 1.6 while every developer Mac ships 1.8.x. scripts/jq-preflight.sh (no args) prints the parsed version and asserts a floor of 1.6 in every gate job's log; scripts/jq-preflight.sh --expect 1.6 additionally pins and fails loudly, but ONLY in the script-tests job. review-verdict.yml never pins — it writes the branch-protection-required review-verdict/h10 status, so a hard pin there would turn any jq bump into a repo-wide merge deadlock. |
2026-07-26 | link |
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 |
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 |
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 |
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 |
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 |
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 |
ci.script-tests-job |
The scripts/tests/ pytest suite runs on every PR as a dedicated script-tests job in pr-checks.yml (runs-on: small, setup-python + pip install pytest, PYTHONPATH=. python3 -m pytest scripts/tests -q), unconditionally rather than behind a scripts/** path filter, and never as a step inside decisions-guard — a job whose reds a standing rule instructs sessions to ignore must never host a gate whose reds are real. Any new CI gate must be reachable by a failure that is unambiguously attributable to it. |
2026-07-26 | link |
ci.shared-pr-file-enumeration |
A PR's complete set of changed file paths is computed by exactly one implementation, scripts/pr-changed-files.sh, called by both .claude/hooks/pretooluse-merge-consent.sh (advisory — a failure falls through to a human prompt) and .gitea/workflows/review-verdict.yml (enforced — a failure must fail closed, because a match here posts the branch-protection-required review-verdict/h10 status with nobody in the loop). The script owns exhaustiveness (pagination, rename/path validation, head-sha binding, and base-ref binding — see ci.exemption-provenance) and returns exit 0 only for a verified-complete list; it does NOT classify paths — each caller keeps its own docs-only allow-list, and the two allow-lists differ on purpose and stay separate. |
2026-07-26 | link |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
docs.corpus-size-signal |
The corpus's size signal is a per-record prose ceiling (decisions_validate.py --record-ceiling, default 60, chosen at a natural gap in the distribution), reported as a NON-BLOCKING ::warning:: naming each record over it. The aggregate prose total is still printed every run but carries NO threshold — it is a ::notice:: trend only — because a total over a monotonically growing corpus can only ratchet, and the generated catalog (docs/decisions/README.md) is no longer counted at all since it gains one row per record and cannot be consolidated away. Being listed by the ceiling is an invitation to check for REDUNDANCY, never an instruction to cut: a long record that is all distinct findings is a legitimate decline, and should be recorded as one. |
2026-07-26 | link |
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 |
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 |
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 |
docs.record-wing-parse-guard |
decisions_validate.py asserts, per PATH, that every *.md under docs/decisions/records/** and docs/decisions/archive/** parses to exactly one record carrying a key — an ERROR, not a warning, since a file in the record wings that is not a record is a mistake by definition. A file sitting DIRECTLY in archive/ is exempt only when it actually looks like a #610 stripped index — exactly one keyless record with a known generated heading — never merely by living there. The one other exemption, archive/README.md, is by exact RELATIVE PATH; nothing is ever exempt by BASENAME, since that would exempt the same filename in the active wing too. _read_frontmatter is deliberately NOT extended to accept YAML block scalars: every record value goes on ONE line, and the structural check is what makes that limitation loud instead of silent. |
2026-07-26 | link |
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 |
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 |
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 |
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 |
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 |
ffmpeg.qsv-hdr-tonemap-opencl |
the QSV pipeline never emits vpp_qsv=tonemap=1, which is a SILENT no-op on pre-Gen11 Intel graphics; HDR is tonemapped on the GPU via hwupload=derive_device=vaapi → scale_vaapi → hwmap=derive_device=opencl → tonemap_opencl when a VA-API device exists, the frames are still in software, and tonemap_opencl is available, and by the software TonemapFilter otherwise. The scale runs BEFORE the tonemap, and any hardware filter on the path forces the output to be re-tagged bt709. |
2026-07-26 | link |
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 |
ffmpeg.watermark-resolution-unified |
Every watermark WatermarkSelector resolves goes through one shared ResolveWatermark — the playout-item, channel and global precedence levels AND the deco path, for all three ChannelWatermarkImageSource values. An unresolvable watermark (missing file, un-migrated external URL, or no logo artwork) resolves to no on-screen bug plus a warning, never a dead path or a URL handed downstream; the one deliberate exception is a playout-item Custom with a blank image, which still falls THROUGH to channel/global. The generated-initials fallback is therefore off everywhere, including the deco path where it demonstrably rendered. Watermarks built OUTSIDE the selector (the song-progress overlay, #653) are not covered and remain unchecked. |
2026-07-26 | link |
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 |
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 |
graphics.channel-level-attachment |
A channel can attach GraphicsElements 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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
process.parallel-session-claim |
Before starting an issue, check for an existing claim four ways — open PRs referencing it, remote branches naming it, recent comments (a claim can precede the label), and a fresh git fetch origin main — then claim with the in-progress label plus a comment. A claim prevents duplicate PICKUP, not duplicate WORK. Re-fetch origin/main before every push, not only at branch time. |
2026-07-21 | link |
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 |
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 |
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 |
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 |
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 |
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.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.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.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 |
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.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.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 |
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.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 against the verdict's OWN @ <sha> field, marker at COLUMN 0 (no indent, so indented code blocks cannot self-approve), whole-word verdict token, fenced code blocks stripped with markdown fence-length semantics, negative wins over positive on the same head); folds into the H6 merge-consent hook as condition (c). The grammar lives in ONE tested place, scripts/check-review-verdict.sh — #629 found three false-opens that survived because it was implemented inline and untested while this record described stricter behaviour than the code had. |
2026-07-12 | link |
release.verdict-status-check |
The H10 review verdict is written as a review-verdict/h10 Gitea commit status on the exact reviewed sha by scripts/post-review-verdict.sh, and that context is a REQUIRED status check on main. Because a status belongs to one sha, a later commit cannot inherit it, so Gitea's own merge_when_checks_succeed refuses to merge a head no one reviewed. The PreToolUse hook additionally refuses to SCHEDULE an auto-merge unless that status is already green on head. A pull_request_target workflow auto-passes the two exempt classes (Renovate-authored, docs-only) unless the PR touches a protected path (.claude/, .gitea/, .husky/, scripts/, docker/ci/). This extends — does not supersede — release.review-verdict-gate (#303 H10), whose comment convention remains the human-readable artifact and the hook's condition (c). |
2026-07-25 | link |
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 |
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 |
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 |
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 |
scan.libraryfolder-unique-identity |
LibraryFolder uniqueness per (LibraryPathId, Path) is enforced by a database unique index over a SHA-256 PathHash (Path is unbounded and not portably indexable), and LibraryRepository.GetOrAddFolder/SetEtag tolerate the constraint violation by re-reading and adopting the winner's row. |
2026-07-25 | link |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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.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 |
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 |
spa.channel-renumber-prompt |
Channel renumbering uses a sequential prompt()-driven "Renumber" action instead of drag-to-reorder. |
2026-07-09 | link |
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.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 |
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 |
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 |
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 |
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 |
spa.library-pickers-resolve-by-search |
A picker over a media-library table (Episode/Song/Image/Movie/MusicVideo/TelevisionShow/TelevisionSeason/Artist/OtherVideo/RemoteStream) resolves its options by SEARCH — a debounced SearchPicker calling searchLibraryPickerOptions, which issues at most ONE getLibraryBrowseItems request per settled query, bounded to LIBRARY_PICKER_RESULTS (25) rows — CLAMPED inside the helper, not merely defaulted — and gated on LIBRARY_PICKER_MIN_QUERY (2) characters. It list-loads NOTHING on mount or on a type switch, so there is no truncation to surface and no truncation hint. The typed text is COMPILED (titleContainsQuery → title:*<escaped>*), never forwarded raw. The current selection renders from the OWNING RECORD, not from the result set (selectedName on a rerun collection / playlist item; a single by-id detail read — getShow/getSeason/getArtist — for a filler preset, which stores only the id), and an edit draft is INITIALIZED ONCE from the detail read — never seeded from the list row, never reconciled against a late response — with the form withheld until it lands, the editor failing CLOSED when the response carries no USABLE concurrency token — absent, empty and whitespace-only ETags are ONE case, normalized in one place, so a PUT without If-Match is unreachable, and a deadline plus a route back so a hung request cannot strand it. An id NEVER travels without its namespace: search results are cached against (source, query) and list-backed options carry the type they were loaded for, so no id from one type can be offered under another; and every id entering editor state — search result, list-backed option, or a selection restored from a detail read — passes ONE shared isSelectionId (int32) predicate at that boundary, an unbindable id being treated as ABSENT rather than coerced. Conflicts are detected at SAVE time via If-Match -> 412 -> Reload, and Reload simply drops the draft back to null and re-runs the same initialize-once load, so the form is unmounted while the replacement is in flight; an asynchronously-resolved name is keyed to the id it was resolved for and never overwrites a label naming a different id. The typeahead implements the full ARIA combobox keyboard contract, because it replaces a natively keyboard-operable <select>. The other half of the superseded record is UNCHANGED: bounded-by-construction admin lists (collections, multi-collections, smart collections, playlists) still page to completeness via loadAllPages and still report complete/hint: incomplete. Server-side caps are not raised — this is a web-only change. |
2026-07-26 | link |
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 |
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 |
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 |
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.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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
testing.enumerating-guard-identity-not-position |
A guard that cross-checks a hand-reviewed registry against call sites discovered across the whole repo must key each entry on properties INTRINSIC to the site — file, kind, and the value source text — and never on its absolute line or column. A registry keyed on position is a function of every other file in the repo, so a branch that never touches the guard can invalidate it; and because each PR is green against its own base, that failure is structurally invisible pre-merge and lands on main after review and after the merge gate. Dropping the position keeps every mutation the guard exists for — a NEW site, a REMOVED site and a CHANGED value each still fail, since each changes the identity multiset — and costs exactly ONE case, which must be stated rather than implied: a SAME-IDENTITY SUBSTITUTION within one file (delete a registered site, add a different unreviewed one with the same kind and value token, net-zero count) now passes. A REPORTED failure still prints the discovered line:column, because identity and diagnostics need not share a format. Comparison stays a MULTISET count rather than set membership, so two sites in one file sharing an identity must be discovered exactly that many times and a third occurrence still fails. A SCANNER test that asserts real AST positions against FIXED inline fixtures is the opposite case and keeps its line/column identity — it has no churn, because its input does not move. |
2026-07-27 | link |
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 |
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 |
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 |
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 |
Review due
Active records that assert facts about the outside world and carry a stale-after date.
Once that date passes, re-confirm the fact and either extend the date or supersede the
record. Sorted soonest-first.
| Stale after | Key | Record |
|---|---|---|
| 2027-01-15 | ci.runner-placement |
link |
| 2027-02-15 | ci.infra-shaped-red-under-load |
link |
| 2027-03-15 | ci.peak-anon-measurement |
link |