Comment- and docs-only. Verified: the diff for both .cs files contains no non-comment line. HIGH (claim). "LIMIT n reads exactly n index entries and n rows" is false, and the bounded-physical- work reading it implied is false with it. Two mechanisms, both retracted explicitly in the record rather than quietly reworded: - MySQL purge lag. Deleted clustered-index records survive until purge runs and a range scan still traverses them, so returning 2,000 VISIBLE rows can touch far more index records. Deletion history therefore STILL affects physical work — precisely what attempt 4's failure was supposed to have made irrelevant. Attempt 5 removes the LOGICAL dependence on Id distribution; it does not make physical work independent of deletion history. - Unbounded row width. Artists/AlbumArtists are unrestricted TEXT/longtext and both SQLite and InnoDB spill to overflow pages, so a row count implies neither a byte count nor a page-read count. The guarantee is now stated as exactly what it is: at most 20,000 LOGICAL rows returned/materialized, and at most 10 round trips (11 for artist). The 392 KiB measurement is labelled one data point on one library whose credits average ~20 B/row, with an instruction to re-measure rather than extrapolate for long credit lists or MySQL over a network. Also noted: the query-shape test pins the SQL STRING — it cannot pin a plan, MVCC visibility work or payload I/O, and on MySQL using the index to satisfy ORDER BY is an optimizer choice, not a semantic guarantee. Precision fix, and the reviewer is right that the sloppy version misleads: "any predicate defeats LIMIT" is wrong, since the query's own cursor is a predicate. The distinction is a SEEKABLE PREDICATE ON THE ORDERING KEY (positions the scan, never discards a row) versus a RESIDUAL predicate (discards rows the engine already produced, so LIMIT bounds survivors and says nothing about how many were produced). Restated in the handler, the record and api-conventions. MEDIUM. docs/decisions.md still advertised "a superset LIKE pre-filter that may over-match but never under-match" for list-valued columns. That is the documented entry point for convention lookups, so a maintainer starting there was told to preserve exactly what round 4 deleted, in direct contradiction of the linked active record. Fixed. LOW. Two test comments read as current: "however many non-matching rows" (false past the ceiling — now bounded and cross-referenced to the test that pins that boundary) and the ampersand case "widens to the bare anchor" (no prefix predicate exists; kept because it is the input shape that broke the old scheme, now labelled as such). Fifth consecutive round of stale text, so this sweep was done by grepping the subject across handler, tests, record, decisions.md, api-conventions.md and the endpoint description: LIKE, ESCAPE, pre-filter, superset, over-match, under-match, anchor, keyspace, window, candidate, row cap, index entries, 392, and every deleted constant name. Every surviving hit is now either current-and-correct or explicitly framed as history.
219 lines
29 KiB
Markdown
219 lines
29 KiB
Markdown
# Decisions — lifecycle log
|
||
|
||
Purpose: why the codebase does what it does, so agents don't "fix" an established convention or
|
||
relitigate a settled call. **Update this doc (or a topic file under `docs/decisions/`) in the same
|
||
PR that changes any fact below** (or that establishes a new convention worth recording).
|
||
|
||
**Lifecycle, not append-only (ersatztv#521, supersedes the ersatztv#303 H9 append-only mechanic).**
|
||
A migrated record is an H2 whose first non-blank content line is a metadata block:
|
||
|
||
> YYYY-MM-DD — Title … (#issue)
|
||
> `` `key: area.topic` · `status: active` · `since: YYYY-MM-DD` · `supersedes: none` · `superseded-by: none` ``
|
||
> **Rule:** one-line current rule.
|
||
> **Signals:** concept · paths: a/b.yml · issues: #issue
|
||
> **Mechanics:** docs/some-doc.md → section
|
||
> \<rationale prose …\>
|
||
|
||
(Blockquoted here so the illustrative `key`/heading don't parse as a real record; the `## 2026-07-17
|
||
— No persistent compiler servers …` entry below is a live example.) Five metadata fields: `key`
|
||
(dotted, e.g. `ci.runner-placement`), `status`, `since`, `supersedes`, `superseded-by`. The
|
||
**`Signals:`** line is also required (validator-enforced, ersatztv#545): it is the keywords/paths
|
||
MemPalace's recall matches on, so a record without it ingests with weak recall metadata and
|
||
under-surfaces — pack it with synonyms, symbol names, `paths:`, and issue refs. `status` is
|
||
one of:
|
||
- **`active`** — the current, authoritative record for its `key`. Exactly one active record per key.
|
||
- **`superseded`** — reversed by a newer record; relocated to `docs/decisions/archive/` with
|
||
`superseded-by: <new key>` pointing forward, and the successor's `supersedes` pointing back.
|
||
- **`retired`** — no longer applicable (not reversed, just obsolete); also lives in the archive.
|
||
|
||
(An H2 with no metadata block is `legacy-unmigrated` — not yet ported to this schema. The validator
|
||
tolerates these and reports a count; they trend to zero over time, not required to hit zero at once.)
|
||
|
||
**Two optional fields (ersatztv#603).** `` `stale-after: YYYY-MM-DD` `` on the metadata line marks a
|
||
record that asserts a fact about the *outside world* as due for re-confirmation once that date
|
||
passes — an absolute date, no TTL. A **`Sources:`** line in the metadata block cites the evidence the
|
||
record rests on (a CI run, a measurement, a log line), as distinct from `Signals:`, which is recall
|
||
keywords. Both are optional and never required; a malformed `stale-after` is a blocking error (it
|
||
would silently never fire), but a *past-due* record is only a non-blocking notice — going stale is
|
||
the passage of time, not a defect in whatever commit happens to be under test. Dated records are
|
||
listed in the catalog's **Review due** section, which renders the date only and never a
|
||
clock-derived verdict.
|
||
|
||
**Supersession is same-PR, not a later consolidation pass**: add the new active record, then relocate
|
||
the predecessor's file (or section) into `docs/decisions/archive/` with the metadata above rewritten
|
||
to `status: superseded` (or `retired`) and the reciprocal `supersedes`/`superseded-by` links filled
|
||
in on both records. Never silently rewrite a record's rationale prose in place.
|
||
|
||
**Generated active catalog**: `docs/decisions/README.md` is generated from every `active` record
|
||
(this file + the topic files, excluding the archive) via `scripts/build_decisions_catalog.py` — run
|
||
it after any status change; CI's `decisions lifecycle` job fails on drift (`--check`).
|
||
|
||
**Enforcement**: `scripts/decisions_validate.py` checks metadata well-formedness (including a required
|
||
`Signals:` line), one-active-record-per-key, reciprocal links, that no record vanishes from the active
|
||
set without an archive copy, and that the active catalog is in sync. Separately, and as a
|
||
NON-BLOCKING warning rather than an error, it reports a per-record prose ceiling (ersatztv#620).
|
||
The aggregate active-corpus line budget that used to sit in this list — itself the replacement for
|
||
an older 1800-line floor on this single file — is RETIRED: the total is now printed as an
|
||
unthresholded trend notice only. The Husky `pre-commit` hook runs the structural checks over the working
|
||
tree; the CI `decisions lifecycle` job additionally runs the body-diff check with `--base`/`--head`.
|
||
|
||
**`Decisions-Edit: yes`** (a git **trailer** — the message's final paragraph, alongside
|
||
`Co-Authored-By:`; the pre-#609 `[decisions-edit]` substring is retired and no longer exempts
|
||
anything) is narrow: it is required ONLY when a commit changes the **rationale
|
||
prose** of a surviving or archived record — a factual correction to already-written history. Routine
|
||
lifecycle writes (adding a new active record, relocating a superseded/retired record to the archive,
|
||
updating metadata fields, regenerating the catalog) are token-free; the validator proves they're
|
||
legitimate structurally instead of gating on the token.
|
||
|
||
---
|
||
|
||
## Index
|
||
|
||
Decisions are split between the chronological log **in this file** and four **topic files** under
|
||
`docs/decisions/` — large same-topic clusters extracted at the v26.9.0 consolidation (full
|
||
rationale preserved). Check the relevant topic file below for its subject; otherwise scan the
|
||
in-file entries.
|
||
|
||
**Topic files:**
|
||
|
||
- [`decisions/optimistic-concurrency.md`](decisions/optimistic-concurrency.md) — ETag / If-Match / `Version` optimistic concurrency (#253, #259, #265, #269). Mechanics: `api-conventions.md` §7a–§7c.
|
||
- [`decisions/api-auth-security.md`](decisions/api-auth-security.md) — API/SPA auth & security posture (#197 bundles + #279 headers, #206, #283, #292, #295, #301, #319, #330).
|
||
- [`decisions/release-ci-governance.md`](decisions/release-ci-governance.md) — release / CI / merge governance (#303 H3/H4/H5/H6/H9/H10, #311, #314, #315, #335).
|
||
- [`decisions/spa-modularization.md`](decisions/spa-modularization.md) — App.tsx screen/shell extraction epic #243 (#244, #245, #247).
|
||
- [`decisions/workflow-process.md`](decisions/workflow-process.md) — session workflow, CI-run triage, review routing, worktree/parallel-session hygiene and tooling gotchas (#542). Extracted from the kickoff handoff doc, which was their only copy.
|
||
|
||
**In this file:**
|
||
|
||
---
|
||
|
||
gates, and cheaper to reason about than the version-string parsing in `NvidiaHardwareCapabilities`.
|
||
|
||
their backend endpoints exist. No `/api` endpoint was added, so no OpenAPI regen.
|
||
re-throw cancellation is a separate, pre-existing concern.
|
||
|
||
pre-fix shape, 15 912 of 20 000 rounds over-claimed.
|
||
|
||
**Per-viewer resume was deliberately not built.** `OnDemandCheckpoint` is a single value on the playout, so resume is per-channel, not per-viewer. #68 states per-channel suffices for a single household; multi-viewer identity would diverge from this model and is out of scope.
|
||
|
||
leaving the shared tree dirty is the one outcome that would make this script a net negative.
|
||
**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).
|
||
**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`).
|
||
`security.iptv-browser-token`.
|
||
external-process pipeline remains #563's.
|
||
|
||
picker; TZ-independence holds only for divisors of 60. See #77 (prior art) and #392.
|
||
|
||
**`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.
|
||
before there's a measured cost would be premature.
|
||
|
||
(`dateMacro.test.ts`, and the property test in `roundtrip.test.ts`, #438) rather than a special case.
|
||
|
||
## Records formerly in this file
|
||
|
||
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.
|
||
|
||
- 2026-06 — REST API wraps existing MediatR handlers 1:1, no service layer — [`api.mediatr-passthrough`](decisions/records/api/mediatr-passthrough.md)
|
||
- 2026-06 — UI rebuild is a React SPA (ChicoryTV) on the REST API, not a Blazor reskin — [`spa.spa-rebuild-decision`](decisions/records/spa/spa-rebuild-decision.md)
|
||
- 2026-07 — PUT-replace list endpoints derive `Index` from array order; alternate-schedules last row = catch-all default — [`api.put-replace-index-order`](decisions/records/api/put-replace-index-order.md)
|
||
- 2026-07 — Response DTOs live in `ErsatzTV.Core/Api`, file-scoped `#nullable enable` — [`api.response-dtos`](decisions/records/api/response-dtos.md)
|
||
- 2026-07 — Templates editor in the SPA is a table, not Blazor's drag-calendar — [`spa.templates-editor-table`](decisions/records/spa/templates-editor-table.md)
|
||
- 2026-07-07 — API artwork contract: rooted URLs produced server-side — [`api.artwork-rooted-urls`](decisions/records/api/artwork-rooted-urls.md)
|
||
- 2026-07-07 — Convention docs read at session start, updated in-PR — [`docs.convention-docs-session-start`](decisions/records/docs/convention-docs-session-start.md)
|
||
- 2026-07-07 — Decode-style endpoints take a row id and look up server-side — [`api.decode-by-id`](decisions/records/api/decode-by-id.md)
|
||
- 2026-07-07 — Season/episode/music-video drill-in via `parentId`, not new child-listing endpoints — [`api.parentid-drillin`](decisions/records/api/parentid-drillin.md)
|
||
- 2026-07-09 — "Table, not calendar" convention also covers the deco-templates editor — [`spa.deco-templates-table`](decisions/records/spa/deco-templates-table.md)
|
||
- 2026-07-09 — Channel numbers: prompt-driven sequential renumber instead of drag-to-reorder — [`spa.channel-renumber-prompt`](decisions/records/spa/channel-renumber-prompt.md)
|
||
- 2026-07-09 — Collection custom order: move up/down buttons, any-kind collections — [`spa.collection-custom-order-ui`](decisions/records/spa/collection-custom-order-ui.md)
|
||
- 2026-07-09 — OpenAPI spec mirrors the runtime Newtonsoft serializer (#198) — [`api.openapi-mirrors-runtime`](decisions/records/api/openapi-mirrors-runtime.md)
|
||
- 2026-07-09 — Per-playout "Schedule reset" button dropped; Reset uses the server-default build mode — [`spa.playout-reset-button`](decisions/records/spa/playout-reset-button.md)
|
||
- 2026-07-09 — Playback-troubleshooting completion feedback: poll status, no push channel — [`spa.playback-troubleshoot-poll`](decisions/records/spa/playback-troubleshoot-poll.md)
|
||
- 2026-07-09 — SPA gates Download Media Sample while a session is active — [`spa.download-sample-gate`](decisions/records/spa/download-sample-gate.md)
|
||
- 2026-07-09 — YAML playout validator: paste-textarea instead of a server file path — [`spa.yaml-validator-textarea`](decisions/records/spa/yaml-validator-textarea.md)
|
||
- 2026-07-09 — datetime-local instead of Chronic natural-language start parsing — [`spa.datetime-local-input`](decisions/records/spa/datetime-local-input.md)
|
||
- 2026-07-10 — Playout API mutations return 409 while the build lock is held (#215) — [`api.playout-build-lock-409`](decisions/records/api/playout-build-lock-409.md)
|
||
- 2026-07-10 — Schedule-item GET returns a flat, non-polymorphic DTO (`ScheduleItemResponseModel`) — [`api.schedule-item-flat-dto`](decisions/records/api/schedule-item-flat-dto.md)
|
||
- 2026-07-10 — Shared "Add to…" layer lives in `web/src/media/addTo/`; select-mode is an explicit toggle — [`spa.add-to-layer`](decisions/records/spa/add-to-layer.md)
|
||
- 2026-07-11 — Async-op API contract normalization + playout build observability + F9 scan endpoints (#235) — [`api.async-op-contract`](decisions/records/api/async-op-contract.md)
|
||
- 2026-07-11 — Blazor Server UI removed (#91 phase b) — [`blazor.ui-removed`](decisions/records/blazor/ui-removed.md)
|
||
- 2026-07-11 — Channel editor: bare-create entry point + external-logo mutual exclusion (#212) — [`spa.channel-editor-create-logo`](decisions/records/spa/channel-editor-create-logo.md)
|
||
- 2026-07-11 — EntityLocker: atomic flags + single-owner release discipline, no owner tokens (#231) — [`locking.entitylocker-atomic-flags`](decisions/records/locking/entitylocker-atomic-flags.md)
|
||
- 2026-07-11 — Legacy→SPA redirect matcher: exact map + ordered segment-template patterns (#204) — [`spa.legacy-redirect-matcher`](decisions/records/spa/legacy-redirect-matcher.md)
|
||
- 2026-07-11 — Logs column sorting: allow-listed `sortField`/`sortDirection` on `GET /api/logs` — [`api.logs-sort-params`](decisions/records/api/logs-sort-params.md)
|
||
- 2026-07-11 — Logs page-size is a client-local preference, not a server ConfigElement — [`spa.logs-page-size-local`](decisions/records/spa/logs-page-size-local.md)
|
||
- 2026-07-11 — Media-source management REST write API + SPA (#202) — [`media.source-mgmt-write-api`](decisions/records/media/source-mgmt-write-api.md)
|
||
- 2026-07-11 — Post-commit side effects run on `CancellationToken.None` (generalized from #251 to #254) — [`api.postcommit-cancellation-none`](decisions/records/api/postcommit-cancellation-none.md)
|
||
- 2026-07-11 — Pre-removal Blazor rollback tag `blazor-final` (#205) — [`blazor.rollback-tag`](decisions/records/blazor/rollback-tag.md)
|
||
- 2026-07-11 — Schedules SPA editor: draft/explicit-Save over instant-persist; Copy includes multi/smart/rerun; shuffled-GET normalization preserved — [`spa.schedules-editor-draft-save`](decisions/records/spa/schedules-editor-draft-save.md)
|
||
- 2026-07-11 — Trash "See all" reuses library-browse paging; search stays capped per kind (#213) — [`api.search-paging-cap`](decisions/records/api/search-paging-cap.md)
|
||
- 2026-07-12 — External-collections scans get an authoritative status surface (#271); the SPA timeout is retired — [`scan.collections-scan-status`](decisions/records/scan/collections-scan-status.md)
|
||
- 2026-07-12 — Live-E2E is a required step for API write-path handler changes (#303) — [`release.live-e2e-required`](decisions/records/release/live-e2e-required.md)
|
||
- 2026-07-12 — TopBar primary-action button: wire creates, drop the rest (#238) — [`spa.topbar-primary-action`](decisions/records/spa/topbar-primary-action.md)
|
||
- 2026-07-13 — API versioning: the whole `/api` surface is mounted at `/api/v1`, additive-only after freeze (#286) — [`api.versioning-v1`](decisions/records/api/versioning-v1.md)
|
||
- 2026-07-13 — Scheduling API hardening: null-name 500s, duplicate template items, unreachable 404 (#172) — [`api.scheduling-hardening`](decisions/records/api/scheduling-hardening.md)
|
||
- 2026-07-16 — Auto-tuning enumerates via EF, persists via SmartCollection; additive coexistence (#69) — [`sched.auto-tune-foundation`](decisions/records/sched/auto-tune-foundation.md)
|
||
- 2026-07-16 — Functional-E2E CI harness: advisory curl-contract job over an app booted from source (#299) — [`ci.functional-e2e-harness`](decisions/records/ci/functional-e2e-harness.md)
|
||
- 2026-07-16 — Optional advertised IPTV base URL (`iptv.base_url`) resolved centrally in the two generators (#340) — [`iptv.base-url`](decisions/records/iptv/base-url.md)
|
||
- 2026-07-16 — Per-playout reshuffle = scoped Reset build; seed surfaced (#71) — [`sched.reshuffle-scoped-reset`](decisions/records/sched/reshuffle-scoped-reset.md)
|
||
- 2026-07-17 — Auto-Tune DetailPanel member list = live search-index roll-up, not EF enumeration (#384) — [`sched.autotune-detailpanel-members`](decisions/records/sched/autotune-detailpanel-members.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-channel-overrides`](decisions/records/sched/autotune-per-channel-overrides.md)
|
||
- 2026-07-17 — Clock-boundary schedule padding already exists (FillerMode.Pad); #77 verified, convenience toggle deferred — [`sched.clock-padding-existing`](decisions/records/sched/clock-padding-existing.md)
|
||
- 2026-07-17 — Docs-only CI skip gates STEPS in always-running required jobs, never `if:`-skips them (#416) — [`ci.docs-only-skip-steps`](decisions/records/ci/docs-only-skip-steps.md)
|
||
- 2026-07-17 — Docs-only detect must be shallow-checkout safe: FETCH_HEAD + two-dot, not origin/main + three-dot (#416 follow-up) — [`ci.docs-only-detect-shallow-safe`](decisions/records/ci/docs-only-detect-shallow-safe.md)
|
||
- 2026-07-17 — Health-check remediation is server-declared `{Kind, Target}` on an additive DTO; the SPA acts on it (#164) — [`api.healthcheck-remediation-dto`](decisions/records/api/healthcheck-remediation-dto.md)
|
||
- 2026-07-17 — No persistent compiler servers in CI; every `services:` container gets an explicit cap; #390's small-lane move reversed (#406) — [`ci.runner-placement`](decisions/records/ci/runner-placement.md)
|
||
- 2026-07-17 — Pre-push guard: don't push a file whose working-tree copy is uncommitted (H13, #416 session) — [`release.prepush-clean-worktree-guard`](decisions/records/release/prepush-clean-worktree-guard.md)
|
||
- 2026-07-17 — Seasonal / date-conditional scheduling already exists (alternate schedules / playout templates); #73 closed as implemented — [`sched.seasonal-scheduling-existing`](decisions/records/sched/seasonal-scheduling-existing.md)
|
||
- 2026-07-17 — Shuffle-source construction extracted to `ShuffleSourceBuilder`; per-family seam, not a god-factory (#380) — [`sched.shuffle-source-builder`](decisions/records/sched/shuffle-source-builder.md)
|
||
- 2026-07-17 — Weighted / fair-share distribution is a new `WeightedShuffle` order; `ShuffleInOrder` is anti-clumping, not fair-share (#70) — [`sched.weighted-shuffle`](decisions/records/sched/weighted-shuffle.md)
|
||
- 2026-07-18 — Auto-Tune DetailPanel SPA: reusable `SlideOver` + shared advanced-options model; decorative panes dropped to match the backend (#386) — [`spa.autotune-detailpanel-slideover`](decisions/records/spa/autotune-detailpanel-slideover.md)
|
||
- 2026-07-18 — Auto-Tune per-source weights ride #70's MultiCollection machinery; created at tune time, not a post-hoc PUT (#425) — [`sched.autotune-per-source-weights`](decisions/records/sched/autotune-per-source-weights.md)
|
||
- 2026-07-18 — CI build-once was measured and rejected; keep the #420 tree-skip — [`ci.build-once-rejected`](decisions/records/ci/build-once-rejected.md)
|
||
- 2026-07-18 — Collapsible sidebar + nav-group accordions: two `ctv-sidebar-*` localStorage keys, labeled groups default-collapsed (#396) — [`spa.sidebar-collapsible-accordions`](decisions/records/spa/sidebar-collapsible-accordions.md)
|
||
- 2026-07-18 — Never-scanned `LastScan` surfaces as null at the API boundary, not the 0001-01-01 MinValue sentinel (#409) — [`media.lastscan-null-boundary`](decisions/records/media/lastscan-null-boundary.md)
|
||
- 2026-07-18 — Search all-items is paged to cap DoS exposure; SPA add-all pages to completeness (#293) — [`api.search-allitems-paging`](decisions/records/api/search-allitems-paging.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.playbackorder-support-matrix`](decisions/records/sched/playbackorder-support-matrix.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) — [`scan.zero-item-fetch-guard`](decisions/records/scan/zero-item-fetch-guard.md)
|
||
- 2026-07-19 — CI `test` job reports a sampled true peak-anon, not cache-inflated `memory.peak` (#412) — [`ci.peak-anon-measurement`](decisions/records/ci/peak-anon-measurement.md)
|
||
- 2026-07-19 — Health-check results are TTL-cached; `?refresh=true` forces a fresh run (#431) — [`api.healthcheck-ttl-cache`](decisions/records/api/healthcheck-ttl-cache.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`](decisions/records/media/remote-stream-probe.md)
|
||
- 2026-07-19 — The `format` gate runs `dotnet format whitespace . --folder`, not the full solution format (#469) — [`ci.format-gate-folder-mode`](decisions/records/ci/format-gate-folder-mode.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) — [`sched.weightedshuffle-editor`](decisions/records/sched/weightedshuffle-editor.md)
|
||
- 2026-07-20 (#489) — Jellyfin mixed-content libraries map to one library holding many kinds — [`scan.jellyfin-mixed-content-library`](decisions/records/scan/jellyfin-mixed-content-library.md)
|
||
- 2026-07-20 (#498) — QSV decode is split from QSV encode via a single `QsvPreferNativeDecoder` bool — [`ffmpeg.qsv-decode-encode-split`](decisions/records/ffmpeg/qsv-decode-encode-split.md)
|
||
- 2026-07-20 — External-JSON playout channels now probe the remote-stream URL too, closing the #473 scope gap (#480) — [`media.remote-stream-probe-externaljson`](decisions/records/media/remote-stream-probe-externaljson.md)
|
||
- 2026-07-20 — External-URL channel logos pass through to the graphics engine; never `File.Exists`-gated, never ffmpeg-native (#502) — [`ffmpeg.external-logo-graphics-engine`](decisions/records/ffmpeg/external-logo-graphics-engine.md)
|
||
- 2026-07-20 — HLS cold start is fixed with `-readrate_initial_burst`, not by raising the work-ahead limit (#350) — [`ffmpeg.hls-cold-start-burst`](decisions/records/ffmpeg/hls-cold-start-burst.md)
|
||
- 2026-07-20 — MCP server (`ErsatzTV.Mcp`) built fresh over frozen `/api/v1`: read + cautious writes (#58) — [`mcp.server-foundation`](decisions/records/mcp/server-foundation.md)
|
||
- 2026-07-20 — One logo drives the bug via a shared ChannelLogo preset, not new schema (#67) — [`iptv.logo-drives-bug-preset`](decisions/records/iptv/logo-drives-bug-preset.md)
|
||
- 2026-07-20 — Remote graphics-engine images are fetched through a bounded, pooled `IRemoteImageFetcher`; re-fetched per element init, not cached (#511) — [`ffmpeg.remote-image-fetcher-bounded`](decisions/records/ffmpeg/remote-image-fetcher-bounded.md)
|
||
- 2026-07-20 — `ILibraryRepository.GetOrAddFolder` resolves the folder from the DB, not the caller's `LibraryPath.LibraryFolders` navigation (#488) — [`scan.getoraddfolder-db-lookup`](decisions/records/scan/getoraddfolder-db-lookup.md)
|
||
- 2026-07-20 — `runs-on: small` means git-only; the two `docker build` jobs move to `ubuntu-latest` (server-management#639) — [`ci.small-lane-git-only`](decisions/records/ci/small-lane-git-only.md)
|
||
- 2026-07-21 — An on-demand time shift rebuilds the channel's cached XMLTV so the guide can't lag playback (#68) — [`scheduling.ondemand-guide-refresh-on-thaw`](decisions/records/scheduling/ondemand-guide-refresh-on-thaw.md)
|
||
- 2026-07-21 — Browser channel preview is a server-declared per-channel capability (#60) — [`api.channel-preview-capability`](decisions/records/api/channel-preview-capability.md)
|
||
- 2026-07-21 — Check the worked issue before the decision corpus; a closed tracker's comments need no retrofit (#524) — [`docs.tracker-comment-retrofit`](decisions/records/docs/tracker-comment-retrofit.md)
|
||
- 2026-07-21 — Decision records carry a lifecycle schema, validated by a script; append-only-by-diff is retired (#521) — [`docs.decision-lifecycle`](decisions/records/docs/decision-lifecycle.md)
|
||
- 2026-07-21 — External channel-logo URLs are downloaded and cached at save time; the render path never fetches a logo (#525) — [`graphics.channel-logo-caching`](decisions/records/graphics/channel-logo-caching.md)
|
||
- 2026-07-21 — Parallel orientation + selection is the startup protocol; #237 retired (#520) — [`startup.parallel-orientation`](decisions/records/startup/parallel-orientation.md)
|
||
- 2026-07-21 — QSV hardware-frame headroom is a floor, not an operator preference (#529) — [`ffmpeg.qsv-extra-hw-frames-floor`](decisions/records/ffmpeg/qsv-extra-hw-frames-floor.md)
|
||
- 2026-07-21 — Session end fast-forwards the shared checkout; a stale tree serves stale FILES (#541) — [`session.shared-checkout-refresh`](decisions/records/session/shared-checkout-refresh.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-atomic`](decisions/records/ffmpeg/work-ahead-slot-atomic.md)
|
||
- 2026-07-21 — `WorkAheadSlots.Release()` clamps before decrementing and reports unbalance in-band (#539) — [`ffmpeg.work-ahead-slot-release-never-negative`](decisions/records/ffmpeg/work-ahead-slot-release-never-negative.md)
|
||
- 2026-07-21 — `from-lineup` advanced overrides express "clear to none" via a typed `clear` enum list (#135) — [`api.from-lineup-clear-to-none`](decisions/records/api/from-lineup-clear-to-none.md)
|
||
- 2026-07-22 — Channel-level graphics-element attachment + seeded On Now/Next text element (#74) — [`graphics.channel-level-attachment`](decisions/records/graphics/channel-level-attachment.md)
|
||
- 2026-07-22 — Sequential (YAML) playout gets a golden; Scripted is excluded from the golden net by construction (#381) — [`testing.scripted-playout-golden-deferred`](decisions/records/testing/scripted-playout-golden-deferred.md)
|
||
- 2026-07-22 — per-schedule clock-boundary padding is a synthetic content-less Pad over the existing per-episode machinery (#392) — [`sched.clock-padding-schedule-toggle`](decisions/records/sched/clock-padding-schedule-toggle.md)
|
||
- 2026-07-23 — Channel health = a server-derived `health` object on the channel DTOs, built-timeline detection (#415) — [`api.channel-health-object`](decisions/records/api/channel-health-object.md)
|
||
- 2026-07-23 — Channel origin is immutable creation-provenance, stamped at insert, not a health signal (#414) — [`channel.origin-marker`](decisions/records/channel/origin-marker.md)
|
||
- 2026-07-23 — Facet-value typeahead is a new endpoint, allow-listed to text fields, no caching (#434) — [`api.search-field-values`](decisions/archive/api/search-field-values.md) (superseded by `api.search-field-values-sources`)
|
||
- 2026-07-23 — Relative-date rule builder operators are a frontend-only mapping onto existing Lucene macros (#435) — [`rulebuilder.relative-date-macros`](decisions/records/rulebuilder/relative-date-macros.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.projection-failure-sweep-guard`](decisions/records/scan/projection-failure-sweep-guard.md)
|
||
- 2026-07-25 — LibraryFolder identity is enforced by a unique index on `(LibraryPathId, PathHash)`, not an in-process lock (#491) — [`scan.libraryfolder-unique-identity`](decisions/records/scan/libraryfolder-unique-identity.md)
|
||
- 2026-07-25 — Music videos carry a per-library server identity; reconciliation is an itemId diff + soft trash (#496) — [`scan.musicvideo-server-identity`](decisions/records/scan/musicvideo-server-identity.md)
|
||
- 2026-07-25 — OKF evaluated and rejected as a replacement; two of its optional fields adopted (#603) — [`docs.decision-optional-provenance`](decisions/records/docs/decision-optional-provenance.md)
|
||
- 2026-07-25 — Rule-builder group nesting is bounded-arbitrary depth (`MAX_GROUP_DEPTH`), not one level (#436) — [`spa.rulebuilder-nesting`](decisions/records/spa/rulebuilder-nesting.md)
|
||
- 2026-07-25 — The rationale-edit marker is a git trailer, not a substring anywhere in the commit range (#609) — [`ci.decisions-edit-trailer`](decisions/records/ci/decisions-edit-trailer.md)
|
||
- 2026-07-25 — UI-E2E: headless Playwright flows in the existing `functional-e2e` job, browser baked into the CI image (#445) — [`ci.ui-e2e-harness`](decisions/records/ci/ui-e2e-harness.md)
|
||
- 2026-07-26 — Facet-value typeahead restated: every artist source covered; the JSON-column source is paged by row position with no residual SQL predicate (#578) — [`api.search-field-values-sources`](decisions/records/api/search-field-values-sources.md)
|