Files
ersatztv/docs/decisions.md
T
timothy 5f73cd4482 docs(578): scope the bound to LOGICAL ROWS — physical work is not bounded, and I claimed it was
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.
2026-07-27 03:10:36 +02:00

29 KiB
Raw Blame History

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:

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
  • 2026-06 — UI rebuild is a React SPA (ChicoryTV) on the REST API, not a Blazor reskin — spa.spa-rebuild-decision
  • 2026-07 — PUT-replace list endpoints derive Index from array order; alternate-schedules last row = catch-all default — api.put-replace-index-order
  • 2026-07 — Response DTOs live in ErsatzTV.Core/Api, file-scoped #nullable enableapi.response-dtos
  • 2026-07 — Templates editor in the SPA is a table, not Blazor's drag-calendar — spa.templates-editor-table
  • 2026-07-07 — API artwork contract: rooted URLs produced server-side — api.artwork-rooted-urls
  • 2026-07-07 — Convention docs read at session start, updated in-PR — docs.convention-docs-session-start
  • 2026-07-07 — Decode-style endpoints take a row id and look up server-side — api.decode-by-id
  • 2026-07-07 — Season/episode/music-video drill-in via parentId, not new child-listing endpoints — api.parentid-drillin
  • 2026-07-09 — "Table, not calendar" convention also covers the deco-templates editor — spa.deco-templates-table
  • 2026-07-09 — Channel numbers: prompt-driven sequential renumber instead of drag-to-reorder — spa.channel-renumber-prompt
  • 2026-07-09 — Collection custom order: move up/down buttons, any-kind collections — spa.collection-custom-order-ui
  • 2026-07-09 — OpenAPI spec mirrors the runtime Newtonsoft serializer (#198) — api.openapi-mirrors-runtime
  • 2026-07-09 — Per-playout "Schedule reset" button dropped; Reset uses the server-default build mode — spa.playout-reset-button
  • 2026-07-09 — Playback-troubleshooting completion feedback: poll status, no push channel — spa.playback-troubleshoot-poll
  • 2026-07-09 — SPA gates Download Media Sample while a session is active — spa.download-sample-gate
  • 2026-07-09 — YAML playout validator: paste-textarea instead of a server file path — spa.yaml-validator-textarea
  • 2026-07-09 — datetime-local instead of Chronic natural-language start parsing — spa.datetime-local-input
  • 2026-07-10 — Playout API mutations return 409 while the build lock is held (#215) — api.playout-build-lock-409
  • 2026-07-10 — Schedule-item GET returns a flat, non-polymorphic DTO (ScheduleItemResponseModel) — api.schedule-item-flat-dto
  • 2026-07-10 — Shared "Add to…" layer lives in web/src/media/addTo/; select-mode is an explicit toggle — spa.add-to-layer
  • 2026-07-11 — Async-op API contract normalization + playout build observability + F9 scan endpoints (#235) — api.async-op-contract
  • 2026-07-11 — Blazor Server UI removed (#91 phase b) — blazor.ui-removed
  • 2026-07-11 — Channel editor: bare-create entry point + external-logo mutual exclusion (#212) — spa.channel-editor-create-logo
  • 2026-07-11 — EntityLocker: atomic flags + single-owner release discipline, no owner tokens (#231) — locking.entitylocker-atomic-flags
  • 2026-07-11 — Legacy→SPA redirect matcher: exact map + ordered segment-template patterns (#204) — spa.legacy-redirect-matcher
  • 2026-07-11 — Logs column sorting: allow-listed sortField/sortDirection on GET /api/logsapi.logs-sort-params
  • 2026-07-11 — Logs page-size is a client-local preference, not a server ConfigElement — spa.logs-page-size-local
  • 2026-07-11 — Media-source management REST write API + SPA (#202) — media.source-mgmt-write-api
  • 2026-07-11 — Post-commit side effects run on CancellationToken.None (generalized from #251 to #254) — api.postcommit-cancellation-none
  • 2026-07-11 — Pre-removal Blazor rollback tag blazor-final (#205) — blazor.rollback-tag
  • 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
  • 2026-07-11 — Trash "See all" reuses library-browse paging; search stays capped per kind (#213) — api.search-paging-cap
  • 2026-07-12 — External-collections scans get an authoritative status surface (#271); the SPA timeout is retired — scan.collections-scan-status
  • 2026-07-12 — Live-E2E is a required step for API write-path handler changes (#303) — release.live-e2e-required
  • 2026-07-12 — TopBar primary-action button: wire creates, drop the rest (#238) — spa.topbar-primary-action
  • 2026-07-13 — API versioning: the whole /api surface is mounted at /api/v1, additive-only after freeze (#286) — api.versioning-v1
  • 2026-07-13 — Scheduling API hardening: null-name 500s, duplicate template items, unreachable 404 (#172) — api.scheduling-hardening
  • 2026-07-16 — Auto-tuning enumerates via EF, persists via SmartCollection; additive coexistence (#69) — sched.auto-tune-foundation
  • 2026-07-16 — Functional-E2E CI harness: advisory curl-contract job over an app booted from source (#299) — ci.functional-e2e-harness
  • 2026-07-16 — Optional advertised IPTV base URL (iptv.base_url) resolved centrally in the two generators (#340) — iptv.base-url
  • 2026-07-16 — Per-playout reshuffle = scoped Reset build; seed surfaced (#71) — sched.reshuffle-scoped-reset
  • 2026-07-17 — Auto-Tune DetailPanel member list = live search-index roll-up, not EF enumeration (#384) — sched.autotune-detailpanel-members
  • 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
  • 2026-07-17 — Clock-boundary schedule padding already exists (FillerMode.Pad); #77 verified, convenience toggle deferred — sched.clock-padding-existing
  • 2026-07-17 — Docs-only CI skip gates STEPS in always-running required jobs, never if:-skips them (#416) — ci.docs-only-skip-steps
  • 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
  • 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
  • 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
  • 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
  • 2026-07-17 — Seasonal / date-conditional scheduling already exists (alternate schedules / playout templates); #73 closed as implemented — sched.seasonal-scheduling-existing
  • 2026-07-17 — Shuffle-source construction extracted to ShuffleSourceBuilder; per-family seam, not a god-factory (#380) — sched.shuffle-source-builder
  • 2026-07-17 — Weighted / fair-share distribution is a new WeightedShuffle order; ShuffleInOrder is anti-clumping, not fair-share (#70) — sched.weighted-shuffle
  • 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
  • 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
  • 2026-07-18 — CI build-once was measured and rejected; keep the #420 tree-skip — ci.build-once-rejected
  • 2026-07-18 — Collapsible sidebar + nav-group accordions: two ctv-sidebar-* localStorage keys, labeled groups default-collapsed (#396) — spa.sidebar-collapsible-accordions
  • 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
  • 2026-07-18 — Search all-items is paged to cap DoS exposure; SPA add-all pages to completeness (#293) — api.search-allitems-paging
  • 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
  • 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
  • 2026-07-19 — CI test job reports a sampled true peak-anon, not cache-inflated memory.peak (#412) — ci.peak-anon-measurement
  • 2026-07-19 — Health-check results are TTL-cached; ?refresh=true forces a fresh run (#431) — api.healthcheck-ttl-cache
  • 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
  • 2026-07-19 — The format gate runs dotnet format whitespace . --folder, not the full solution format (#469) — ci.format-gate-folder-mode
  • 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
  • 2026-07-20 (#489) — Jellyfin mixed-content libraries map to one library holding many kinds — scan.jellyfin-mixed-content-library
  • 2026-07-20 (#498) — QSV decode is split from QSV encode via a single QsvPreferNativeDecoder bool — ffmpeg.qsv-decode-encode-split
  • 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
  • 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
  • 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
  • 2026-07-20 — MCP server (ErsatzTV.Mcp) built fresh over frozen /api/v1: read + cautious writes (#58) — mcp.server-foundation
  • 2026-07-20 — One logo drives the bug via a shared ChannelLogo preset, not new schema (#67) — iptv.logo-drives-bug-preset
  • 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
  • 2026-07-20 — ILibraryRepository.GetOrAddFolder resolves the folder from the DB, not the caller's LibraryPath.LibraryFolders navigation (#488) — scan.getoraddfolder-db-lookup
  • 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
  • 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
  • 2026-07-21 — Browser channel preview is a server-declared per-channel capability (#60) — api.channel-preview-capability
  • 2026-07-21 — Check the worked issue before the decision corpus; a closed tracker's comments need no retrofit (#524) — docs.tracker-comment-retrofit
  • 2026-07-21 — Decision records carry a lifecycle schema, validated by a script; append-only-by-diff is retired (#521) — docs.decision-lifecycle
  • 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
  • 2026-07-21 — Parallel orientation + selection is the startup protocol; #237 retired (#520) — startup.parallel-orientation
  • 2026-07-21 — QSV hardware-frame headroom is a floor, not an operator preference (#529) — ffmpeg.qsv-extra-hw-frames-floor
  • 2026-07-21 — Session end fast-forwards the shared checkout; a stale tree serves stale FILES (#541) — session.shared-checkout-refresh
  • 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
  • 2026-07-21 — WorkAheadSlots.Release() clamps before decrementing and reports unbalance in-band (#539) — ffmpeg.work-ahead-slot-release-never-negative
  • 2026-07-21 — from-lineup advanced overrides express "clear to none" via a typed clear enum list (#135) — api.from-lineup-clear-to-none
  • 2026-07-22 — Channel-level graphics-element attachment + seeded On Now/Next text element (#74) — graphics.channel-level-attachment
  • 2026-07-22 — Sequential (YAML) playout gets a golden; Scripted is excluded from the golden net by construction (#381) — testing.scripted-playout-golden-deferred
  • 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
  • 2026-07-23 — Channel health = a server-derived health object on the channel DTOs, built-timeline detection (#415) — api.channel-health-object
  • 2026-07-23 — Channel origin is immutable creation-provenance, stamped at insert, not a health signal (#414) — channel.origin-marker
  • 2026-07-23 — Facet-value typeahead is a new endpoint, allow-listed to text fields, no caching (#434) — api.search-field-values (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
  • 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
  • 2026-07-25 — LibraryFolder identity is enforced by a unique index on (LibraryPathId, PathHash), not an in-process lock (#491) — scan.libraryfolder-unique-identity
  • 2026-07-25 — Music videos carry a per-library server identity; reconciliation is an itemId diff + soft trash (#496) — scan.musicvideo-server-identity
  • 2026-07-25 — OKF evaluated and rejected as a replacement; two of its optional fields adopted (#603) — docs.decision-optional-provenance
  • 2026-07-25 — Rule-builder group nesting is bounded-arbitrary depth (MAX_GROUP_DEPTH), not one level (#436) — spa.rulebuilder-nesting
  • 2026-07-25 — The rationale-edit marker is a git trailer, not a substring anywhere in the commit range (#609) — ci.decisions-edit-trailer
  • 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
  • 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