Files
ersatztv/docs/decisions.md
T
timothy 8e83183b8b
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 15s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 18s
PR Gates / Docs update reminder (pull_request) Successful in 21s
PR Gates / decisions lifecycle (pull_request) Successful in 36s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m27s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m30s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m1s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Failing after 22m24s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
docs(491): migrate the decision record to the per-file corpus format
#610 split docs/decisions.md into one YAML-frontmatter file per record while
this branch was open, so the inline record could not be merged -- it had to be
converted. Same content and metadata, now at
docs/decisions/records/scan/libraryfolder-unique-identity.md with an index
entry and a regenerated catalog.
2026-07-25 21:16:51 +02:00

216 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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, that the active catalog is in sync, and an aggregate active-corpus line
budget (replaces the old 1800-line
floor on this single file). 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/records/api/search-field-values.md)
- 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)