Review of 1b78dc9e found the pre-filter's correctness claim was false, and the claim was in the decision record as well as the code. F1 (high). The pattern JSON-encoded the whole query prefix on the reasoning that the stored text escapes non-ASCII, so encoding the prefix the same way would line up. It does not: SQL LOWER() lowercases the *escape text* (`É` -> `é`); it cannot case-fold the codepoint that escape denotes. So `q=é` built `%"é%`, the stored `Édith Piaf` never matched, and the row was discarded before the in-memory filter could accept it. Every accented artist — Beyoncé, Björk, Sigur Rós, Édith Piaf — was silently unsuggestable, which in a music library is the common case. The invariant that was missing, now stated in the code: the SQL pre-filter is an OPTIMIZATION. It may over-match; it must never under-match. Correctness lives in the in-memory filter. So the pattern now narrows only on the leading run of characters the JSON writer stores verbatim and stops at the first character it cannot prove — `q=Beyoncé` still narrows on `beyonc`, `q=é` narrows on nothing and leans on the row cap. Soundness rests on two facts now asserted by exhaustive computation rather than argued: no non-ASCII codepoint in U+0080..U+10FFFF OrdinalIgnoreCase-equals a printable ASCII character (false for InvariantCultureIgnoreCase, which folds ~190 — the choice of Ordinal is load-bearing), and the exact set of ASCII the encoder escapes. F1b. `UseRequestLocalization` honours Accept-Language, so the culture was caller-controlled and `ToLower()` plus the default linguistic `StartsWith(string)` let a header change the answer. Comparison is now OrdinalIgnoreCase and ordering StringComparer.Ordinal throughout — including the shared FilterSortTake that state/video_dynamic_range/content_rating also use. Sets unchanged, order now ordinal rather than culture-dependent. F2. The merge comment asserted an exactness the code does not have: sources truncate by their own ordering (DB collation / primary key), not the merge's, so a dropped value can outrank a survivor. Comment and record now say best-effort, exact only below the truncation points. F3/F4. The cap now rides `ORDER BY Id` rather than the JSON column: MySQL sorts TEXT by only max_sort_length bytes, so the old ordering was not deterministic there, and sorting the whole matching set was avoidable work. What the cap still does NOT bound is the scan — a leading-wildcard LIKE cannot seek an index — so that cost is now documented as accepted, with a normalized `SongArtist` table named as the follow-up candidate rather than left implicit. Every clause above is covered by a test verified to FAIL when that clause is mutated (old pattern builder: 5 red; culture chain: 3 red; cap=3 / cap=limit / ORDER BY json / no cap: red each). F5. Converted to a proper supersession. The old record did not merely hold a stale fact — it recorded song/music-video credits as an "intentionally-uncovered gap" and album_artist as unsupported, and this reverses that call, which `docs.decision-lifecycle` says is never a line-edit. `api.search-field-values` is archived with its original prose restored, and `api.search-field-values-sources` replaces it carrying the whole endpoint contract.
29 KiB
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 itskey. Exactly one active record per key.superseded— reversed by a newer record; relocated todocs/decisions/archive/withsuperseded-by: <new key>pointing forward, and the successor'ssupersedespointing 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— ETag / If-Match /Versionoptimistic concurrency (#253, #259, #265, #269). Mechanics:api-conventions.md§7a–§7c.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— release / CI / merge governance (#303 H3/H4/H5/H6/H9/H10, #311, #314, #315, #335).decisions/spa-modularization.md— App.tsx screen/shell extraction epic #243 (#244, #245, #247).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 - 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
Indexfrom 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 enable—api.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/sortDirectiononGET /api/logs—api.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
/apisurface 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
WeightedShuffleorder;ShuffleInOrderis 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
LastScansurfaces 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
testjob reports a sampled true peak-anon, not cache-inflatedmemory.peak(#412) —ci.peak-anon-measurement - 2026-07-19 — Health-check results are TTL-cached;
?refresh=trueforces 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
formatgate runsdotnet 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
QsvPreferNativeDecoderbool —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.GetOrAddFolderresolves the folder from the DB, not the caller'sLibraryPath.LibraryFoldersnavigation (#488) —scan.getoraddfolder-db-lookup - 2026-07-20 —
runs-on: smallmeans git-only; the twodocker buildjobs move toubuntu-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-lineupadvanced overrides express "clear to none" via a typedclearenum 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
healthobject 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 byapi.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-e2ejob, browser baked into the CI image (#445) —ci.ui-e2e-harness - 2026-07-26 — Facet-value typeahead restated: every artist source covered; list-valued columns via a superset LIKE pre-filter that may over-match but never under-match (#578) —
api.search-field-values-sources