Files
ersatztv/docs/decisions.md
T
timothyandClaude Opus 5 fefd11dffe
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 20s
PR Gates / Docs update reminder (pull_request) Successful in 28s
PR Gates / decisions lifecycle (pull_request) Successful in 30s
Review verdict / Set review-verdict status (pull_request) Successful in 12s
PR Gates / Script tests (pytest) (pull_request) Successful in 42s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m31s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 18m33s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21m27s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m24s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
review-verdict/h10 Review-verdict: MERGEABLE @ fefd11d
fix(620): signal corpus size per RECORD; the aggregate becomes an unthresholded trend
Squashed from 7 commits (4 review rounds) to keep the rebase onto #621 tractable; the
full round-by-round history is on PR #642.

corpus was 5658/5600 — over budget and warning again — 3h35m after #619 put it at 5228,
with nobody consolidating anything.

So this does NOT re-baseline. An aggregate over a monotonically growing corpus can only
ratchet; that is the "permanently red, therefore no signal" state #542 re-baselined away
from, and growth is not even a smooth rate to plan against (the corpus FELL from 5089 to
5042 across four days, then gained 427 in one evening as two large records landed).

Replaces it with a per-record prose ceiling (default 60), non-blocking, naming each
record over it — not monotonic, so it can go red AND green, and it points at a file. The
aggregate is still printed as an unthresholded trend notice, with record prose and
non-record scaffolding reported separately because they are not the same unit. The
GENERATED catalog is no longer counted at all: it gains one row per record and no
consolidation can shrink it, which made the metric partly a record COUNT in a line-count
costume.

The calibration test took FOUR versions, and the failures are the durable lesson:
  v1  true by construction (`max(under) <= 60 < min(over)` over lists built by that test)
  v2  a gap WIDTH — a ceiling of 200 also sits in a wide gap, so it passed
  v3  fraction band + "clear air" vs the nearest record above — hostage to an unrelated
      record: one ordinary 62-line addition reddened it with the ceiling correctly
      placed, and the only remedy was to RAISE the ceiling. That is this very treadmill,
      as a hard failure in what #631 makes a blocking job.
  v4  `p90 <= ceiling <= p95` — the property stated directly and scale-free.
Two rules recorded: a guard test must depend only on the thing it guards, and a threshold
over a growing population must be expressed in that population's own terms.

Candidates: all over-ceiling records assessed, each actioned or declined with a reason.
The largest (scan.libraryfolder-unique-identity, 230 lines) is a legitimate DECLINE — a
dozen-odd distinct traps whose only copy that is. Nothing pruned, so no archive or
supersession was required. An automated redundancy metric is explicitly rejected.

Also: `--budget` is accepted but announces its retirement rather than no-opping silently;
the dead `budget_ok` parameter is gone; and five "untresholded" typos are fixed, one of
which was propagating into the generated catalog row and MemPalace's per-key drawer.

Refs #620

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 13:57:01 +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
  • 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