Compare commits

...
Author SHA1 Message Date
timothyandClaude Opus 4.8 928784ba48 fix(135): from-lineup advanced overrides can express "clear to none"
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 17s
PR Gates / Docs update reminder (pull_request) Successful in 19s
PR Gates / decisions lifecycle (pull_request) Successful in 22s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13m44s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 17m8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 20m34s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m41s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
CreateChannelFromLineupHandler resolved every advanced override with
advanced.X ?? template.X, so null always meant INHERIT and a channel could
not drop a template-set watermark / filler / preferred language. Add an
optional typed `clear` enum list to CreateChannelFromLineupAdvancedOptions:
omitted/null still inherits (byte-stable for existing clients), a field named
in `clear` is forced to none. Set+clear of the same field is a 422.

The enum (CreateChannelFromLineupClearField) lives in ErsatzTV.Core so the
OpenAPI string-enum scan renders it as a string enum, matching every sibling
advanced-options enum. Handler resolves clearable fields once via
ResolveClearable and validates set/clear conflicts via ValidateClear;
reference validation skips existence checks for cleared (null) refs.

SPA: the shared advancedOptions model re-adds a real "None" option to the five
id selects (watermark + fillers) in both the Channel Builder and the Auto-Tune
DetailPanel, routed through a CLEAR overrides sentinel that applyOverridesToRequest
folds into advanced.clear (never leaking onto the wire as a field value). The
backend enum also covers the preferred audio/subtitle language strings for
machine clients; the SPA text inputs keep "empty = inherit" (tri-state deferred).

Docs: api-conventions.md §2, spa-conventions.md §11, decisions.md record
api.from-lineup-clear-to-none; v1.json + generated TS regenerated.

fixes #135

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 22:25:58 +02:00
timothy 44d9e47e1c Merge pull request 'fix(539): WorkAheadSlots.Release never publishes a negative count' (#546) from fix/539-workaheadslots-hardening into main
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 29s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 30s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 31s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 10m53s
2026-07-21 19:20:42 +00:00
timothyandClaude Opus 4.8 1ce5743bc1 fix(539): WorkAheadSlots.Release clamps before decrementing, reports unbalance in-band
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
PR Gates / Docs update reminder (pull_request) Successful in 15s
PR Gates / decisions lifecycle (pull_request) Successful in 16s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m31s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 9s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 20m12s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m41s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Three Low findings from the #536 clamp re-review, unreachable today (one
guarded release site) but filed against the day a second release site is added.

- §1: Release() now reads the count and CAS-decrements only when current > 0,
  so it never publishes a negative count even transiently. The prior
  decrement-first-then-clamp shape dipped to -1, which a concurrent TryAcquire
  could read as phantom room and over-admit at the limit (re-opening the #529
  QSV pool exhaustion). It records the unbalanced release synchronously on the
  offending thread rather than blaming a later innocent release.
- §3: Release() returns bool; HlsSessionWorker logs a warning on the false
  (unbalanced) return — the one in-band signal a future second release site
  would need. WorkAheadSlots stays logger-free by design.
- §2: UnbalancedReleases doc-comment corrected — it can under-count (an
  over-release while count > 0 cancels a coexisting leak and goes unrecorded);
  no false positives, but zero does not prove correctness.

Test: Release_Unbalanced_NeverPublishesNegativeCount (2M unbalanced releases vs
4 count-samplers) with a documented, verified negative control (reverting to
the decrement-first body makes readers observe the transient -1).

Adds a decisions.md entry (ffmpeg.work-ahead-slot-release-never-negative).

fixes #539

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 20:59:32 +02:00
timothy 04d3eb2dbc Merge pull request 'ci(545): require a **Signals:** line on decision records' (#547) from fix/545-signals-required into main
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 7m35s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 20m50s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 14m23s
2026-07-21 18:58:03 +00:00
timothy c05fdd3be1 Merge pull request 'docs(skill): correct the ersatztv skill for the fork' (#549)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 20s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 35s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 37s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 15s
2026-07-21 18:51:47 +00:00
timothy 86fe69d7fb docs(skill): correct the ersatztv skill for the fork — it described upstream
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 13s
PR Gates / decisions lifecycle (pull_request) Successful in 22s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 29s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 30s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 24s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 23s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 13s
The skill still described archived upstream v26.3.0: "MediatR + Blazor (not
REST for mutations)", "No REST CRUD for channels/collections/schedules —
must use SQLite DB directly", and the ghcr.io image. All false for this
fork — Blazor was removed in #91b and /api/v1 has full write paths. A
session trusting it would hand-edit SQLite for something the API does.

Also adds what this session had to discover by hand while driving the API:
the key is root-owned so an unsudo'd read fails SILENTLY (empty header ->
401 body that parses as a dict -> a naive script reports "0 channels"
rather than an auth error); settings live at /api/v1/settings/ffmpeg, not
ffmpeg/settings; test is port 8410 and the service-scoped manual refresh
command for validating before the 03:00 auto-update.

Two measurement traps recorded with them: OCI labels are inherited from the
linuxserver base image and lie about what is running (compare .Image to the
registry Docker-Content-Digest), and container log brackets are local time
while `docker logs -t` is UTC, so --since windows mis-slice.

Endpoint lists now defer to docs/endpoint-index.md rather than being
re-maintained here, since a hand-copied list is what drifted in the first
place.
2026-07-21 20:49:33 +02:00
timothy 9713bae498 Merge pull request 'docs(542): record the workflow lore, then prune the kickoff doc to instructions' (#544)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 11s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 10s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 11s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m17s
2026-07-21 18:43:05 +00:00
timothy dc130f2526 Merge pull request 'chore(541): fast-forward the shared checkout at session end (H13)' (#543)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 10s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 10s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 11s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
2026-07-21 18:23:17 +00:00
timothyandClaude Opus 4.8 0ef053a209 ci(545): require a **Signals:** line on decision records
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 13s
PR Gates / Docs update reminder (pull_request) Successful in 22s
PR Gates / decisions lifecycle (pull_request) Successful in 37s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m23s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m21s
The decisions-guard validator enforced lifecycle metadata (key/status/since/
supersedes/superseded-by + reciprocal links) but not the **Signals:** line —
which is exactly what MemPalace's keyword recall matches on. A record without
it ingests with weak recall metadata and produces confident false-negatives
for the "MemPalace to find, file to confirm" retrieval workflow.

Add "signals" to REQUIRED_META so a migrated record with a missing or empty
**Signals:** line fails the same way a missing key does. All 114 active + 2
archive records already carry a Signals line, so this is non-breaking on the
current corpus. Archive records are intentionally out of scope (recall targets
the active corpus).

- scripts/decisions_validate.py: signals in REQUIRED_META (+ rationale comment)
- scripts/tests/test_decisions_validate.py: _rec() default + missing/empty/present cases
- docs/decisions.md: header + Enforcement note the requirement and why

fixes #545

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 20:19:10 +02:00
timothy 12e5c3d26f docs(542): record the workflow lore, then prune the kickoff doc to instructions
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 10s
PR Gates / Docs update reminder (pull_request) Successful in 13s
PR Gates / decisions lifecycle (pull_request) Successful in 22s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 16s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 13s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m44s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 15m11s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 20m14s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Timothy asked why the kickoff handoff doc stores historical narrative when
it should be instructions. It shouldn't — its own lore section is chartered
as "STANDING workflow/orchestration rules only" with the why belonging in
docs/decisions.md. But an inventory of every bullet against the decision
corpus inverted the premise: only ~8 of ~38 were actually covered. 19 had
no record anywhere and 11 more were half-covered, so that single file was
the ONLY copy of the mandatory review rubric, the whole CI-triage
vocabulary, the build-concurrency policy, the H12 session-end audit, and
the plumbing-merge recipe. Pruning first would have destroyed them.

So the records come first. New topic file docs/decisions/workflow-process.md
carries 32 records (ci.*, process.*, testing.*) covering every NONE and
PARTIAL the inventory found, including the Gitea `?milestones=` no-op bug
whose only copy was the archived selector section this prune deletes.

Only then the prune: HARD CONSTRAINTS and the lore section become one- or
two-line rules, each citing the decision key that holds its evidence, and
the 40-line "Archived — do not follow" section is gone. 636 -> 353 lines,
with every cited key verified to resolve against the corpus.

The aggregate corpus budget is re-baselined 4800 -> 5600 with the reason in
the code: the corpus grew because knowledge MOVED into it, which is the
system working, not drift.

refs #542
2026-07-21 20:04:17 +02:00
timothy 92bf8b083d chore(541): fast-forward the shared checkout at session end (H13)
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 10s
PR Gates / Docs update reminder (pull_request) Successful in 14s
PR Gates / decisions lifecycle (pull_request) Successful in 17s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m22s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m23s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m34s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m8s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m55s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
A session was handed docs/handoffs/chicorytv-issue-queue.md pasted out of
/Users/timothy/ersatztv while that tree was 81 commits behind, so it still
described the queue protocol #520 retired the day before (read tracker
command was ever run against that tree, so every existing "never read its
HEAD" guard was irrelevant: a stale checkout serves stale FILES, and docs
are what a kickoff depends on. Nothing broke only because selection went
through scripts/select-queue.sh.

The lore bullet on that tree already prescribed the shape of the fix for
its earlier failure modes — "a design flaw, not a discipline failure; a
check does not stay true" — so this removes the stale condition instead of
adding another check.

scripts/refresh-shared-checkout.sh fast-forwards the tree to origin/main
and reinstalls web/node_modules when the lockfile moved. It is deliberately
timid: it refuses and changes nothing when the tree is not on main, is
dirty, is ahead, or is mid-rebase/merge, and it never switches branches,
stashes or discards. A NO-OP is a normal outcome.

Uses npm ci rather than npm install — the first version used install,
which rewrote package-lock.json and left the tree dirty, i.e. the exact
state the next run refuses on, so it would have disabled itself after one
use. Asserts the tree is clean at exit.

refs #541
2026-07-21 20:02:57 +02:00
timothy d9eb307d71 Merge pull request 'fix(68): rebuild on-demand channel guide (and mirrors) on thaw' (#540) from fix/68-ondemand-guide-refresh into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 11s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 31s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 32s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 11m6s
2026-07-21 17:49:14 +00:00
timothyandClaude Opus 4.8 dfed9a393b fix(68): rebuild on-demand channel guide (and mirrors) on thaw
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 10s
PR Gates / Docs update reminder (pull_request) Successful in 15s
PR Gates / decisions lifecycle (pull_request) Successful in 27s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m19s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m26s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m38s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 17m52s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m25s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
An on-demand channel (`PlayoutMode.OnDemand`) already is the "resume where I
left off" feature: `Playout.OnDemandCheckpoint` persists the viewer's position
and `PlayoutTimeShifter.TimeShift` slides the materialized timeline forward on
tune-in so the paused item is active again. Because it rewrites `GuideStart`/
`GuideFinish` alongside `Start`/`Finish`, guide and playback freeze together —
structurally avoiding the free-running-wall-clock desync #68 was filed about.

The one gap: `TimeShift` rewrote the stored `PlayoutItem` rows but the XMLTV
guide is served from a cached fragment that only `RefreshChannelData` rebuilds,
and the tune-in path never enqueued it. So an external EPG client polling after
a thaw could see a stale timeline until the next incidental rebuild.

Fix: `IPlayoutTimeShifter.TimeShift` now returns the channel numbers whose cached
guide is stale — the shifted channel plus any channels that mirror it (the same
fan-out `BuildPlayoutHandler` already does) — and `TimeShiftOnDemandPlayoutHandler`
enqueues a `RefreshChannelData` for each on `CancellationToken.None` (post-commit
side effect must not be abandoned if the session token cancels).

Tests: handler enqueues a rebuild per stale channel (+ mirror + no-shift cases);
`PlayoutTimeShifter` reports source+mirrors on a shift, empty on Continuous /
zero-offset / active-unforced, and correctly seeds+shifts a never-watched playout.
Non-vacuity of the enqueue proven by a compiling negative control.

Docs: channels.md (On-demand resume section), domain-model.md, decisions.md
(scheduling.ondemand-guide-refresh-on-thaw). Per-viewer resume is out of scope
(single per-channel checkpoint; #68 says per-channel suffices).

fixes #68

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 19:12:42 +02:00
timothy 5e5da9dbd1 Merge pull request 'fix(536): enforce workAheadSegmenterLimit with an atomic slot claim' (#538)
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 7m37s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 14m39s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 17m34s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m10s
2026-07-21 16:44:11 +00:00
timothy c21d227132 Merge pull request 'ci(535): split PR-only git gates into pr-checks.yml so release tags don't red' (#537) from fix/535-pr-only-workflow-split into main
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 13s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 29s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 30s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 11m22s
2026-07-21 16:31:41 +00:00
timothy 5db0836d41 fix(536): clamp an unbalanced work-ahead release instead of going negative
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 13s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 14s
Build ErsatzTV Image / decisions lifecycle (pull_request) Successful in 14s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 18s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m17s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m10s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m37s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Pre-push adversarial review, Low finding. The pool is process-wide and
lives for the life of the app, so a `Release()` not matched by a
successful `TryAcquire` would drive the count to -1 permanently: with a
limit of 1 that silently admits two unthrottled transcodes forever, which
is exactly the #529 QSV pool exhaustion with nothing in the logs to find
it by. Clamp at zero and record the breakage in `UnbalancedReleases`
rather than throwing — the sole caller releases from a `finally`, where a
throw would swallow the real exception.

The hammer tests now also assert `UnbalancedReleases == 0`, so the clamp
cannot mask drift it was added to survive.

Also moves the #536 index line to the end of the in-file decisions index
(it was inserted in the 2026-07-11 block while its body appends at the
end) — review nit, anchors were already correct.

refs #536
2026-07-21 18:23:12 +02:00
timothy c0d3dab190 fix(536): enforce workAheadSegmenterLimit with an atomic slot claim [decisions-edit]
The slot check and its increment straddled an await: `Run` compared
`Volatile.Read(ref _workAheadCount)` against a DB-backed limit, and the
increment happened later inside `Transcode`. Every simultaneous tune-in
therefore observed `0 < limit` and started unthrottled — three concurrent
tunes on prod with a limit of 1 all ran with no `-readrate`. `Interlocked`
on the write side alone buys nothing when the read side is a separate,
earlier load (same class as #231/#250).

Extract the counter into a `WorkAheadSlots` pool whose `TryAcquire(limit)`
claims via compare-exchange, so the count never even transiently exceeds
the limit that the QSV hardware-frame pool sizing (#529) is derived from.
`Run` claims the slot and passes ownership in; `Transcode(bool
ownsWorkAheadSlot, ...)` derives `realtime` from it and releases it in its
existing `finally`, keeping acquire/release one-for-one. Acquisition stays
in the caller because `Run` sets `_state` from the outcome and `Transcode`
reads that state on entry to pick the item start time.

Tests hammer 8 threads x 20k rounds (a single Barrier round does not
collide on this hardware); the documented negative control reinstates the
check-then-act body and produces 15912 over-claiming rounds of 20000.

Also annotates the #350 decision record, whose "every concurrent tune-in
falls back to the throttled path" bullet described the intent rather than
the behaviour.

fixes #536
2026-07-21 18:16:13 +02:00
timothyandClaude Opus 4.8 b4ac46fce1 ci(535): split PR-only git gates into pr-checks.yml so release tags don't red
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 9s
PR Gates / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 12s
PR Gates / decisions lifecycle (pull_request) Successful in 17s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m24s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 16m39s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m49s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21m20s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Three PR-only git-diff gates (ci-image-pin, docs-reminder, decisions-guard)
lived in docker-build.yml, which also triggers on push to main and v* tags.
Gitea dispatches a job as a runner task even when its `if` skips it, so on
every tag/main push these three were dispatched to the `small` lane just to
evaluate the skip. On the v26.12.0 tag those dispatched skip-tasks wedged in
act's setup phase and were killed by a runner restart mid-setup, reporting
`failure` (no logs) and reddening the tag's overall commit status even though
the release built, scanned, and deployed fine. The two identical-`if:` jobs on
ubuntu-latest (api-docs, format) skipped cleanly — the job logic was never the
problem; the kill lands in the dispatch window before any step or skip runs, so
tweaking the `if:`/step logic could not fix it.

Relocate exactly those three (pure checkout + git-diff, no container:, no image
pin) verbatim into a dedicated pr-checks.yml that triggers `on: pull_request`
only. Gitea evaluates a workflow's trigger before creating any job, so on a
tag/main push this workflow produces zero jobs: no dispatch, no kill, no
spurious red — for the whole class, permanently.

- ci-image-pin carries no pin and still greps docker-build.yml, where all five
  pin-bearing jobs (test/migrations/functional-e2e/api-docs/format) remain, so
  its validation is unchanged.
- None of the three are required checks (only Build & test + EF migration
  integrity are), so the status-context prefix change (Build ErsatzTV Image / ...
  -> PR Gates / ...) does not affect merges; the merge-consent hook reads the
  prefix-agnostic combined status.
- pr-checks.yml declares `defaults: run: shell: bash` (ci-image-pin uses
  mapfile / set -o pipefail).

docs/ci-cd.md: new "PR gates workflow" section + cross-references.

fixes #535

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 18:07:56 +02:00
timothy b432892d35 Merge pull request 'docs(524): triage #237's 111 comments — no decision retrofit is owed' (#534) from docs/524-237-retrofit into main
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions lifecycle (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 15s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 30s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 30s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 7s
Merge pull request 'docs(524): triage #237's 111 comments — no decision retrofit is owed' (#534) from docs/524-237-retrofit into main
2026-07-21 15:35:39 +00:00
timothy f355a4a96b docs(524): triage #237's 111 comments — no decision retrofit is owed
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 11s
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 10s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 10s
Build ErsatzTV Image / decisions lifecycle (pull_request) Successful in 19s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 37s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 37s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 36s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 38s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Exhaustive triage of the closed tracker ersatztv#237, whose 111 comments
server-management#642 excludes from MemPalace ingestion (over the per-file
cap) and #520 removes from startup. #524's premise was that facts living only
in those comments would be orphaned and need curating into lifecycle records.

Result: zero decision-shaped orphans. Every durable decision-shaped fact is
already held by the decision corpus or by the individual issue the comment
narrates -- which the exporter does ingest. The tracker was always the lossy
copy, because the session protocol required the fuller closing record on the
worked issue first.

- docs/decisions.md: new active record docs.tracker-comment-retrofit, leading
  with the reusable rule (check the worked issue BEFORE the decision corpus)
  and the consequence for #642's benchmark row, which has no valid subject.
- docs/tracker-retrofit-triage-237.md: the audit trail -- method, per-comment
  classification of all 111, totals, and the one candidate raised and
  disproved (#497's Guids/Directors scope, stated more fully on its own issue).
- docs/handoffs/chicorytv-issue-queue.md: sweeps the two genuinely orphaned
  LORE facts the triage surfaced (e2e-local.sh readiness probe hanging on a
  reused config dir; troubleshooting playback cannot exercise channel branding).
- docs/README.md: index the new doc.

The claim is deliberately narrow: no *decision-shaped* orphans. The lore bucket
was classified but not coverage-checked, and it was not empty -- hence the sweep.

fixes #524
2026-07-21 17:31:43 +02:00
37 changed files with 2653 additions and 571 deletions
+59 -11
View File
@@ -5,19 +5,55 @@ description: ErsatzTV custom IPTV channel management — REST API, SQLite DB, Je
# ErsatzTV Channel Management
Container: `ersatztv` | Port: `8409` | IP: `172.16.238.11` (may change on restart)
Web UI: internal only (`http://localhost:8409` via SSH)
SQLite DB: `~/downloadswarm/ersatztv/ersatztv.sqlite3` on jazz (owned by root — use `sudo sqlite3`)
Image: `ghcr.io/ersatztv/ersatztv:latest` (v26.3.0, repo archived Feb 2026)
Host: **jazz (192.168.1.29)**. Prod container `ersatztv` port **8409**; test `ersatztv-test` port
**8410** (tracks `:latest` via Komodo auto-update, daily 03:00 — a same-day validation needs the
manual pull below).
SQLite DB: `~/downloadswarm/ersatztv/ersatztv.sqlite3` (owned by root — use `sudo sqlite3`)
Image: **our fork**, `192.168.1.95:3000/timothy/ersatztv` (`:prod` / `:latest`). Upstream
`ghcr.io/ersatztv/ersatztv` was archived at v26.3.0 and is NOT what runs here.
## Architecture
ErsatzTV uses **MediatR + Blazor** (not REST for mutations). The REST API is limited:
- **GET endpoints**: channels, collections, schedules, playouts, shows, movies, artists, ffmpeg profiles, health, search, watermarks
- **POST endpoints**: library scan, playout reset, show scan
- **No REST CRUD for channels/collections/schedules** — must use SQLite DB directly
**This section described upstream v26.3.0 and was wrong for the fork — corrected 2026-07-21.**
## REST API
- The **Blazor UI is gone** (#91 phase b). The only UI is the ChicoryTV React SPA at `/app`; legacy
routes 302 there.
- There **is** a full versioned REST API under **`/api/v1`**, write paths included — channels,
collections, schedules, playouts and media sources have CRUD. **Do not hand-edit SQLite for
something the API can do.** The DB-scripting recipes below survive only for gaps with no endpoint.
- Controllers stay thin and delegate to MediatR handlers; the SPA talks to `/api/v1` only.
- Authoritative endpoint list: `docs/endpoint-index.md` (generated) + `docs/api-conventions.md`.
Prefer those over any list in this file — a hand-maintained copy drifts.
## REST API access (auth-gated — read before curling)
Calls need **`X-Api-Key`** (machine clients) or a browser session. An unauthenticated call returns a
401 JSON body that is easy to mistake for real data — see the silent-401 trap in Gotchas.
The key file is **root-owned `0600`**, so `cat` as `timothy` fails *silently* and yields an empty
header. Read it with `sudo`, inline, so the value is never printed:
```bash
# prod (8409); test is identical with .../ersatztv-test/api.key and port 8410
ssh timothy@192.168.1.29 'K=$(sudo -n cat /home/timothy/downloadswarm/ersatztv/api.key); \
curl -s -H "X-Api-Key: $K" http://localhost:8409/api/v1/channels'
```
Settings live under `/api/v1/settings/*` — `settings/ffmpeg` (`workAheadSegmenterLimit`,
`qsvExtraHardwareFrames`) and `settings/logging` (`streamingMinimumLogLevel`). Note the order: it is
`settings/ffmpeg`, **not** `ffmpeg/settings`.
Refresh test to the newest `:latest` without waiting for 03:00 — scope it to the service, since a
bare `up -d` would recreate everything else in the compose project:
```bash
D=/etc/komodo/stacks/ersatztv/docker/jazz/stacks/ersatztv
docker compose -f $D/compose.yaml pull ersatztv-test
docker compose -f $D/compose.yaml up -d --no-deps ersatztv-test
```
The unversioned `/api/*` endpoints below predate the `/api/v1` surface — verify one against
`docs/endpoint-index.md` before relying on it.
```bash
# Via docker exec
@@ -148,13 +184,25 @@ After creating: `POST /api/channels/{number}/playout/reset`
## Gotchas
- DB owned by root — always use `sudo sqlite3`
- **The api.key file is root-owned too, and an unsudo'd read fails SILENTLY.** `cat` returns nothing,
the header goes out empty, and the 401 body parses as a dict — so a naive script reports "0
channels" rather than an auth error. If a query returns a suspiciously empty result, check auth
before believing it. (Cost a wrong reading on 2026-07-21.)
- WAL mode: reads OK while running, stop container for writes
- No REST API for channel/collection/schedule CRUD — DB scripting only
- ~~No REST API for channel/collection/schedule CRUD~~ — **false since the fork's `/api/v1`**; use the
API, not DB scripting, wherever an endpoint exists
- **A container's OCI labels lie about what is running** — they are inherited from the linuxserver
base image (they claimed `2026-06-27` on an image built minutes earlier). To prove which build is
live, compare `docker inspect <c> --format '{{.Image}}'` to the registry's `Docker-Content-Digest`
for that tag
- **Container log lines carry a LOCAL-time bracket (`[18:48:13 DBG]`) while `docker logs -t` emits
UTC**, so `--since` windows silently mis-slice. For before/after measurements capture by line
offset instead (`wc -l` before, `tail -n +N` after)
- Secrets file uses PascalCase JSON (`Address`, `ApiKey`)
- Scanner is separate binary (`ErsatzTV.Scanner`) — check with `docker top ersatztv | grep Scanner`
- EF TPT inheritance: `ProgramScheduleItem` has subtype tables (`ProgramScheduleOneItem`, etc.) — MUST insert into subtype table
- External URL logos work for M3U but NOT for watermark burn-in (code checks `File.Exists()`)
- `/api/health` returns Blazor HTML, not JSON — use `/api/channels` to verify API
- `/api/health` predates the Blazor removal; verify the API with an authenticated `/api/v1/channels` instead
- PlaybackOrder enum: 3=Shuffle, 6=SeasonEpisode (use 3 for all channels)
- CollectionType enum: 0=Collection, 1=Show (direct show reference via MediaItemId)
- SubtitleMode: 0=None, 2=Burn-in. Set to 2 with PreferredSubtitleLanguageCode='eng' for non-music channels
+4 -113
View File
@@ -6,6 +6,10 @@ name: Build ErsatzTV Image
# push tag v* -> :prod + :<version> + :<short-sha> (prod release)
# workflow_dispatch -> manual run; only publishes when the ref is main or a v* tag
#
# The PR-only git-diff gates (ci-image-pin, docs-reminder, decisions-guard) live in the sibling
# .gitea/workflows/pr-checks.yml (`on: pull_request`). They were split out of this file so they
# are not dispatched-and-killed on a tag/main push (ersatztv#535 — see that file's header).
#
# Runner + registry provisioned in server-management#172. The Gitea registry is
# HTTP-only, so BuildKit needs the inline `http = true` config below (it does not
# inherit the host daemon's insecure-registries setting).
@@ -642,119 +646,6 @@ jobs:
exit 1
fi
# BLOCKING (ersatztv#390): the CI toolchain image pin in this file must name the image that
# ci-image.yml actually last published — i.e. the short sha of the last commit to touch the image's
# sources. Without this detector, a PR that edits docker/ci/** publishes a NEW image but runs its own
# jobs against the OLD pin: CI green-lights a toolchain it never executed, and once merged, main's
# Dockerfile silently disagrees with what CI runs. **Renovate actively generates exactly that PR** —
# it manages docker/ci/Dockerfile's base pins (dockerfile manager) but cannot bump an opaque
# `:<sha>` in `container.image`, so it would leave the pin behind every time.
#
# Failing here forces the documented two-step (docs/ci-cd.md -> "CI toolchain image"): push the
# Dockerfile change, let ci-image.yml publish `:<sha>`, then update the pin to that sha. Seconds-long
# git+grep -> keep it off the build runners.
ci-image-pin:
name: CI image pin matches docker/ci
runs-on: small
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# need real history: `git log -- <path>` on a shallow clone can't find the last
# commit that touched the image sources
fetch-depth: 0
- name: Verify the pin matches the last-published image
run: |
set -euo pipefail
# ci-image.yml tags the image `git rev-parse --short HEAD` of the push that built it, and it
# only builds on pushes touching these paths — so the published image is named by the last
# commit to touch them.
#
# Compare RESOLVED FULL shas, never the abbreviations: git auto-scales abbreviation length
# with the repo's object count, so the tag built in CI from a `fetch-depth: 1` shallow clone
# is 7 chars while `%h` here (full clone) is 8. Comparing those strings would fail always.
expected="$(git log -1 --format=%H -- docker/ci .gitea/workflows/ci-image.yml)"
mapfile -t pins < <(grep -oE 'ersatztv-ci:[0-9a-f]+' .gitea/workflows/docker-build.yml | cut -d: -f2 | sort -u)
echo "Image sources last changed in: ${expected}"
echo "Pins found in docker-build.yml: ${pins[*]} (${#pins[@]} distinct)"
if [ "${#pins[@]}" -ne 1 ]; then
echo "::error::docker-build.yml pins MORE THAN ONE ersatztv-ci tag (${pins[*]}). All jobs must pin the same image — bump them together."
exit 1
fi
pin_full="$(git rev-parse --verify --quiet "${pins[0]}^{commit}" || true)"
if [ -z "$pin_full" ]; then
echo "::error::The pinned CI image tag ersatztv-ci:${pins[0]} does not resolve to a commit in this repo, so it cannot correspond to an image ci-image.yml built from these sources. Rebuild the image and pin the sha it prints."
exit 1
fi
if [ "$pin_full" != "$expected" ]; then
echo "::error::CI toolchain image pin is stale: docker-build.yml pins ersatztv-ci:${pins[0]} ($pin_full), but docker/ci was last changed in $expected. Your jobs are testing an image that is NOT built from this PR's docker/ci. Let ci-image.yml publish the new :<sha>, then update the pin in ALL jobs to it (docs/ci-cd.md -> 'CI toolchain image')."
exit 1
fi
echo "Pin is current: ersatztv-ci:${pins[0]} resolves to $pin_full = docker/ci's last change."
# Non-blocking nudge: if a PR migrates/adds a route but forgets the parity tracker, warn.
# The rule lives in CLAUDE.md → Conventions; this only surfaces an easy-to-miss omission.
# Deliberately no setup-dotnet/setup-node (and thus no actions/cache) so it can't hit the
# cache-save issues seen on the relocated runner (server-management#570).
docs-reminder:
name: Docs update reminder
runs-on: small # seconds-long git diff; keep it off the build runners
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Warn when a screen/route change skips the parity doc
run: |
base_ref="${{ github.base_ref }}"
git fetch --no-tags --depth=100 origin "$base_ref" || true
changed="$(git diff --name-only "origin/${base_ref}...HEAD" 2>/dev/null || true)"
echo "Changed files in this PR:"; printf '%s\n' "$changed"
screen_or_route=no
if printf '%s\n' "$changed" | grep -Eq '^web/src/screens/.+\.tsx$|^ErsatzTV/LegacyUiRedirects\.cs$'; then
screen_or_route=yes
fi
parity=no
if printf '%s\n' "$changed" | grep -qx 'docs/blazor-route-parity.md'; then
parity=yes
fi
if [ "$screen_or_route" = yes ] && [ "$parity" = no ]; then
echo "::warning::This PR touches a SPA screen or LegacyUiRedirects.cs but does not update docs/blazor-route-parity.md. If you added/migrated/redirected a route, update the parity tracker (and docs/domain-model.md) in THIS PR — see CLAUDE.md → Conventions."
else
echo "Parity-doc reminder: nothing to flag."
fi
# BLOCKING (ersatztv#521, supersedes the ersatztv#303 H9 append-only mechanic): validates decision-
# record lifecycle invariants (metadata schema, one active record per key, reciprocal
# supersedes/superseded-by links, no rationale-prose rewrite without [decisions-edit], no record
# vanishing from the active set without an archive copy) and that the generated active catalog
# (docs/decisions/README.md) is in sync. Same validator the Husky pre-commit hook shim calls, so
# local and CI enforcement can't drift. Seconds-long git diff + parse -> keep it off the build runners.
decisions-guard:
name: decisions lifecycle
runs-on: small
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Validate decision lifecycle
run: |
base_ref="${{ github.base_ref }}"
git fetch --no-tags --depth=200 origin "$base_ref" || true
PYTHONPATH=. python3 scripts/decisions_validate.py --base "origin/${base_ref}" --head HEAD
- name: Active catalog in sync
run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check
- name: Kickoff guard
run: bash scripts/check-kickoff-guard.sh
# BLOCKING (unlike docs-reminder): the mechanizable half of the "docs-update in the
# same PR" rule for the API contract (ersatztv#303 H4/H5). If a PR touches the API
# surface (ErsatzTV/Controllers/Api/** or ErsatzTV.Core/Api/**), the generated
+156
View File
@@ -0,0 +1,156 @@
name: PR Gates
# Fast, git-only PR gates split out of docker-build.yml into a dedicated `on: pull_request`
# workflow (ersatztv#535) so they are NEVER created on a tag/main push.
#
# WHY THIS FILE EXISTS. These three checks are pure `checkout + git diff` gates: they carry no
# `container:`, run on the `small` lane (git-only, 1 GiB; server-management#639), and are PR-only.
# While they lived in docker-build.yml — which also triggers on push to main and on `v*` tags —
# Gitea still DISPATCHED them as runner tasks on every such push to evaluate the `if:` skip, because
# **Gitea dispatches a job as a runner task even when its `if` skips it** (docs/ci-cd.md -> the
# `small` lane). On the v26.12.0 release tag those dispatched skip-tasks wedged in act's setup phase
# and were killed by a runner restart mid-setup, so they reported `failure` (no logs) and reddened
# the tag's overall commit status even though the release built, scanned, and deployed fine
# (ersatztv#535). The two PR-only jobs on `ubuntu-latest` (`api-docs`, `format`) carry the identical
# `if:` and skipped cleanly on the same tag — the job logic was never the problem; the kill happens
# in the dispatch window before any step or `if:`-skip runs.
#
# Gitea evaluates a workflow's TRIGGER before creating any job, so a `pull_request`-only workflow
# produces ZERO jobs on a tag/main push: no dispatch, no kill, no spurious red. That is the whole
# fix. The per-job `if: github.event_name == 'pull_request'` guards are kept as belt-and-suspenders
# (they also encode "these steps need a PR base_ref"; harmless given the trigger).
#
# These stay on `runs-on: small` and carry NO CI toolchain image pin, so `ci-image-pin`'s grep of
# docker-build.yml still validates the five pin-bearing jobs (test/migrations/functional-e2e/
# api-docs/format) that remain there. None of these three are required checks — branch protection
# requires only `Build & test (.NET)` and `EF migration integrity` — so relocating them (which
# changes their status-context prefix from "Build ErsatzTV Image / …" to "PR Gates / …") does not
# affect merges. See docs/ci-cd.md -> "PR gates workflow".
on:
pull_request:
# git-only host-runner jobs: no `container:`, so the runner default shell would be bash anyway, but
# declare it explicitly — ci-image-pin uses `mapfile`/`set -o pipefail`, which die under dash.
defaults:
run:
shell: bash
# Per-ref: a new push to the PR supersedes its in-flight gate run. Only runs on PRs, so always cancel.
concurrency:
group: ersatztv-pr-gates-${{ github.ref }}
cancel-in-progress: true
jobs:
# BLOCKING (ersatztv#390): the CI toolchain image pin in docker-build.yml must name the image that
# ci-image.yml actually last published — i.e. the short sha of the last commit to touch the image's
# sources. Without this detector, a PR that edits docker/ci/** publishes a NEW image but runs its own
# jobs against the OLD pin: CI green-lights a toolchain it never executed, and once merged, main's
# Dockerfile silently disagrees with what CI runs. **Renovate actively generates exactly that PR** —
# it manages docker/ci/Dockerfile's base pins (dockerfile manager) but cannot bump an opaque
# `:<sha>` in `container.image`, so it would leave the pin behind every time.
#
# Failing here forces the documented two-step (docs/ci-cd.md -> "CI toolchain image"): push the
# Dockerfile change, let ci-image.yml publish `:<sha>`, then update the pin to that sha. Seconds-long
# git+grep -> keep it off the build runners.
ci-image-pin:
name: CI image pin matches docker/ci
runs-on: small
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# need real history: `git log -- <path>` on a shallow clone can't find the last
# commit that touched the image sources
fetch-depth: 0
- name: Verify the pin matches the last-published image
run: |
set -euo pipefail
# ci-image.yml tags the image `git rev-parse --short HEAD` of the push that built it, and it
# only builds on pushes touching these paths — so the published image is named by the last
# commit to touch them.
#
# Compare RESOLVED FULL shas, never the abbreviations: git auto-scales abbreviation length
# with the repo's object count, so the tag built in CI from a `fetch-depth: 1` shallow clone
# is 7 chars while `%h` here (full clone) is 8. Comparing those strings would fail always.
expected="$(git log -1 --format=%H -- docker/ci .gitea/workflows/ci-image.yml)"
mapfile -t pins < <(grep -oE 'ersatztv-ci:[0-9a-f]+' .gitea/workflows/docker-build.yml | cut -d: -f2 | sort -u)
echo "Image sources last changed in: ${expected}"
echo "Pins found in docker-build.yml: ${pins[*]} (${#pins[@]} distinct)"
if [ "${#pins[@]}" -ne 1 ]; then
echo "::error::docker-build.yml pins MORE THAN ONE ersatztv-ci tag (${pins[*]}). All jobs must pin the same image — bump them together."
exit 1
fi
pin_full="$(git rev-parse --verify --quiet "${pins[0]}^{commit}" || true)"
if [ -z "$pin_full" ]; then
echo "::error::The pinned CI image tag ersatztv-ci:${pins[0]} does not resolve to a commit in this repo, so it cannot correspond to an image ci-image.yml built from these sources. Rebuild the image and pin the sha it prints."
exit 1
fi
if [ "$pin_full" != "$expected" ]; then
echo "::error::CI toolchain image pin is stale: docker-build.yml pins ersatztv-ci:${pins[0]} ($pin_full), but docker/ci was last changed in $expected. Your jobs are testing an image that is NOT built from this PR's docker/ci. Let ci-image.yml publish the new :<sha>, then update the pin in ALL jobs to it (docs/ci-cd.md -> 'CI toolchain image')."
exit 1
fi
echo "Pin is current: ersatztv-ci:${pins[0]} resolves to $pin_full = docker/ci's last change."
# Non-blocking nudge: if a PR migrates/adds a route but forgets the parity tracker, warn.
# The rule lives in CLAUDE.md → Conventions; this only surfaces an easy-to-miss omission.
# Deliberately no setup-dotnet/setup-node (and thus no actions/cache) so it can't hit the
# cache-save issues seen on the relocated runner (server-management#570).
docs-reminder:
name: Docs update reminder
runs-on: small # seconds-long git diff; keep it off the build runners
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Warn when a screen/route change skips the parity doc
run: |
base_ref="${{ github.base_ref }}"
git fetch --no-tags --depth=100 origin "$base_ref" || true
changed="$(git diff --name-only "origin/${base_ref}...HEAD" 2>/dev/null || true)"
echo "Changed files in this PR:"; printf '%s\n' "$changed"
screen_or_route=no
if printf '%s\n' "$changed" | grep -Eq '^web/src/screens/.+\.tsx$|^ErsatzTV/LegacyUiRedirects\.cs$'; then
screen_or_route=yes
fi
parity=no
if printf '%s\n' "$changed" | grep -qx 'docs/blazor-route-parity.md'; then
parity=yes
fi
if [ "$screen_or_route" = yes ] && [ "$parity" = no ]; then
echo "::warning::This PR touches a SPA screen or LegacyUiRedirects.cs but does not update docs/blazor-route-parity.md. If you added/migrated/redirected a route, update the parity tracker (and docs/domain-model.md) in THIS PR — see CLAUDE.md → Conventions."
else
echo "Parity-doc reminder: nothing to flag."
fi
# BLOCKING (ersatztv#521, supersedes the ersatztv#303 H9 append-only mechanic): validates decision-
# record lifecycle invariants (metadata schema, one active record per key, reciprocal
# supersedes/superseded-by links, no rationale-prose rewrite without [decisions-edit], no record
# vanishing from the active set without an archive copy) and that the generated active catalog
# (docs/decisions/README.md) is in sync. Same validator the Husky pre-commit hook shim calls, so
# local and CI enforcement can't drift. Seconds-long git diff + parse -> keep it off the build runners.
decisions-guard:
name: decisions lifecycle
runs-on: small
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Validate decision lifecycle
run: |
base_ref="${{ github.base_ref }}"
git fetch --no-tags --depth=200 origin "$base_ref" || true
PYTHONPATH=. python3 scripts/decisions_validate.py --base "origin/${base_ref}" --head HEAD
- name: Active catalog in sync
run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check
- name: Kickoff guard
run: bash scripts/check-kickoff-guard.sh
@@ -1,4 +1,4 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
@@ -43,7 +43,8 @@ public record CreateChannelFromLineupAdvancedOptions(
ChannelIdleBehavior? IdleBehavior = null,
bool? ShuffleScheduleItems = null,
bool? RandomStartPoint = null,
FixedStartTimeBehavior? FixedStartTimeBehavior = null);
FixedStartTimeBehavior? FixedStartTimeBehavior = null,
IReadOnlyList<CreateChannelFromLineupClearField> Clear = null);
public record CreateChannelFromLineupItem(
LibraryBrowseMediaType MediaType,
@@ -190,11 +190,21 @@ public class CreateChannelFromLineupHandler(
return new NotFoundError($"Channel template {request.TemplateId} does not exist.");
}
// "clear to none" (#135): a field named in advanced.Clear is forced to none even when the
// template sets one; both setting and clearing the same field is contradictory.
Either<BaseError, Unit> clearValidation = ValidateClear(advanced);
foreach (BaseError error in clearValidation.LeftToSeq())
{
return error;
}
ResolvedClearableOptions resolved = ResolveClearable(advanced, template);
int ffmpegProfileId = advanced.FFmpegProfileId ?? template.FFmpegProfileId;
int? fallbackFillerId = advanced.FallbackFillerId ?? template.FallbackFillerId;
int? preRollFillerId = advanced.PreRollFillerId ?? template.PreRollFillerId;
int? midRollFillerId = advanced.MidRollFillerId ?? template.MidRollFillerId;
int? postRollFillerId = advanced.PostRollFillerId ?? template.PostRollFillerId;
int? fallbackFillerId = resolved.FallbackFillerId;
int? preRollFillerId = resolved.PreRollFillerId;
int? midRollFillerId = resolved.MidRollFillerId;
int? postRollFillerId = resolved.PostRollFillerId;
PlaybackOrder playbackOrder = advanced.PlaybackOrder ?? PlaybackOrder.Chronological;
ChannelPlayoutSource playoutSource = advanced.PlayoutSource ?? template.PlayoutSource;
@@ -207,8 +217,8 @@ public class CreateChannelFromLineupHandler(
Either<BaseError, Unit> referenceValidation = await ValidateReferences(
dbContext,
advanced,
template,
ffmpegProfileId,
resolved,
cancellationToken);
foreach (BaseError error in referenceValidation.LeftToSeq())
{
@@ -272,6 +282,7 @@ public class CreateChannelFromLineupHandler(
request,
template,
advanced,
resolved,
name,
number,
group,
@@ -291,6 +302,7 @@ public class CreateChannelFromLineupHandler(
playbackOrder,
advanced,
template,
resolved,
fallbackFillerId,
preRollFillerId,
midRollFillerId,
@@ -383,6 +395,7 @@ public class CreateChannelFromLineupHandler(
CreateChannelFromLineup request,
ChannelTemplate template,
CreateChannelFromLineupAdvancedOptions advanced,
ResolvedClearableOptions resolved,
string name,
string number,
string group,
@@ -421,16 +434,14 @@ public class CreateChannelFromLineupHandler(
PlayoutSource = advanced.PlayoutSource ?? template.PlayoutSource,
PlayoutMode = advanced.PlayoutMode ?? template.PlayoutMode,
StreamingMode = advanced.StreamingMode ?? template.StreamingMode,
WatermarkId = advanced.WatermarkId ?? template.WatermarkId,
WatermarkId = resolved.WatermarkId,
FallbackFillerId = fallbackFillerId,
Artwork = artwork,
StreamSelectorMode = advanced.StreamSelectorMode ?? template.StreamSelectorMode,
StreamSelector = advanced.StreamSelector ?? template.StreamSelector ?? string.Empty,
PreferredAudioLanguageCode =
advanced.PreferredAudioLanguageCode ?? template.PreferredAudioLanguageCode ?? string.Empty,
PreferredAudioTitle = advanced.PreferredAudioTitle ?? template.PreferredAudioTitle ?? string.Empty,
PreferredSubtitleLanguageCode =
advanced.PreferredSubtitleLanguageCode ?? template.PreferredSubtitleLanguageCode ?? string.Empty,
PreferredAudioLanguageCode = resolved.PreferredAudioLanguageCode,
PreferredAudioTitle = resolved.PreferredAudioTitle,
PreferredSubtitleLanguageCode = resolved.PreferredSubtitleLanguageCode,
SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode,
MusicVideoCreditsMode = advanced.MusicVideoCreditsMode ?? template.MusicVideoCreditsMode,
MusicVideoCreditsTemplate =
@@ -462,6 +473,7 @@ public class CreateChannelFromLineupHandler(
PlaybackOrder playbackOrder,
CreateChannelFromLineupAdvancedOptions advanced,
ChannelTemplate template,
ResolvedClearableOptions resolved,
int? fallbackFillerId,
int? preRollFillerId,
int? midRollFillerId,
@@ -478,11 +490,9 @@ public class CreateChannelFromLineupHandler(
MidRollFillerId = midRollFillerId,
PostRollFillerId = postRollFillerId,
FallbackFillerId = fallbackFillerId,
PreferredAudioLanguageCode =
advanced.PreferredAudioLanguageCode ?? template.PreferredAudioLanguageCode ?? string.Empty,
PreferredAudioTitle = advanced.PreferredAudioTitle ?? template.PreferredAudioTitle ?? string.Empty,
PreferredSubtitleLanguageCode =
advanced.PreferredSubtitleLanguageCode ?? template.PreferredSubtitleLanguageCode ?? string.Empty,
PreferredAudioLanguageCode = resolved.PreferredAudioLanguageCode,
PreferredAudioTitle = resolved.PreferredAudioTitle,
PreferredSubtitleLanguageCode = resolved.PreferredSubtitleLanguageCode,
SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode
};
@@ -526,20 +536,21 @@ public class CreateChannelFromLineupHandler(
private static async Task<Either<BaseError, Unit>> ValidateReferences(
TvContext dbContext,
CreateChannelFromLineupAdvancedOptions advanced,
ChannelTemplate template,
int ffmpegProfileId,
ResolvedClearableOptions resolved,
CancellationToken cancellationToken)
{
int ffmpegProfileId = advanced.FFmpegProfileId ?? template.FFmpegProfileId;
if (!await dbContext.FFmpegProfiles.AnyAsync(p => p.Id == ffmpegProfileId, cancellationToken))
{
return new NotFoundError($"FFmpegProfile {ffmpegProfileId} does not exist.");
}
// Validate the post-clear effective ids: a cleared reference resolves to null and skips the
// existence check (there is nothing to point at).
Either<BaseError, Unit> channelReferences = await ValidateChannelReferences(
dbContext,
advanced.WatermarkId ?? template.WatermarkId,
advanced.FallbackFillerId ?? template.FallbackFillerId,
resolved.WatermarkId,
resolved.FallbackFillerId,
cancellationToken);
foreach (BaseError error in channelReferences.LeftToSeq())
{
@@ -548,9 +559,9 @@ public class CreateChannelFromLineupHandler(
Either<BaseError, Unit> itemFillers = await ValidateItemFillers(
dbContext,
advanced.PreRollFillerId ?? template.PreRollFillerId,
advanced.MidRollFillerId ?? template.MidRollFillerId,
advanced.PostRollFillerId ?? template.PostRollFillerId,
resolved.PreRollFillerId,
resolved.MidRollFillerId,
resolved.PostRollFillerId,
cancellationToken);
foreach (BaseError error in itemFillers.LeftToSeq())
{
@@ -560,6 +571,80 @@ public class CreateChannelFromLineupHandler(
return Unit.Default;
}
// A field named in advanced.Clear must not also carry a set value: that request is contradictory.
// A null/empty set value alongside a clear is fine (redundant, not conflicting). (#135)
private static Either<BaseError, Unit> ValidateClear(CreateChannelFromLineupAdvancedOptions advanced)
{
if (advanced.Clear is null || advanced.Clear.Count == 0)
{
return Unit.Default;
}
var cleared = advanced.Clear.ToHashSet();
(CreateChannelFromLineupClearField Field, bool HasSetValue)[] checks =
[
(CreateChannelFromLineupClearField.Watermark, advanced.WatermarkId.HasValue),
(CreateChannelFromLineupClearField.FallbackFiller, advanced.FallbackFillerId.HasValue),
(CreateChannelFromLineupClearField.PreRollFiller, advanced.PreRollFillerId.HasValue),
(CreateChannelFromLineupClearField.MidRollFiller, advanced.MidRollFillerId.HasValue),
(CreateChannelFromLineupClearField.PostRollFiller, advanced.PostRollFillerId.HasValue),
(CreateChannelFromLineupClearField.PreferredAudioLanguage,
!string.IsNullOrEmpty(advanced.PreferredAudioLanguageCode)),
(CreateChannelFromLineupClearField.PreferredAudioTitle,
!string.IsNullOrEmpty(advanced.PreferredAudioTitle)),
(CreateChannelFromLineupClearField.PreferredSubtitleLanguage,
!string.IsNullOrEmpty(advanced.PreferredSubtitleLanguageCode))
];
foreach ((CreateChannelFromLineupClearField field, bool hasSetValue) in checks)
{
if (cleared.Contains(field) && hasSetValue)
{
return BaseError.New(
$"Advanced option '{field}' cannot be both set and cleared in the same request");
}
}
return Unit.Default;
}
// Compute the effective value of every clearable field once: cleared -> none, else the advanced
// override coalesced with the template value (the historical omitted=inherit contract). (#135)
private static ResolvedClearableOptions ResolveClearable(
CreateChannelFromLineupAdvancedOptions advanced,
ChannelTemplate template)
{
System.Collections.Generic.HashSet<CreateChannelFromLineupClearField> cleared = advanced.Clear is null
? []
: advanced.Clear.ToHashSet();
int? Id(CreateChannelFromLineupClearField field, int? adv, int? tmpl) =>
cleared.Contains(field) ? null : adv ?? tmpl;
string Str(CreateChannelFromLineupClearField field, string adv, string tmpl) =>
cleared.Contains(field) ? string.Empty : adv ?? tmpl ?? string.Empty;
return new ResolvedClearableOptions(
Id(CreateChannelFromLineupClearField.Watermark, advanced.WatermarkId, template.WatermarkId),
Id(CreateChannelFromLineupClearField.FallbackFiller, advanced.FallbackFillerId, template.FallbackFillerId),
Id(CreateChannelFromLineupClearField.PreRollFiller, advanced.PreRollFillerId, template.PreRollFillerId),
Id(CreateChannelFromLineupClearField.MidRollFiller, advanced.MidRollFillerId, template.MidRollFillerId),
Id(CreateChannelFromLineupClearField.PostRollFiller, advanced.PostRollFillerId, template.PostRollFillerId),
Str(
CreateChannelFromLineupClearField.PreferredAudioLanguage,
advanced.PreferredAudioLanguageCode,
template.PreferredAudioLanguageCode),
Str(
CreateChannelFromLineupClearField.PreferredAudioTitle,
advanced.PreferredAudioTitle,
template.PreferredAudioTitle),
Str(
CreateChannelFromLineupClearField.PreferredSubtitleLanguage,
advanced.PreferredSubtitleLanguageCode,
template.PreferredSubtitleLanguageCode));
}
private static async Task<Either<BaseError, Unit>> ValidateChannelReferences(
TvContext dbContext,
int? watermarkId,
@@ -803,4 +888,16 @@ public class CreateChannelFromLineupHandler(
Playlist Playlist,
ProgramSchedule ProgramSchedule,
Playout Playout);
// Effective values for the clearable advanced fields after applying advanced.Clear + template
// coalescing (#135). Strings coalesce to string.Empty (never null); ids stay nullable.
private sealed record ResolvedClearableOptions(
int? WatermarkId,
int? FallbackFillerId,
int? PreRollFillerId,
int? MidRollFillerId,
int? PostRollFillerId,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
string PreferredSubtitleLanguageCode);
}
@@ -1,16 +1,35 @@
using System.Threading.Channels;
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Scheduling;
namespace ErsatzTV.Application.Playouts;
public class TimeShiftOnDemandPlayoutHandler(IPlayoutTimeShifter playoutTimeShifter)
public class TimeShiftOnDemandPlayoutHandler(
IPlayoutTimeShifter playoutTimeShifter,
ChannelWriter<IBackgroundServiceRequest> workerChannel)
: IRequestHandler<TimeShiftOnDemandPlayout, Option<BaseError>>
{
public async Task<Option<BaseError>> Handle(TimeShiftOnDemandPlayout request, CancellationToken cancellationToken)
{
try
{
await playoutTimeShifter.TimeShift(request.PlayoutId, request.Now, request.Force, cancellationToken);
List<string> staleGuideChannels = await playoutTimeShifter.TimeShift(
request.PlayoutId,
request.Now,
request.Force,
cancellationToken);
// the time shift rewrote stored PlayoutItem timestamps but not the cached XMLTV
// fragment; rebuild the guide for the shifted channel (and any mirrors of it) so a
// client tuning in doesn't see a stale timeline. this is a post-commit side effect
// (TimeShift already saved) so it runs on CancellationToken.None — a session token that
// cancels between the DB commit and this enqueue must not leave the guide stale
// (decisions.md api.postcommit-cancellation-none)
foreach (string channelNumber in staleGuideChannels)
{
await workerChannel.WriteAsync(new RefreshChannelData(channelNumber), CancellationToken.None);
}
}
catch (Exception ex)
{
@@ -26,7 +26,9 @@ namespace ErsatzTV.Application.Streaming;
public class HlsSessionWorker : IHlsSessionWorker
{
private static int _workAheadCount;
// process-wide, shared by every session — the work-ahead limit is a global resource budget
private static readonly WorkAheadSlots _workAheadSlots = new();
private readonly OutputFormatKind _outputFormatKind;
private readonly IHlsInitSegmentCache _hlsInitSegmentCache;
private readonly Dictionary<long, int> _discontinuityMap = [];
@@ -243,10 +245,12 @@ public class HlsSessionWorker : IHlsSessionWorker
cancellationToken);
}
bool initialWorkAhead = Volatile.Read(ref _workAheadCount) < await GetWorkAheadLimit(cancellationToken);
// claim the slot here rather than checking here and claiming inside Transcode: the check
// and the claim have to be one atomic step or every simultaneous tune-in wins (#536)
bool initialWorkAhead = _workAheadSlots.TryAcquire(await GetWorkAheadLimit(cancellationToken));
_state = initialWorkAhead ? HlsSessionState.SeekAndWorkAhead : HlsSessionState.SeekAndRealtime;
if (!await Transcode(!initialWorkAhead, cancellationToken))
if (!await Transcode(initialWorkAhead, cancellationToken))
{
return;
}
@@ -269,8 +273,8 @@ public class HlsSessionWorker : IHlsSessionWorker
// only use realtime encoding when we're at least 30 seconds ahead
bool realtime = transcodedBuffer >= TimeSpan.FromSeconds(30);
bool subsequentWorkAhead =
!realtime && Volatile.Read(ref _workAheadCount) < await GetWorkAheadLimit(cancellationToken);
if (!await Transcode(!subsequentWorkAhead, cancellationToken))
!realtime && _workAheadSlots.TryAcquire(await GetWorkAheadLimit(cancellationToken));
if (!await Transcode(subsequentWorkAhead, cancellationToken))
{
return;
}
@@ -456,15 +460,23 @@ public class HlsSessionWorker : IHlsSessionWorker
return result;
}
private async Task<bool> Transcode(bool realtime, CancellationToken cancellationToken)
/// <summary>
/// Runs one transcode. The caller is the one that races for a work-ahead slot, so ownership is
/// passed IN: <paramref name="ownsWorkAheadSlot" /> means the caller already claimed a slot from
/// <see cref="_workAheadSlots" />, and this method releases it in its <c>finally</c> — acquire
/// and release stay one-for-one (#536).
/// </summary>
private async Task<bool> Transcode(bool ownsWorkAheadSlot, CancellationToken cancellationToken)
{
// a session works ahead exactly when it holds a slot; everything else runs realtime (throttled)
bool realtime = !ownsWorkAheadSlot;
try
{
bool wasSeekAndWorkAhead = _state is HlsSessionState.SeekAndWorkAhead;
if (!realtime)
{
Interlocked.Increment(ref _workAheadCount);
_logger.LogDebug("HLS segmenter will work ahead for channel {Channel}", _channelNumber);
HlsSessionState nextState = _state switch
@@ -747,9 +759,15 @@ public class HlsSessionWorker : IHlsSessionWorker
// do nothing
}
if (!realtime)
if (ownsWorkAheadSlot && !_workAheadSlots.Release())
{
Interlocked.Decrement(ref _workAheadCount);
// Release() reports false only when the pool was already empty, i.e. this slot was
// released more than once. Nothing reaches here in a correct program, but if a
// future second release site breaks the ownership contract this is the one in-band
// signal that the unthrottled-transcode budget is inflated (ersatztv#536/#539 §3).
_logger.LogWarning(
"Released a work-ahead slot that was not held for channel {Channel} - the unthrottled-transcode budget may be inflated",
_channelNumber);
}
}
@@ -0,0 +1,102 @@
namespace ErsatzTV.Application.Streaming;
/// <summary>
/// The process-wide pool of work-ahead slots shared by every HLS session (ersatztv#536).
/// </summary>
/// <remarks>
/// <para>
/// <c>workAheadSegmenterLimit</c> is a resource guarantee, not a tuning knob: it bounds how many
/// transcodes may run unthrottled (no <c>-readrate</c>) at once, and the QSV hardware-frame pool
/// sizing from ersatztv#529 assumes that bound holds.
/// </para>
/// <para>
/// Acquisition must therefore be atomic. The previous shape — <c>Volatile.Read(count) &lt; limit</c>
/// in the caller, <c>Interlocked.Increment</c> later inside the transcode — is a check-then-act
/// TOCTOU separated by at least one <c>await</c> (the limit is a DB-backed config read), so N
/// simultaneous tune-ins all observed <c>0 &lt; limit</c> and all ran unthrottled. Same class as
/// ersatztv#231 / #250.
/// </para>
/// </remarks>
public sealed class WorkAheadSlots
{
private int _count;
private int _unbalancedReleases;
/// <summary>
/// Gets the number of slots currently held. For diagnostics and tests only — never branch on
/// this to decide whether to work ahead; that is exactly the race <see cref="TryAcquire" /> exists to close.
/// </summary>
public int Count => Volatile.Read(ref _count);
/// <summary>
/// Atomically claims one slot if fewer than <paramref name="limit" /> are held.
/// </summary>
/// <returns><c>true</c> when a slot was claimed; the caller then owns it and MUST
/// <see cref="Release" /> it exactly once.</returns>
public bool TryAcquire(int limit)
{
while (true)
{
int current = Volatile.Read(ref _count);
if (current >= limit)
{
return false;
}
// only the thread whose compare-exchange observes the value it read wins the slot, so
// the count can never transiently exceed the limit and two racers can never both claim
if (Interlocked.CompareExchange(ref _count, current + 1, current) == current)
{
return true;
}
}
}
/// <summary>
/// Gets the number of releases that were not matched by a successful acquire. Non-zero always
/// means the ownership contract was broken somewhere (no false positives), so the value is a
/// reliable "something is wrong" signal — but it can UNDER-count and zero does not prove
/// correctness. It only increments when a release finds the pool already empty; an over-release
/// that happens while the count is positive — e.g. one cancelling out a coexisting leak —
/// decrements a real-looking slot and is never recorded, so the two bugs hide each other. This
/// is inherent to a single counter; exact accounting would need per-owner tokens (ersatztv#539 §2).
/// </summary>
public int UnbalancedReleases => Volatile.Read(ref _unbalancedReleases);
/// <summary>
/// Returns a slot claimed by <see cref="TryAcquire" />. Only ever called by the owner of that slot.
/// </summary>
/// <returns>
/// <c>true</c> when a held slot was returned; <c>false</c> when the pool was already empty, i.e.
/// the release was unbalanced (also counted in <see cref="UnbalancedReleases" />). Callers should
/// log the <c>false</c> case: it is the only in-band signal that the budget contract was broken.
/// </returns>
/// <remarks>
/// Ownership is a discipline, not a token — the same call-once contract as `EntityLocker` (#231).
/// The one failure this defends against is an unbalanced release inflating the budget: this pool
/// is process-wide and lives for the life of the app, so a leaked slot would silently and
/// permanently admit one extra unthrottled transcode, re-opening the #529 QSV pool exhaustion.
/// It clamps at zero rather than throwing — the single caller releases from a `finally`, where a
/// throw would swallow the real exception. Unlike a decrement-first-then-clamp shape, this never
/// publishes a negative count even transiently, so a concurrent <see cref="TryAcquire" /> can
/// never read the pool as having phantom room and over-admit (ersatztv#539 §1); and it records
/// the unbalanced release synchronously here, rather than blaming a later, innocent release.
/// </remarks>
public bool Release()
{
while (true)
{
int current = Volatile.Read(ref _count);
if (current <= 0)
{
Interlocked.Increment(ref _unbalancedReleases);
return false;
}
if (Interlocked.CompareExchange(ref _count, current - 1, current) == current)
{
return true;
}
}
}
}
@@ -0,0 +1,263 @@
using ErsatzTV.Application.Streaming;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.Streaming;
// Verifies the atomic-claim contract of the work-ahead slot pool (ersatztv#536): at most
// `limit` sessions may hold a slot at once, no matter how many race for one simultaneously.
//
// NEGATIVE CONTROL (per the ersatztv#231/#250 lesson — a one-shot Barrier + Task.WhenAll does NOT
// catch this class on this hardware): to prove these tests are non-vacuous, temporarily replace the
// compare-exchange in WorkAheadSlots.TryAcquire with the check-then-act shape this issue fixed —
//
// int current = Volatile.Read(ref _count);
// if (current >= limit) return false;
// Interlocked.Increment(ref _count);
// return true;
//
// — and TryAcquire_ParallelCallers_NeverExceedsLimit must FAIL (badRounds > 0). Do NOT "break" it by
// stubbing `if (true)`: that leaves values assigned-but-never-read, and CS0219 under
// warnings-as-errors fails the build silently, so `dotnet test --no-build` then runs the STALE
// (fixed) dll and the control falsely passes. Always grep the build output for `error CS` first.
[TestFixture]
public class WorkAheadSlotsTests
{
[Test]
public void TryAcquire_BelowLimit_Succeeds()
{
var slots = new WorkAheadSlots();
slots.TryAcquire(2).ShouldBeTrue();
slots.TryAcquire(2).ShouldBeTrue();
slots.TryAcquire(2).ShouldBeFalse();
slots.Count.ShouldBe(2);
}
[Test]
public void TryAcquire_DoesNotConsumeASlotWhenItFails()
{
var slots = new WorkAheadSlots();
slots.TryAcquire(1).ShouldBeTrue();
slots.TryAcquire(1).ShouldBeFalse();
slots.Count.ShouldBe(1);
// the failed attempt must not have leaked a slot: releasing the one real holder frees the pool
slots.Release();
slots.Count.ShouldBe(0);
slots.TryAcquire(1).ShouldBeTrue();
}
[Test]
public void TryAcquire_WithNonPositiveLimit_NeverSucceeds()
{
var slots = new WorkAheadSlots();
slots.TryAcquire(0).ShouldBeFalse();
slots.TryAcquire(-1).ShouldBeFalse();
slots.Count.ShouldBe(0);
}
// The pool is process-wide and never recreated, so a negative count would not self-heal: it would
// permanently admit more than `limit` unthrottled transcodes, with nothing in the logs to find it by.
[Test]
public void Release_WithoutAcquire_ClampsAtZeroAndIsRecorded()
{
var slots = new WorkAheadSlots();
slots.Release();
slots.Count.ShouldBe(0);
slots.UnbalancedReleases.ShouldBe(1);
// the budget is intact: a limit of 1 still admits exactly one holder, not two
slots.TryAcquire(1).ShouldBeTrue();
slots.TryAcquire(1).ShouldBeFalse();
}
[Test]
public void UnbalancedReleases_IsZeroUnderCorrectUse()
{
var slots = new WorkAheadSlots();
slots.TryAcquire(1).ShouldBeTrue();
slots.Release();
slots.UnbalancedReleases.ShouldBe(0);
}
// Hammers the acquire race over many rounds rather than a single simultaneous burst: the
// read->increment window is far too narrow to collide reliably when threads release once.
[Test]
[TestCase(1)]
[TestCase(2)]
[TestCase(3)]
public void TryAcquire_ParallelCallers_NeverExceedsLimit(int limit)
{
const int threads = 8;
const int rounds = 20_000;
var slots = new WorkAheadSlots();
var winners = 0;
var badRounds = 0;
using var startRound = new Barrier(threads);
using var endRound = new Barrier(
threads,
_ =>
{
int held = Volatile.Read(ref winners);
if (held > limit || held != slots.Count)
{
Interlocked.Increment(ref badRounds);
}
// reset for the next round: release every slot claimed this round
for (var i = 0; i < held; i++)
{
slots.Release();
}
Volatile.Write(ref winners, 0);
});
var workers = new Thread[threads];
for (var t = 0; t < threads; t++)
{
workers[t] = new Thread(() =>
{
for (var r = 0; r < rounds; r++)
{
startRound.SignalAndWait();
if (slots.TryAcquire(limit))
{
Interlocked.Increment(ref winners);
}
endRound.SignalAndWait();
}
});
workers[t].Start();
}
foreach (Thread worker in workers)
{
worker.Join();
}
badRounds.ShouldBe(0);
slots.Count.ShouldBe(0);
slots.UnbalancedReleases.ShouldBe(0);
}
// §1 (ersatztv#539): an unbalanced Release() must never publish a NEGATIVE count, even
// transiently. The pre-#539 shape decremented FIRST (0 -> -1) and clamped afterwards, so a
// concurrent TryAcquire(limit) could read the -1, see "-1 < limit" as phantom room, and admit a
// holder the budget doesn't have (the second acquirer then reads 0 and admits another). Because
// TryAcquire is the sole, CAS-guarded increment path, a count that is provably never negative is
// exactly what forecloses that over-admit. Here one thread hammers unbalanced releases on an
// empty pool while readers sample the count; none may ever observe a value below zero.
//
// NEGATIVE CONTROL (per the class-level ersatztv#231/#250 lesson) — revert Release() to its
// pre-#539 decrement-first body to prove this test is non-vacuous:
//
// if (Interlocked.Decrement(ref _count) >= 0) return true;
// Interlocked.Increment(ref _unbalancedReleases);
// while (true) { int c = Volatile.Read(ref _count);
// if (c >= 0 || Interlocked.CompareExchange(ref _count, 0, c) == c) return false; }
//
// — and this test must FAIL (sawNegative > 0): the readers catch the transient -1. Do NOT stub
// `if (true)`: that leaves values assigned-but-never-read, and CS0219 under warnings-as-errors
// fails the build silently so `dotnet test --no-build` runs the STALE dll and the control falsely
// passes. Always grep the build output for `error CS` first.
[Test]
public void Release_Unbalanced_NeverPublishesNegativeCount()
{
const int releases = 2_000_000;
const int readers = 4;
var slots = new WorkAheadSlots();
var sawNegative = 0;
var done = false;
var readerThreads = new Thread[readers];
for (var i = 0; i < readers; i++)
{
readerThreads[i] = new Thread(() =>
{
while (!Volatile.Read(ref done))
{
if (slots.Count < 0)
{
Interlocked.Increment(ref sawNegative);
}
}
});
readerThreads[i].Start();
}
// every release finds the pool empty, so every one is unbalanced
for (var r = 0; r < releases; r++)
{
slots.Release();
}
Volatile.Write(ref done, true);
foreach (Thread reader in readerThreads)
{
reader.Join();
}
sawNegative.ShouldBe(0);
slots.Count.ShouldBe(0);
slots.UnbalancedReleases.ShouldBe(releases);
}
// The release path is the fiddly half: a slot freed by its owner must become available again,
// and a burst of acquire/release cycles must not drift the count in either direction.
[Test]
public void AcquireAndRelease_UnderContention_LeavesNoLeakedOrDoubleFreedSlots()
{
const int threads = 8;
const int rounds = 20_000;
const int limit = 3;
var slots = new WorkAheadSlots();
var overLimit = 0;
var live = 0;
var workers = new Thread[threads];
for (var t = 0; t < threads; t++)
{
workers[t] = new Thread(() =>
{
for (var r = 0; r < rounds; r++)
{
if (!slots.TryAcquire(limit))
{
continue;
}
if (Interlocked.Increment(ref live) > limit)
{
Interlocked.Increment(ref overLimit);
}
Interlocked.Decrement(ref live);
slots.Release();
}
});
workers[t].Start();
}
foreach (Thread worker in workers)
{
worker.Join();
}
overLimit.ShouldBe(0);
slots.Count.ShouldBe(0);
slots.UnbalancedReleases.ShouldBe(0);
}
}
@@ -0,0 +1,20 @@
namespace ErsatzTV.Core.Api.Channels;
// The "clear to none" signal for POST /api/v1/channels/from-lineup (#135). For these
// template-inheritable advanced fields a null/omitted override means INHERIT the template value;
// naming the field here forces it to NONE on the new channel even when the template sets one.
// Omitting the field entirely keeps the historical omitted=inherit behavior stable for existing
// clients. Sending both a set value and a clear for the same field is a validation error (see
// CreateChannelFromLineupHandler). Lives in Core so the OpenAPI string-enum scan (Startup
// UseStringEnumSchemas) renders it as a string enum, matching every sibling advanced-options enum.
public enum CreateChannelFromLineupClearField
{
Watermark,
FallbackFiller,
PreRollFiller,
MidRollFiller,
PostRollFiller,
PreferredAudioLanguage,
PreferredAudioTitle,
PreferredSubtitleLanguage
}
@@ -2,5 +2,14 @@ namespace ErsatzTV.Core.Interfaces.Scheduling;
public interface IPlayoutTimeShifter
{
Task TimeShift(int playoutId, DateTimeOffset now, bool force, CancellationToken cancellationToken);
/// <summary>
/// Slides an on-demand playout's materialized timeline forward so the item the viewer had
/// reached is active again at <paramref name="now" />.
/// </summary>
/// <returns>
/// The channel numbers whose cached XMLTV guide is now stale and should be rebuilt — the shifted
/// channel plus any channels that mirror it — when a non-zero shift was persisted; otherwise an
/// empty list.
/// </returns>
Task<List<string>> TimeShift(int playoutId, DateTimeOffset now, bool force, CancellationToken cancellationToken);
}
@@ -15,7 +15,11 @@ public class PlayoutTimeShifter(
ILogger<PlayoutTimeShifter> logger)
: IPlayoutTimeShifter
{
public async Task TimeShift(int playoutId, DateTimeOffset now, bool force, CancellationToken cancellationToken)
public async Task<List<string>> TimeShift(
int playoutId,
DateTimeOffset now,
bool force,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
@@ -39,7 +43,7 @@ public class PlayoutTimeShifter(
{
if (playout.Channel.PlayoutMode is not ChannelPlayoutMode.OnDemand)
{
return;
return [];
}
if (!force && segmenterService.IsActive(playout.Channel.Number))
@@ -49,7 +53,7 @@ public class PlayoutTimeShifter(
playout.Channel.Number,
playout.Channel.Name);
return;
return [];
}
if (playout.Items.Count == 0)
@@ -59,7 +63,7 @@ public class PlayoutTimeShifter(
playout.Channel.Number,
playout.Channel.Name);
return;
return [];
}
if (playout.OnDemandCheckpoint is null)
@@ -122,7 +126,26 @@ public class PlayoutTimeShifter(
playout.OnDemandCheckpoint = now;
await dbContext.SaveChangesAsync(cancellationToken);
// a non-zero shift moved every PlayoutItem (and its guide window), so the cached
// XMLTV fragment for this channel is now stale; report the channel number so the
// caller can rebuild the guide and keep it in sync with playback
if (toOffset != TimeSpan.Zero)
{
// mirror channels relay this channel's timeline (shifted by their PlayoutOffset),
// so their cached guides are stale too — refresh them alongside, mirroring the
// fan-out BuildPlayoutHandler already does after it time-shifts
List<string> mirrorNumbers = await dbContext.Channels
.AsNoTracking()
.Filter(c => c.MirrorSourceChannelId == channel.Id)
.Map(c => c.Number)
.ToListAsync(cancellationToken);
return [playout.Channel.Number, .. mirrorNumbers];
}
}
}
return [];
}
}
@@ -304,6 +304,110 @@ public class CreateChannelFromLineupHandlerTests
item.MidRollFillerId.ShouldBe(3);
}
[Test]
public async Task Clear_Should_Force_Template_Inherited_Values_To_None()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
// Give the template a watermark + preferred audio language so "clear" has something to drop
// (the base template has fillers 2-5 but no watermark / audio language).
await using (TvContext context = _db.CreateContext())
{
context.ChannelWatermarks.Add(new ChannelWatermark { Id = 21, Name = "wm", Image = "wm.png" });
ChannelTemplate template = await context.ChannelTemplates.SingleAsync();
template.WatermarkId = 21;
template.PreferredAudioLanguageCode = "eng";
await context.SaveChangesAsync();
}
var advanced = new CreateChannelFromLineupAdvancedOptions(
PlaybackOrder: PlaybackOrder.Shuffle,
Clear:
[
CreateChannelFromLineupClearField.Watermark,
CreateChannelFromLineupClearField.FallbackFiller,
CreateChannelFromLineupClearField.PreRollFiller,
CreateChannelFromLineupClearField.MidRollFiller,
CreateChannelFromLineupClearField.PostRollFiller,
CreateChannelFromLineupClearField.PreferredAudioLanguage
]);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(MakeRequest(advanced: advanced), CancellationToken.None);
RightOf(result);
await using TvContext assert = _db.CreateContext();
DomainChannel channel = await assert.Channels.SingleAsync();
// Cleared -> none, even though the template supplies a value.
channel.WatermarkId.ShouldBeNull();
channel.FallbackFillerId.ShouldBeNull();
channel.PreferredAudioLanguageCode.ShouldBe(string.Empty);
ProgramScheduleItem item = await assert.ProgramScheduleItems.SingleAsync();
item.PreRollFillerId.ShouldBeNull();
item.MidRollFillerId.ShouldBeNull();
item.PostRollFillerId.ShouldBeNull();
item.FallbackFillerId.ShouldBeNull();
item.PreferredAudioLanguageCode.ShouldBe(string.Empty);
}
[Test]
public async Task Clear_And_Set_Same_Field_Should_Be_Validation_Error()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
await using (TvContext context = _db.CreateContext())
{
context.FillerPresets.Add(MakeFiller(6, FillerKind.Fallback));
await context.SaveChangesAsync();
}
var advanced = new CreateChannelFromLineupAdvancedOptions(
PlaybackOrder: PlaybackOrder.Shuffle,
FallbackFillerId: 6,
Clear: [CreateChannelFromLineupClearField.FallbackFiller]);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(MakeRequest(advanced: advanced), CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("cannot be both set and cleared");
}
[Test]
public async Task Clear_And_Set_String_Field_Conflicts_But_Empty_String_Is_Redundant()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
// A real value + clear on the same string field is contradictory.
var conflicting = new CreateChannelFromLineupAdvancedOptions(
PlaybackOrder: PlaybackOrder.Shuffle,
PreferredAudioLanguageCode: "eng",
Clear: [CreateChannelFromLineupClearField.PreferredAudioLanguage]);
BaseError error = LeftOf(
await MakeHandler().Handle(MakeRequest(advanced: conflicting), CancellationToken.None));
error.Value.ShouldContain("cannot be both set and cleared");
// An empty string + clear is redundant, not conflicting -> the create still succeeds. (This
// runs second: the conflicting request above returned Left without persisting, so number "12"
// is still free.)
var redundant = new CreateChannelFromLineupAdvancedOptions(
PlaybackOrder: PlaybackOrder.Shuffle,
PreferredAudioLanguageCode: "",
Clear: [CreateChannelFromLineupClearField.PreferredAudioLanguage]);
RightOf(await MakeHandler().Handle(MakeRequest(advanced: redundant), CancellationToken.None));
}
[Test]
public async Task Should_Return_Validation_Error_When_Not_Exactly_One_Id_Provided()
{
@@ -0,0 +1,90 @@
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core.Interfaces.Scheduling;
using LanguageExt;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using Channel = System.Threading.Channels.Channel;
namespace ErsatzTV.Tests.Application.Playouts;
[TestFixture]
public class TimeShiftOnDemandPlayoutHandlerTests
{
private IPlayoutTimeShifter _timeShifter = null!;
private Channel<IBackgroundServiceRequest> _worker = null!;
[SetUp]
public void SetUp()
{
_timeShifter = Substitute.For<IPlayoutTimeShifter>();
_worker = Channel.CreateUnbounded<IBackgroundServiceRequest>();
}
private TimeShiftOnDemandPlayoutHandler CreateHandler() => new(_timeShifter, _worker.Writer);
private List<string> DrainWorker()
{
var messages = new List<string>();
while (_worker.Reader.TryRead(out IBackgroundServiceRequest message))
{
message.ShouldBeOfType<RefreshChannelData>();
messages.Add(((RefreshChannelData)message).ChannelNumber);
}
return messages;
}
[Test]
public async Task Should_Rebuild_Guide_When_Playout_Was_Shifted()
{
// a non-zero shift reports the channel number whose cached XMLTV is now stale
_timeShifter
.TimeShift(Arg.Any<int>(), Arg.Any<DateTimeOffset>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(["42"]);
Option<ErsatzTV.Core.BaseError> result = await CreateHandler().Handle(
new TimeShiftOnDemandPlayout(1, DateTimeOffset.Now, true),
CancellationToken.None);
result.IsNone.ShouldBeTrue();
// the guide for that channel must be rebuilt so playback and EPG stay in sync
DrainWorker().ShouldBe(["42"]);
}
[Test]
public async Task Should_Rebuild_Guides_For_Shifted_Channel_And_Its_Mirrors()
{
// a mirror channel relays the shifted timeline, so its cached guide is stale too
_timeShifter
.TimeShift(Arg.Any<int>(), Arg.Any<DateTimeOffset>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(["42", "43", "44"]);
Option<ErsatzTV.Core.BaseError> result = await CreateHandler().Handle(
new TimeShiftOnDemandPlayout(1, DateTimeOffset.Now, true),
CancellationToken.None);
result.IsNone.ShouldBeTrue();
DrainWorker().ShouldBe(["42", "43", "44"]);
}
[Test]
public async Task Should_Not_Rebuild_Guide_When_No_Shift_Occurred()
{
// no shift (wrong mode / active session / no elapsed time) => nothing to rebuild
_timeShifter
.TimeShift(Arg.Any<int>(), Arg.Any<DateTimeOffset>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns([]);
Option<ErsatzTV.Core.BaseError> result = await CreateHandler().Handle(
new TimeShiftOnDemandPlayout(1, DateTimeOffset.Now, true),
CancellationToken.None);
result.IsNone.ShouldBeTrue();
DrainWorker().ShouldBeEmpty();
}
}
@@ -0,0 +1,194 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Scheduling;
using ErsatzTV.Tests.Support;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Infrastructure.Scheduling;
[TestFixture]
public class PlayoutTimeShifterTests
{
// fixed timestamps so the test is deterministic (Date.Now is never read here)
private static readonly DateTimeOffset Checkpoint = new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero);
private static readonly DateTimeOffset Now = Checkpoint.AddDays(3);
private InMemoryTvContext _db = null!;
private IFFmpegSegmenterService _segmenter = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_segmenter = Substitute.For<IFFmpegSegmenterService>();
_segmenter.IsActive(Arg.Any<string>()).Returns(false);
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private PlayoutTimeShifter CreateSubject() =>
new(_db.Factory, _segmenter, NullLogger<PlayoutTimeShifter>.Instance);
private static Channel MakeChannel(int id, string number, ChannelPlayoutMode mode) =>
new(Guid.NewGuid())
{
Id = id,
Number = number,
Name = $"Channel {number}",
Group = "",
Categories = "",
StreamSelector = "",
PreferredAudioLanguageCode = "",
PreferredAudioTitle = "",
PreferredSubtitleLanguageCode = "",
MusicVideoCreditsTemplate = "",
PlayoutMode = mode
};
private async Task<int> SeedPlayout(
ChannelPlayoutMode mode,
DateTimeOffset? checkpoint,
bool withMirror = false)
{
await using TvContext context = _db.CreateContext();
Channel channel = MakeChannel(1, "1", mode);
var playout = new Playout
{
Id = 1,
ChannelId = channel.Id,
Channel = channel,
OnDemandCheckpoint = checkpoint,
Items =
[
new PlayoutItem
{
Id = 1,
Start = Checkpoint.UtcDateTime,
Finish = Checkpoint.UtcDateTime.AddHours(1),
GuideStart = Checkpoint.UtcDateTime,
GuideFinish = Checkpoint.UtcDateTime.AddHours(1)
}
],
PlayoutHistory = [],
ProgramScheduleAnchors = []
};
await context.Channels.AddAsync(channel);
await context.Playouts.AddAsync(playout);
if (withMirror)
{
Channel mirror = MakeChannel(2, "2", ChannelPlayoutMode.Continuous);
mirror.PlayoutSource = ChannelPlayoutSource.Mirror;
mirror.MirrorSourceChannelId = channel.Id;
await context.Channels.AddAsync(mirror);
}
await context.SaveChangesAsync();
return playout.Id;
}
[Test]
public async Task Should_Shift_Items_And_Report_Channel_Number_For_OnDemand_Playout()
{
int playoutId = await SeedPlayout(ChannelPlayoutMode.OnDemand, Checkpoint);
List<string> shifted = await CreateSubject().TimeShift(playoutId, Now, force: true, CancellationToken.None);
// reports the channel number so the caller can rebuild the (now-stale) cached guide
shifted.ShouldBe(["1"]);
await using TvContext context = _db.CreateContext();
Playout playout = await context.Playouts.Include(p => p.Items).SingleAsync(p => p.Id == playoutId);
PlayoutItem item = playout.Items.Single();
TimeSpan expectedOffset = Now - Checkpoint; // 3 days
// both playback (Start/Finish) and the guide window (GuideStart/GuideFinish) advance together
item.Start.ShouldBe(Checkpoint.UtcDateTime + expectedOffset);
item.Finish.ShouldBe(Checkpoint.UtcDateTime.AddHours(1) + expectedOffset);
item.GuideStart!.Value.ShouldBe(Checkpoint.UtcDateTime + expectedOffset);
item.GuideFinish!.Value.ShouldBe(Checkpoint.UtcDateTime.AddHours(1) + expectedOffset);
playout.OnDemandCheckpoint.ShouldBe(Now);
}
[Test]
public async Task Should_Also_Report_Mirror_Channels_So_Their_Guides_Rebuild()
{
// a channel mirroring the shifted on-demand channel relays its timeline, so its cached
// guide is stale after the thaw and must be rebuilt too
int playoutId = await SeedPlayout(ChannelPlayoutMode.OnDemand, Checkpoint, withMirror: true);
List<string> shifted = await CreateSubject().TimeShift(playoutId, Now, force: true, CancellationToken.None);
shifted.ShouldBe(["1", "2"]); // shifted channel first, then its mirror
}
[Test]
public async Task Should_Shift_And_Seed_Checkpoint_For_Never_Watched_Playout()
{
// null checkpoint => it is seeded to the earliest item start, then the whole timeline slides
// forward to `now` (the subtlest branch: an unwatched channel still resumes correctly)
int playoutId = await SeedPlayout(ChannelPlayoutMode.OnDemand, checkpoint: null);
List<string> shifted = await CreateSubject().TimeShift(playoutId, Now, force: true, CancellationToken.None);
shifted.ShouldBe(["1"]);
await using TvContext context = _db.CreateContext();
Playout playout = await context.Playouts.Include(p => p.Items).SingleAsync(p => p.Id == playoutId);
playout.OnDemandCheckpoint.ShouldBe(Now);
// item started at Checkpoint; with the seed = earliest start, the offset is Now - Checkpoint
playout.Items.Single().Start.ShouldBe(Now.UtcDateTime);
}
[Test]
public async Task Should_Not_Shift_Or_Report_For_Continuous_Playout()
{
// negative control: a Continuous channel is never time-shifted
int playoutId = await SeedPlayout(ChannelPlayoutMode.Continuous, Checkpoint);
List<string> shifted = await CreateSubject().TimeShift(playoutId, Now, force: true, CancellationToken.None);
shifted.ShouldBeEmpty();
await using TvContext context = _db.CreateContext();
PlayoutItem item = await context.PlayoutItems.SingleAsync();
item.Start.ShouldBe(Checkpoint.UtcDateTime); // untouched
}
[Test]
public async Task Should_Not_Shift_When_Active_And_Not_Forced()
{
// an active session must not have its timeline yanked out from under it unless forced
_segmenter.IsActive("1").Returns(true);
int playoutId = await SeedPlayout(ChannelPlayoutMode.OnDemand, Checkpoint);
List<string> shifted = await CreateSubject().TimeShift(playoutId, Now, force: false, CancellationToken.None);
shifted.ShouldBeEmpty();
await using TvContext context = _db.CreateContext();
PlayoutItem item = await context.PlayoutItems.SingleAsync();
item.Start.ShouldBe(Checkpoint.UtcDateTime); // untouched
}
[Test]
public async Task Should_Not_Report_When_Shift_Is_Zero()
{
// non-vacuous gate: an OnDemand playout already at `now` produces a zero offset,
// so there is no stale guide to rebuild
int playoutId = await SeedPlayout(ChannelPlayoutMode.OnDemand, Now);
List<string> shifted = await CreateSubject().TimeShift(playoutId, Now, force: true, CancellationToken.None);
shifted.ShouldBeEmpty();
}
}
@@ -2,6 +2,7 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.Channels;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
@@ -64,7 +65,8 @@ public record CreateChannelFromLineupAdvancedOptionsRequest(
ChannelIdleBehavior? IdleBehavior = null,
bool? ShuffleScheduleItems = null,
bool? RandomStartPoint = null,
FixedStartTimeBehavior? FixedStartTimeBehavior = null)
FixedStartTimeBehavior? FixedStartTimeBehavior = null,
List<CreateChannelFromLineupClearField>? Clear = null)
{
public CreateChannelFromLineupAdvancedOptions ToCommand() =>
new(
@@ -91,7 +93,8 @@ public record CreateChannelFromLineupAdvancedOptionsRequest(
IdleBehavior,
ShuffleScheduleItems,
RandomStartPoint,
FixedStartTimeBehavior);
FixedStartTimeBehavior,
Clear);
}
public record CreateChannelFromLineupItemRequest(
+22
View File
@@ -24544,9 +24544,31 @@
"$ref": "#/components/schemas/FixedStartTimeBehavior"
}
]
},
"clear": {
"type": [
"null",
"array"
],
"items": {
"$ref": "#/components/schemas/CreateChannelFromLineupClearField"
}
}
}
},
"CreateChannelFromLineupClearField": {
"enum": [
"Watermark",
"FallbackFiller",
"PreRollFiller",
"MidRollFiller",
"PostRollFiller",
"PreferredAudioLanguage",
"PreferredAudioTitle",
"PreferredSubtitleLanguage"
],
"type": "string"
},
"CreateChannelFromLineupItemRequest": {
"required": [
"mediaType",
+3
View File
@@ -92,4 +92,7 @@ bounds, what's mined per issue): `docs/handoffs/chicorytv-issue-queue.md` → "K
that file's standing kickoff for the two concurrent tracks (orientation ‖ selection). ersatztv#237
is a closed, archival historical tracker (superseded by `startup.parallel-orientation` in
`docs/decisions.md`) — not a live pointer.
- **`docs/tracker-retrofit-triage-237.md`** — audit trail for the #524 triage of ersatztv#237's 111
comments (method, per-comment classification, totals). Evidence for the
`docs.tracker-comment-retrofit` decision; read it only when triaging another over-cap tracker.
- **`docs/handoffs/rest-api.md`** — original handoff prompt for kicking off the REST API work (#2).
+9
View File
@@ -102,6 +102,15 @@ Exemplars:
(`GetAllHealthCheckResultsForApi(bool Refresh = false)`) which forces a fresh run past the service's TTL
result cache — the cached poll path is the default, the flag is the explicit opt-out (see `decisions.md`
2026-07-19, #431).
- **"Clear to none" vs "inherit" on a coalescing DTO: a typed `clear` enum list, not null.** When a
create/patch DTO resolves a field as `request.X ?? inherited.X` (e.g. `advanced.X ?? template.X`),
`null` already means *inherit*, so it cannot also mean *set to none*. Add an optional `clear` field
typed as a **list of a string enum** naming the fields to force to none — additive, so omitted =
inherit stays byte-stable for existing clients. Validate that a field is not both set and cleared
(reject as 422). **Define the enum in `ErsatzTV.Core`** (not the Application command) so
`Startup.UseStringEnumSchemas` renders it as a string enum in the spec — an Application-layer enum
shows as a bare `integer`. Exemplar: `CreateChannelFromLineupClearField` on
`POST /api/v1/channels/from-lineup` (`decisions.md` 2026-07-21, `api.from-lineup-clear-to-none`, #135).
- **Evolving a frozen DTO: deprecate-in-place, add the richer field, never remove.** `/api/v1` is
frozen-additive (#286), so when a response field's shape needs to grow, keep the old member
populated (mark it deprecated in an XML/`//` comment) and add the replacement alongside. Exemplar:
+24 -1
View File
@@ -7,7 +7,7 @@ Defined in `ErsatzTV.Core/Domain/Channel.cs`. Key fields:
- **Identity**: `Number` (e.g., "1", "2.1"), `Name`, `UniqueId` (GUID for M3U/XMLTV)
- **Encoding**: `FFmpegProfileId` — video/audio codec, bitrate, resolution, hardware acceleration
- **Streaming**: `StreamingMode` (TransportStream, HLS Direct, HLS Segmenter, TS Hybrid)
- **Behavior**: `PlayoutMode` (Continuous vs OnDemand), `IdleBehavior` (StopOnDisconnect vs KeepRunning)
- **Behavior**: `PlayoutMode` (Continuous vs OnDemand — OnDemand *is* the "resume where I left off" mode, see [On-demand resume](#on-demand-resume-freeze-the-clock-when-unwatched)), `IdleBehavior` (StopOnDisconnect vs KeepRunning)
- **Visual**: `WatermarkId`, `FallbackFillerId`, artwork (logos)
- **Mirroring**: `PlayoutSource` (Generated vs Mirror) — a mirror channel copies another with optional time offset
- **Display**: `Group`, `Categories`, `ShowInEpg`, `IsEnabled`
@@ -137,6 +137,29 @@ Channel
The scheduling engine (`ErsatzTV.Core/Scheduling/`) resolves schedule items into concrete `PlayoutItem` entries with precise start/finish times. Each `PlayoutItem` references a specific `MediaItem` and includes trim points (`InPoint`/`OutPoint`), filler classification, and per-item audio/subtitle overrides.
## On-demand resume (freeze the clock when unwatched)
A channel with `PlayoutMode = OnDemand` is the built-in "resume / bookmark" behavior (issue #68): its
playout clock advances only while someone is watching and freezes when nobody is, so on the next
tune-in it resumes where the last viewer stopped rather than jumping to a live wall-clock point.
- **Resume position**: `Playout.OnDemandCheckpoint` (a `DateTimeOffset?`) persists the viewer's spot.
`UpdateOnDemandCheckpointHandler` advances it — monotonically, minus one segmenter-timeout of
rewind-for-context — on each transcode iteration while watching.
- **Freeze/thaw**: on tune-in, `HlsSessionWorker.Run` sends `TimeShiftOnDemandPlayout`, and
`PlayoutTimeShifter.TimeShift` slides the **whole** materialized timeline (`PlayoutItem.Start/Finish`
**and** `GuideStart/GuideFinish`, plus history/anchors) forward by `now − checkpoint`, so the item the
viewer had reached is active at `now` again.
- **Guide stays in sync — the make-or-break requirement.** Guide and playback both read the same stored
`PlayoutItem.Start/Finish`, so freezing them together avoids the classic desync (guide ticking on
wall-clock while playback resumes from a saved spot). Because the XMLTV guide is served from a
**cached** fragment, the shift also enqueues `RefreshChannelData` for the channel — and for any
channels that mirror it — so every affected cache is rebuilt from the just-shifted rows; see
`decisions.md` → `scheduling.ondemand-guide-refresh-on-thaw` (#68).
- **Scope**: resume is **per-channel** (a single checkpoint on the playout), not per-viewer. Pair it with
a sequential playout (Sequential/Chronological order) for a "continue watching" channel of ordered
content.
## Watermarks
`ChannelWatermark` supports modes: Permanent, Intermittent, OpacityExpression. Image sources: custom
+42 -3
View File
@@ -143,6 +143,10 @@ stalling every PR run. `--cpu-shares` below the default 1024 is what makes a run
prod media host acceptable: under contention CI loses to the transcoders (ersatztv 1536 /
jellyfin), which are the reason those hosts exist.
That same "dispatched even when `if` skips it" behavior is why the git-only PR gates live in
their own **[PR gates workflow](#pr-gates-workflow)** (`pr-checks.yml`, `on: pull_request`)
rather than in `docker-build.yml` — see that section (ersatztv#535).
**`small` is git-only, and that is load-bearing (server-management#639).** Everything in
the lane is a checkout plus a `git diff`: `decisions-guard`, `ci-image-pin`,
`docs-reminder`. Nothing there runs a compiler or a `docker build`, which is why the lane
@@ -535,7 +539,7 @@ CI-validated, so the tree-match check correctly declines. So the skip is a genui
win (clean, up-to-date, un-rebased merges in quiet periods) — correct-but-conservative by
construction, not a general dedup. It never fires unsafely; when in doubt it runs the full matrix.
### `docs-reminder` job (non-blocking, PR-only)
### `docs-reminder` job (non-blocking, PR-only — in `pr-checks.yml`)
A lightweight nudge that enforces the CLAUDE.md "docs-update is part of done" rule for the
one case that's easy to forget and easy to detect: a PR that touches a SPA screen
@@ -548,7 +552,7 @@ thus no `actions/cache`), so it can't hit the cache-save hangs seen on the VM-12
(domain-model, spa-conventions) — those stay on the author. (The API contract is mechanized by the
blocking `api-docs` job, and `docs/decisions.md` by the blocking `decisions-guard` job below.)
### `decisions-guard` job (`decisions lifecycle`, blocking, PR-only)
### `decisions-guard` job (`decisions lifecycle`, blocking, PR-only — in `pr-checks.yml`)
Enforces decision-record lifecycle invariants (ersatztv#521, supersedes the ersatztv#303 H9
append-only mechanic): well-formed 5-field metadata, exactly one `active` record per `key`,
@@ -565,6 +569,40 @@ compiler/docker build), so it doesn't violate the "small is git-only" lane rule.
`docs-reminder`, otherwise a seconds-long `git diff` + parse with no dotnet/node setup
(`runs-on: small`).
## PR gates workflow
**File:** `.gitea/workflows/pr-checks.yml` — `on: pull_request` only.
The three git-only PR gates — `ci-image-pin`, `docs-reminder`, `decisions-guard` (described above)
— live here, **not** in `docker-build.yml`, and that separation is the fix for **ersatztv#535**.
**Why they are split out.** All three are pure `checkout + git diff` gates on the `small` lane
(no `container:`) and are PR-only (`if: github.event_name == 'pull_request'`). While they lived in
`docker-build.yml` — which also triggers on push to `main` and on `v*` tags — Gitea still
**dispatched them as runner tasks on every such push to evaluate the skip** (the `small`-lane
behavior documented above: *a job is dispatched even when its `if` skips it*). On the **v26.12.0**
release tag those dispatched skip-tasks wedged in act's setup phase and were killed by a runner
restart mid-setup, so they reported `failure` (no logs) and reddened the tag's overall commit
status even though the release built, scanned, and deployed fine. The two PR-only jobs on
`ubuntu-latest` (`api-docs`, `format`) carry the **identical** `if:` and skipped cleanly on the
same tag — the job logic was never the problem; the kill lands in the dispatch window, before any
step or `if:`-skip runs, so tweaking the `if:`/step logic could not fix it.
**Why a separate workflow fixes it.** Gitea evaluates a workflow's **trigger** before creating any
job, so a `pull_request`-only workflow produces **zero** jobs on a tag/main push: no dispatch, no
kill, no spurious red — for the whole class, permanently. The per-job `if:` guards are kept as
belt-and-suspenders (they also encode "these steps need a PR `base_ref`").
**What stays put and why.** These three carry **no** CI toolchain image pin, so `ci-image-pin`'s
grep of `docker-build.yml` still validates the five pin-bearing jobs
(`test`/`migrations`/`functional-e2e`/`api-docs`/`format`) that remain there. `api-docs` and
`format` stay in `docker-build.yml` because they carry the shared-image `container:` + pin and run
on the healthy `ubuntu-latest` lane (where they skipped correctly). None of the three moved jobs is
a **required** check — branch protection requires only `Build & test (.NET)` and `EF migration
integrity` — so relocating them (their status-context prefix changes from `Build ErsatzTV Image /
…` to `PR Gates / …`) does not affect merges. The file declares `defaults: run: shell: bash`
because `ci-image-pin` uses `mapfile`/`set -o pipefail`.
## CI toolchain image (`docker/ci/Dockerfile`, `.gitea/workflows/ci-image.yml`)
The jobs that need a toolchain — `test`, `migrations`, `functional-e2e`, `api-docs`, `format` —
@@ -617,7 +655,8 @@ registry. Renovate tracks the Dockerfile's image pins (`dockerfile` manager, see
(`entrypoint=["/bin/sleep" "10800"]`), so this is belt-and-braces for anyone running the image by
hand — unlike the two above, which are real.
**Bumping the pin is enforced, not remembered.** The `ci-image-pin` job (blocking, PR-only) fails if
**Bumping the pin is enforced, not remembered.** The `ci-image-pin` job (blocking, PR-only; defined
in `pr-checks.yml`, but it greps `docker-build.yml` where the pins live) fails if
`docker-build.yml`'s pin isn't the short sha of the last commit to touch `docker/ci/**` or
`ci-image.yml`, or if the five jobs ever pin different tags. This exists because **Renovate manages
`docker/ci/Dockerfile`'s base pins but cannot bump an opaque `:<sha>`** in `container.image` — so a
+163 -4
View File
@@ -16,7 +16,10 @@ A migrated record is an H2 whose first non-blank content line is a metadata bloc
(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`. `status` is
(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
@@ -35,9 +38,10 @@ in on both records. Never silently rewrite a record's rationale prose in place.
(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, 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
**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`.
@@ -62,6 +66,7 @@ in-file entries.
- [`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:**
@@ -131,6 +136,9 @@ in-file entries.
- [2026-07-21 — Decision records carry a lifecycle schema, validated by a script; append-only-by-diff is retired (#521)](#2026-07-21--decision-records-carry-a-lifecycle-schema-validated-by-a-script-append-only-by-diff-is-retired-521)
- [2026-07-21 — Parallel orientation + selection is the startup protocol; #237 retired (#520)](#2026-07-21--parallel-orientation--selection-is-the-startup-protocol-237-retired-520)
- [2026-07-21 — External channel-logo URLs are downloaded and cached at save time; the render path never fetches a logo (#525)](#2026-07-21--external-channel-logo-urls-are-downloaded-and-cached-at-save-time-the-render-path-never-fetches-a-logo-525)
- [2026-07-21 — Check the worked issue before the decision corpus; a closed tracker's comments need no retrofit (#524)](#2026-07-21--check-the-worked-issue-before-the-decision-corpus-a-closed-trackers-comments-need-no-retrofit-524)
- [2026-07-21 — Work-ahead slots are claimed atomically by the caller, released by the transcode it hands them to (#536)](#2026-07-21--work-ahead-slots-are-claimed-atomically-by-the-caller-released-by-the-transcode-it-hands-them-to-536)
- [2026-07-21 — Session end fast-forwards the shared checkout; a stale tree serves stale FILES (#541)](#2026-07-21--session-end-fast-forwards-the-shared-checkout-a-stale-tree-serves-stale-files-541)
---
@@ -2854,6 +2862,14 @@ again — the duplication is what let the defect exist in triplicate. Covered by
> made the failure near-deterministic. See `ffmpeg.qsv-extra-hw-frames-floor`; the decision recorded
> here still stands, with that floor in place.
> **Correction (2026-07-21, #536):** the bullet below stating that "every concurrent tune-in falls
> back to the throttled path" described the *intent*, not the behaviour. The slot check and its
> increment straddled an `await`, so N simultaneous tune-ins all read `0 < limit` and all started
> unthrottled; the "one winner, two throttled" observation held only because those tunes were
> effectively staggered. The measurement and the conclusions drawn from it stand — slot availability
> *was* the variable behind the bimodality — but the guarantee itself was not enforced until
> `ffmpeg.work-ahead-slot-atomic`.
- **`-readrate` throttles from the first read, so it sets a floor on time-to-first-segment.** The
realtime playback path pins input reading to 1.05× wall clock so a channel behaves like live TV.
Since `OutputFormatHls.SegmentSeconds` is 4 and the segmenter serves the playlist only once the
@@ -3282,3 +3298,146 @@ stored value moves too (harmless — only the QSV path ever reads it — but it
on a field the user didn't touch); and a machine client that `PUT`s `0` gets a `200` and then reads
back `64`, which is a silent transform of a submitted value that the OpenAPI description does not
advertise.
## 2026-07-21 — Check the worked issue before the decision corpus; a closed tracker's comments need no retrofit (#524)
`key: docs.tracker-comment-retrofit` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** When the knowledge exporter flags an over-cap tracker issue and excludes it from ingestion, triage its comments instead of assuming a retrofit is owed — and for each decision-shaped item check the **worked issue first**, because a tracker session comment is by construction a précis of the fuller closing record posted on the issue it narrates. Applied to #237 (111 comments) this yielded **zero** records, so server-management#642's "a fact found only in a #237 comment" retrieval row has no valid subject and its interim target (an already-migrated record) is permanent.
**Signals:** tracker retrofit, over-cap issue exclusion, worked-issue-first, tracker-is-not-a-knowledge-store, #237 comment history · paths: `docs/tracker-retrofit-triage-237.md` · issues: #524, #237, #520, #521, server-management#642
**Mechanics:** `docs/tracker-retrofit-triage-237.md` — method, per-comment classification table, totals, and the one candidate that was raised and disproved
**Why the worked-issue-first ordering is the load-bearing part.** The session protocol that produced
#237's log required each session to post its full closing record on the issue it actually worked, and
*then* summarize across issues on the tracker. The tracker entry is therefore the lossy copy. A
comment that looks like a unique source is nearly always a précis of a primary the exporter already
indexes — so the instinct "the tracker is excluded, therefore its knowledge is orphaned" inverts the
real dependency. Checking the corpus first and the worked issue second wastes the effort; the reverse
order settles most items in one lookup.
**Worked example.** Comment [106/111] recorded that #497's music-video metadata reconcile deliberately
excludes Guids (not eager-loaded by `GetOrAdd`, so reconciling would duplicate-insert every scan) and
Directors (not add-persisted for `MusicVideoMetadata`). Genuinely decision-shaped, and genuinely
absent from the decision corpus — it survives a corpus-only check and looks like a retrofit target.
Issue #497's own closing comment states both exclusions with fuller reasoning, and the exporter
ingests it as `497.md`. One worked-issue lookup disposes of it.
**Scope of the claim — deliberately narrow.** The coverage test was applied to **decision-shaped
items only**. Items classified as cross-cutting lore were classified but *not* coverage-checked, and
that bucket is **not** empty: two facts from comment [109/111] were found to have no home anywhere —
`scripts/e2e-local.sh`'s readiness probe hanging on a reused config dir, and the troubleshooting
playback API's inability to exercise channel branding. Both were swept into the handoff lore by this
PR. So the correct statement is "no *decision-shaped* orphans," not "no orphans"; a tracker triage
that skips the lore bucket will leave real knowledge on the floor.
**A zero result is a legitimate outcome**, not evidence the triage was done wrong. What would change
the answer is a tracker whose sessions did *not* also write per-issue closing records — one where the
tracker genuinely was the primary rather than the narration layer.
## 2026-07-21 — Work-ahead slots are claimed atomically by the caller, released by the transcode it hands them to (#536)
`key: ffmpeg.work-ahead-slot-atomic` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** `workAheadSegmenterLimit` is enforced by a single compare-exchange claim on a shared `WorkAheadSlots` pool taken by the *caller* of `Transcode`, which then passes ownership in and gets the release in `Transcode`'s `finally` — never a `Volatile.Read` compare in one place and an `Interlocked.Increment` in another.
**Signals:** work-ahead slot, workAheadSegmenterLimit, check-then-act across an await, unthrottled tune-in, TOCTOU · paths: `ErsatzTV.Application/Streaming/WorkAheadSlots.cs`, `HlsSessionWorker.Run`/`Transcode`, `ErsatzTV.Core.Tests/Streaming/WorkAheadSlotsTests.cs` · issues: #536, #350, #529, #231, #250
**Mechanics:** `WorkAheadSlots.TryAcquire(limit)` CAS loop; `Transcode(bool ownsWorkAheadSlot, …)`
**The defect was structural, not a missing `Interlocked`.** The write side already used
`Interlocked.Increment`, which is why the code read as thread-safe. The read side was a separate,
earlier `Volatile.Read(ref _workAheadCount) < await GetWorkAheadLimit(...)` in `Run`, and the
increment happened later inside `Transcode` — with at least one `await` (a DB-backed config read) in
between. `Interlocked` on one half of a check-then-act buys nothing. Three simultaneous tune-ins on
prod with `workAheadSegmenterLimit: 1` all observed `0 < 1` and all ran with no `-readrate`.
**Why the caller claims and the callee releases.** Moving the whole acquire/release pair inside
`Transcode` would be more symmetric, but `Run` needs the outcome *before* the call: it sets
`_state = SeekAndWorkAhead | SeekAndRealtime`, and `Transcode` reads that state on entry
(`wasSeekAndWorkAhead`) to decide whether the item starts at `DateTimeOffset.Now` or at
`_transcodedUntil`. Folding acquisition inward would have silently changed that branch. Instead the
parameter was inverted — `Transcode(bool ownsWorkAheadSlot, …)` with `realtime = !ownsWorkAheadSlot`
derived on the first line — so the contract is stated in the signature rather than implied by a
double negative, and there is exactly one release site guarded by the same flag.
**Compare-exchange rather than increment-then-back-off.** The issue proposed
`Interlocked.Increment` followed by a decrement when the post-increment value exceeds the limit.
That is correct on holder count, but the counter transiently overshoots, so a concurrent reader of
`Count` can observe a value above the limit. The CAS loop never publishes a state that violates the
invariant, which matters because the QSV hardware-frame pool sizing (`ffmpeg.qsv-extra-hw-frames-floor`)
is derived from that bound.
**Testing shape is inherited from #231/#250.** A single `Barrier(N)` + `Task.WhenAll` round does not
reliably collide on this hardware; the tests hammer 8 threads × 20 000 rounds with a per-round
barrier that validates the winner count and resets the pool. The negative control is documented in
the test file: reinstate the check-then-act body (**not** `if (true)`, which trips CS0219 under
warnings-as-errors and leaves `--no-build` running a stale, still-fixed dll). Verified: with the
pre-fix shape, 15 912 of 20 000 rounds over-claimed.
## 2026-07-21 — An on-demand time shift rebuilds the channel's cached XMLTV so the guide can't lag playback (#68)
`key: scheduling.ondemand-guide-refresh-on-thaw` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** When `PlayoutTimeShifter.TimeShift` slides an on-demand playout's materialized timeline forward on tune-in, it reports the channel numbers whose cached guide is now stale — the shifted channel **plus any channels that mirror it** — and `TimeShiftOnDemandPlayoutHandler` enqueues a `RefreshChannelData` for each, so every affected cached XMLTV fragment is regenerated from the just-shifted `PlayoutItem` rows. The guide and playback both read the same stored `PlayoutItem.Start/Finish`, but the guide is served from a **cached** projection — rewriting the rows without rebuilding the cache would leave the guide advertising a stale timeline.
**Signals:** on-demand resume, bookmark playout, freeze the clock when unwatched, guide/EPG desync, "guide showed S2E3 while tune-in played S1E1", XMLTV cache staleness, mirror channel guide · paths: `ErsatzTV.Infrastructure/Scheduling/PlayoutTimeShifter.cs`, `ErsatzTV.Application/Playouts/Commands/TimeShiftOnDemandPlayoutHandler.cs`, `ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs` · issues: #68
**Mechanics:** `IPlayoutTimeShifter.TimeShift` returns `List<string>` (shifted channel + its mirrors on a non-zero shift, empty otherwise); handler `foreach`-enqueues `RefreshChannelData` on `CancellationToken.None`
**This is what #68 ("resume/bookmark for sequential channels") actually needed.** The freeze-when-unwatched, resume-where-you-left-off behavior already existed as `ChannelPlayoutMode.OnDemand`: `Playout.OnDemandCheckpoint` persists the viewer's position, `UpdateOnDemandCheckpoint` advances it (monotonically, minus one segmenter-timeout of rewind-for-context) while watching, and `PlayoutTimeShifter` slides the whole schedule forward by `now − checkpoint` on the next tune-in so the item the viewer had reached is active again. Because the shift rewrites `GuideStart/GuideFinish` alongside `Start/Finish`, ErsatzTV **freezes the guide and playback together** — structurally avoiding the free-running-wall-clock desync the issue was filed about (a guide that keeps ticking while playback resumes from a saved spot).
**The one gap was cache freshness, not the timing model.** `RefreshChannelData` was enqueued by playout-build and channel/config edits, but **not** by the on-tune-in time shift, and `GetChannelGuideHandler` serves a cached `.xml` fragment (the live-recomputed SPA JSON guide already self-healed). So after a thaw an external EPG client (Jellyfin) could poll a guide reflecting the pre-shift timeline until the next incidental rebuild. `BuildPlayoutHandler` already refreshes the guide after it time-shifts — including a fan-out to every channel that mirrors the built channel, because `RefreshChannelDataHandler` refreshes only the one channel it is handed and does not cascade downstream. The tune-in path (`TimeShiftOnDemandPlayoutHandler`) was unpatched, so this decision replicates both halves: the source channel's guide **and** its mirrors' guides are rebuilt on thaw. A channel mirroring an on-demand source is an uncommon combination, but omitting it would leave the same desync one hop out.
**Why the return-value plumbing rather than enqueuing inside the shifter.** `PlayoutTimeShifter` lives in `ErsatzTV.Infrastructure`, which cannot reference the `RefreshChannelData` request type (an `ErsatzTV.Application` type), so the enqueue must happen in the Application-layer handler. `TimeShift` therefore returns `Option<string>` — `Some(channelNumber)` only when a non-zero offset was actually persisted, `None` on every early-out (wrong mode, active-and-unforced, empty playout) and on a zero-offset re-tune — so a guide rebuild fires exactly once per real thaw, never on a no-op. The zero-offset `None` gate is covered by a dedicated non-vacuous test.
**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.
## 2026-07-21 — Session end fast-forwards the shared checkout; a stale tree serves stale FILES (#541)
`key: session.shared-checkout-refresh` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** Session end runs `scripts/refresh-shared-checkout.sh`, which fast-forwards `/Users/timothy/ersatztv` to `origin/main` (and reinstalls `web/node_modules` when the lockfile moved), refusing to touch anything unless that tree is on a clean, non-ahead `main`.
**Signals:** shared checkout, stale kickoff paste, `/Users/timothy/ersatztv`, session-end protocol, H13 · paths: `scripts/refresh-shared-checkout.sh`, `docs/handoffs/chicorytv-issue-queue.md` · issues: #541, #520, #311, #312
**Mechanics:** `docs/handoffs/chicorytv-issue-queue.md` → session-end step 6 + the shared-tree lore bullet
**The existing rule had a hole, and it is a hole no rule can close.** The standing guidance — never
commit in the shared tree, never read its `HEAD`/`git log`/`git status` as truth about `main` — is
written entirely around a session **reading git state**. On 2026-07-21 the trap arrived as a **file**:
the kickoff prompt was pasted out of that tree while it was 81 commits behind, and the handoff doc it
carried still described the queue protocol #520 had retired the previous day (read tracker #237, which
`main` now says must *not* be read for queue state). No `git` command touched that tree all session, so
no discipline check could have fired. Selection happened to go through `scripts/select-queue.sh`, which
is why nothing broke — routing luck, not a control.
**So the fix removes the stale condition rather than adding a check**, which is what the shared-tree
lore bullet already prescribed for its first two failure modes: *"a check does not stay true."*
**The script is deliberately timid, because the tree is shared.** It refuses — loudly, exit 0,
changing nothing — when the tree is not on `main`, is dirty, has local commits, or is mid-rebase or
mid-merge. It never switches branches, never stashes, never discards. A refusal is a normal outcome,
not a failure, because the common reason for one is that another session is legitimately mid-flight.
**Two details that testing forced.** The first version used `npm install`, which **rewrote
`package-lock.json`** and left the shared tree dirty — the exact state the next run refuses on, so the
tool would have disabled itself after one use. It uses `npm ci`, which installs strictly from the
lockfile and never writes it. And it asserts the tree is clean at exit, reporting loudly if not:
leaving the shared tree dirty is the one outcome that would make this script a net negative.
## 2026-07-21 — `WorkAheadSlots.Release()` clamps before decrementing and reports unbalance in-band (#539)
`key: ffmpeg.work-ahead-slot-release-never-negative` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** `Release()` reads the count and compare-exchanges `current - 1` only when `current > 0`; a release against an empty pool records an unbalanced release and returns `false` **without ever writing a negative value**. It never decrements first and clamps afterward. The single caller (`HlsSessionWorker.Transcode`'s `finally`) logs a warning on the `false` return.
**Signals:** work-ahead slot release, unbalanced release, negative slot count, over-admit at limit 1, phantom work-ahead room, UnbalancedReleases counter · paths: `ErsatzTV.Application/Streaming/WorkAheadSlots.cs`, `HlsSessionWorker.Transcode`, `ErsatzTV.Core.Tests/Streaming/WorkAheadSlotsTests.cs` · issues: #539, #536, #529, #231, #250
**Mechanics:** `bool Release()` CAS loop guarded by `current <= 0`; call site `if (ownsWorkAheadSlot && !_workAheadSlots.Release()) _logger.LogWarning(...)`
**Why never-negative, given the release is already unreachable-when-unbalanced.** Today `HlsSessionWorker` has exactly one release site, guarded by `ownsWorkAheadSlot`, so an unbalanced release cannot happen — these were three **Low** findings from the #536 re-review, filed against the day someone adds a second release site. The pre-#539 shape decremented first (`0 → −1`) and clamped afterward, leaving a reachable interleaving where a concurrent `TryAcquire(limit)` reads the `−1`, sees phantom room, and admits a holder the budget doesn't have (a second acquirer then reads `0` and admits another) — two unthrottled transcodes at limit 1, re-opening the #529 QSV pool exhaustion. Because `TryAcquire` is the sole, CAS-guarded increment path, a count proven never-negative is exactly what forecloses that over-admit. Clamping *before* the decrement also records the breakage **synchronously on the offending thread**, instead of the decrement-first shape where the blame lands on a later, innocent release.
**`Release()` returns `bool` so the breach is visible in the logs, not only to tests.** #536's motivation was "a silently inflated budget with nothing in the logs to find it by," yet `UnbalancedReleases` was reachable only from a debugger or a test. `WorkAheadSlots` has no logger by design (it is a leaf primitive), so rather than plumb one in, `Release()` returns `false` on an unbalanced release and the caller — which already holds an `ILogger` — logs the warning. This is the one in-band signal that the ownership contract broke.
**`UnbalancedReleases` can under-count, and that is documented rather than fixed.** It only increments when a release finds the pool already empty. An over-release while the count is positive — e.g. one cancelling out a coexisting leak — decrements a real-looking slot and is never recorded, so the two bugs hide each other. There are no false positives (non-zero still means the contract broke), but zero does not prove correctness. Exact accounting would need per-owner tokens, which the #536 "ownership is a discipline, not a token" decision deliberately avoids; the docstring now states the limitation instead.
**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).
## 2026-07-21 — `from-lineup` advanced overrides express "clear to none" via a typed `clear` enum list (#135)
`key: api.from-lineup-clear-to-none` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** `POST /api/v1/channels/from-lineup` (and the Auto-Tune per-channel `advanced`, which reuses the same DTO) distinguishes *inherit* from *clear-to-none* with a typed `clear` enum list on `advanced`. A field left null/omitted still inherits the template value (unchanged for every existing client); naming a field in `clear` forces it to none on the new channel even when the template sets one. Sending both a set value and a clear for the same field is a validation error.
**Signals:** clear to none, inherit vs none, advanced override, watermark/filler clear, template-minus-one-setting · paths: `ErsatzTV.Core/Api/Channels/CreateChannelFromLineupClearField.cs`, `ErsatzTV.Application/Channels/Commands/CreateChannelFromLineupHandler.cs`, `ErsatzTV/Controllers/Api/Requests/CreateChannelFromLineupRequest.cs`, `web/src/builder/advancedOptions.tsx` · issues: #135, #89, #385, #386
**Mechanics:** `CreateChannelFromLineupHandler.ResolveClearable`/`ValidateClear`; SPA `applyOverridesToRequest`/`collectClears`/`CLEAR` sentinel; api-conventions.md §2, spa-conventions.md
**Why a `clear` list, not a sentinel or per-field flags.** The gap (found in #89 review) was that the handler resolved every advanced override with `advanced.X ?? template.X`, so a client sending `null` always *inherited*. That is correct for the common path but leaves "this channel should have NO watermark / pre-roll filler even though the template has one" inexpressible. The fix had to keep `omitted = inherit` byte-stable for existing clients (`/api/v1` is frozen-additive, #286), so it is a new optional field, not a reshaping of the existing ones. A `{set, value}` wrapper per field would have rewritten every field's wire type; a reserved `0` sentinel is magic and asymmetric between int ids and strings; parallel `clearX` bools add one field per clearable. A single **typed enum list** is additive, self-documenting, type-checked (an invalid value is a 400 at model binding), covers ids and strings with one mechanism, and extends by adding an enum member. The clearable set is the eight template-inheritable fields where "none" is meaningful: watermark, the four fillers, and the preferred audio/subtitle language + audio title.
**Set + clear of the same field is rejected, not silently resolved.** The SPA never produces that state (a select is inherit, a value, or None), so the check exists to keep hand-crafted / machine-client requests unambiguous rather than picking a winner. A null/empty set value alongside a clear is fine (redundant, not conflicting).
**The enum lives in `ErsatzTV.Core`, not the Application command, on purpose.** The OpenAPI string-enum pass (`Startup.UseStringEnumSchemas`) scans the Core assembly wholesale; an enum defined in `ErsatzTV.Application` renders as a bare `integer` in the spec while every sibling advanced-options enum (`PlaybackOrder`, `ChannelSubtitleMode`, …) is a string enum. Placing `CreateChannelFromLineupClearField` in `ErsatzTV.Core/Api/Channels/` makes the wire contract a string enum by construction, matching its siblings.
**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`).
+38
View File
@@ -12,6 +12,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `api.async-op-contract` | Queue-triggering `/api/*` endpoints normalize onto one contract — 202 Accepted (queued), 404 (missing entity), 409 (lock held), 422 (domain precondition) — with Trakt as the reference implementation; playout list/detail GETs also carry an `isLocked` observability flag as the HTTP-observable substitute for a live push channel. | 2026-07-11 | [link](../decisions.md#2026-07-11--async-op-api-contract-normalization--playout-build-observability--f9-scan-endpoints-235) |
| `api.channel-health-signal` | Channel health rides `ChannelResponseModel`/`ChannelListItem` DTOs as a raw `int PlayoutCount` fact (free — `GetAll` already `Include`s `Playouts`), not a new endpoint, not `/channels/state` (runtime-liveness cadence), and not a derived `ChannelHealth` enum (would freeze policy before the #383/#384 auto-tune status taxonomy lands). | 2026-07-17 | [link](../decisions.md#2026-07-17--channel-health-on-the-api--the-raw-playoutcount-fact-on-the-list-dto-not-a-derived-status-enum-72) |
| `api.decode-by-id` | Endpoints that decode/expand opaque stored state accept a database row id and resolve it server-side rather than round-tripping client-supplied serialized state. | 2026-07-07 | [link](../decisions.md#2026-07-07--decode-style-endpoints-take-a-row-id-and-look-up-server-side) |
| `api.from-lineup-clear-to-none` | `POST /api/v1/channels/from-lineup` (and the Auto-Tune per-channel `advanced`, which reuses the same DTO) distinguishes *inherit* from *clear-to-none* with a typed `clear` enum list on `advanced`. A field left null/omitted still inherits the template value (unchanged for every existing client); naming a field in `clear` forces it to none on the new channel even when the template sets one. Sending both a set value and a clear for the same field is a validation error. | 2026-07-21 | [link](../decisions.md#2026-07-21--from-lineup-advanced-overrides-express-clear-to-none-via-a-typed-clear-enum-list-135) |
| `api.healthcheck-remediation-dto` | Health-check remediation is server-declared `{Kind, Target}` metadata on an additive DTO field; the SPA renders/acts on it, it doesn't derive labels itself. | 2026-07-17 | [link](../decisions.md#2026-07-17--health-check-remediation-is-server-declared-kind-target-on-an-additive-dto-the-spa-acts-on-it-164) |
| `api.healthcheck-ttl-cache` | Health-check results are held in a 30s TTL cache inside `HealthCheckService`; a non-forced `GET /api/v1/health` returns the cached list, and `?refresh=true` (or a forced internal caller) bypasses it to run fresh. | 2026-07-19 | [link](../decisions.md#2026-07-19--health-check-results-are-ttl-cached-refreshtrue-forces-a-fresh-run-431) |
| `api.logs-sort-params` | `GET /api/logs` takes allow-listed `sortField` (`timestamp`\|`level`) and `sortDirection` (`asc`\|`desc`) query params, normalized (not rejected) on an unrecognized value. | 2026-07-11 | [link](../decisions.md#2026-07-11--logs-column-sorting-allow-listed-sortfieldsortdirection-on-get-apilogs) |
@@ -29,15 +30,25 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `api.versioning-v1` | The entire `/api` surface is versioned to `/api/v1` uniformly (no unversioned corner); legacy unversioned callers are rewritten in-pipeline (not redirected) with Deprecation/Link/Sunset headers, and post-freeze `/api/v1` is additive-only — a breaking change requires `/api/v2`. | 2026-07-13 | [link](../decisions.md#2026-07-13--api-versioning-the-whole-api-surface-is-mounted-at-apiv1-additive-only-after-freeze-286) |
| `blazor.rollback-tag` | The commit immediately preceding the Blazor-removal merge is tagged `blazor-final` (not a `v*` tag, so it doesn't trigger a prod release build) as the documented rollback/restore path. | 2026-07-11 | [link](../decisions.md#2026-07-11--pre-removal-blazor-rollback-tag-blazor-final-205) |
| `blazor.ui-removed` | The legacy Blazor Server UI (`Pages/`, `Shared/`, `ViewModels/`, `Validators/`, MudBlazor + 8 other packages, Blazor Startup wiring) is fully deleted now that the SPA has parity; the legacy `MapWhen` branch is kept only for controllers/docs/OpenAPI/`LegacyUiRedirects`, and the catch-all fallback 302s any unmatched non-api/artwork/docs/openapi path to `/app`. | 2026-07-11 | [link](../decisions.md#2026-07-11--blazor-server-ui-removed-91-phase-b) |
| `ci.batch-pushes-no-cancel-route` | Hold review fixes, doc corrections and format fixes locally and push **once** — a superseded run cannot be cancelled from the agent side and holds a runner slot until it finishes. | 2026-07-21 | [link](workflow-process.md#2026-07-21--batch-your-pushes-there-is-no-agent-side-cancel-route-on-gitea-1254-542) |
| `ci.build-once-rejected` | CI build-once (a shared compile artifact across jobs) was implemented, measured, and rejected for a 40-85% wall-clock regression; keep the #420 cross-run tree-identity skip instead. | 2026-07-18 | [link](../decisions.md#2026-07-18--ci-build-once-was-measured-and-rejected-keep-the-420-tree-skip) |
| `ci.cancelled-is-not-a-verdict` | Treat a `cancelled` conclusion as "no verdict" — never as pass or fail — and report FAILED and CANCELLED counts separately in any CI monitor. | 2026-07-21 | [link](workflow-process.md#2026-07-21--cancelled-is-not-failure-a-cancelled-run-is-no-verdict-542) |
| `ci.decisions-lifecycle-flake` | When `decisions lifecycle` is the **only** red job, do not investigate and do not create a new run to clear it — no rebase, no `--amend`, no no-op push; the operator reruns that single job from the Gitea UI. | 2026-07-21 | [link](workflow-process.md#2026-07-21--a-lone-decisions-lifecycle-red-is-a-known-infra-flake-do-nothing-542) |
| `ci.docs-only-detect-shallow-safe` | The docs-only detect script must diff against `FETCH_HEAD` (always resolves after `git fetch`, even shallow) using a two-dot tree diff — not `origin/<base>` with three-dot — because a `fetch-depth: 1` shallow clone has no remote-tracking ref and no merge-base, which silently fails the original detect into `docs_only=false` (full matrix, no functional error). A CI-behavior change must be verified by measuring the effect (job durations), not just a green check. | 2026-07-17 | [link](../decisions.md#2026-07-17--docs-only-detect-must-be-shallow-checkout-safe-fetch_head--two-dot-not-originmain--three-dot-416-follow-up) |
| `ci.docs-only-skip-steps` | A docs-only change must still run every required job (`test`, `migrations`) so their commit-status contexts always report; each heavy job runs `scripts/ci-detect-docs-only.sh` first and gates its real STEPS on `if: steps.detect.outputs.docs_only != 'true'`, never `if:`-skips the whole job (an `if:`-skipped job reports `skipped`, not `success`, which branch protection may never unblock on). Detection biases toward running more on any doubt. | 2026-07-17 | [link](../decisions.md#2026-07-17--docs-only-ci-skip-gates-steps-in-always-running-required-jobs-never-if-skips-them-416) |
| `ci.format-gate-folder-mode` | The blocking `format` CI job (and matching pre-commit hook) runs `dotnet format whitespace . --folder --include <files>` instead of loading the full MSBuild/Roslyn solution, cutting the gate from ~480s to ~0.5s with unchanged whitespace/charset coverage. | 2026-07-19 | [link](../decisions.md#2026-07-19--the-format-gate-runs-dotnet-format-whitespace----folder-not-the-full-solution-format-469) |
| `ci.functional-e2e-harness` | The `functional-e2e` CI job boots the PR's own code from source via `dotnet run` (`scripts/e2e-local.sh`) and runs curl-only, deterministic assertions (`scripts/e2e-functional.sh`) as an advisory (non-blocking) job, not a `build` dependency or required check. | 2026-07-16 | [link](../decisions.md#2026-07-16--functional-e2e-ci-harness-advisory-curl-contract-job-over-an-app-booted-from-source-299) |
| `ci.gitea-milestone-filter-noop` | Never filter issues with the server-side `?milestones=<name>` parameter — fetch all open issues once and filter LOCALLY on each issue's `.milestone.title`. | 2026-07-21 | [link](workflow-process.md#2026-07-21--giteas-milestones-issue-filter-silently-no-ops-on-names-containing--or--542) |
| `ci.infra-shaped-red-under-load` | When a job dies inside a setup/cache step before your code compiles, check the runner host's load before diagnosing the diff, and never file a CI bug off one sample under pressure. | 2026-07-21 | [link](workflow-process.md#2026-07-21--an-infra-shaped-red-under-host-load-is-not-a-code-failure-542) |
| `ci.killed-job-triage` | Never trust a job's `conclusion` field alone — read the log tail and require an `❌ Failure - Main …` marker before treating a red as a real failure. | 2026-07-21 | [link](workflow-process.md#2026-07-21--a-killed-ci-job-reports-conclusion-failure-read-the-log-tail-before-diagnosing-the-diff-542) |
| `ci.monitor-armed-at-pr-open` | Arm a CI monitor on the PR head sha the moment the PR opens, polling the commit-status endpoint — not at the end of the work. | 2026-07-21 | [link](workflow-process.md#2026-07-21--arm-the-ci-monitor-at-pr-open-via-the-commit-status-endpoint-542) |
| `ci.no-host-health-gating` | Push when your work is validated — never SSH to bumblebee to sample load/RAM first, and never hand-schedule around other sessions' runs. | 2026-07-21 | [link](workflow-process.md#2026-07-21--do-not-gate-or-throttle-pushes-on-host-health-542) |
| `ci.peak-anon-measurement` | The `test` job's headline memory figure is a sampled high-water mark of cgroup `anon`, produced by `scripts/ci-peak-anon.sh`; `memory.peak` and the end-of-job `anon`/`file` split are kept only as a cache-inflated reference. | 2026-07-19 | [link](../decisions.md#2026-07-19--ci-test-job-reports-a-sampled-true-peak-anon-not-cache-inflated-memorypeak-412) |
| `ci.root-screenshot-guard` | The Husky `pre-commit` hook refuses a staged root-level `*.png` (belt-and-suspenders with the `.gitignore` rule); nested `*.png` real assets are unaffected. | 2026-07-12 | [link](release-ci-governance.md#2026-07-12--root-screenshot-guard-pre-commit-refuses-root-level-png-303-h3) |
| `ci.runner-placement` | No persistent Roslyn compiler server survives a CI build (`UseSharedCompilation=false` etc., runner env + Dockerfile `ENV`); every `services:` container gets its own explicit `--memory`/`--memory-swap`/`--cpus` cap (it does not inherit the job container's). | 2026-07-17 | [link](../decisions.md#2026-07-17--no-persistent-compiler-servers-in-ci-every-services-container-gets-an-explicit-cap-390s-small-lane-move-reversed-406) |
| `ci.small-lane-git-only` | `runs-on: small` is defined by what a job does (git-only), not its usual runtime; the two `docker build` jobs (docker-build.yml, ci-image.yml) move to `ubuntu-latest` because their worst-case memory, not median runtime, was pinning the small lane's per-slot cap. | 2026-07-20 | [link](../decisions.md#2026-07-20--runs-on-small-means-git-only-the-two-docker-build-jobs-move-to-ubuntu-latest-server-management639) |
| `ci.verify-locally-ci-confirms` | Treat the local build/verify/review pass as the decision point and CI as confirmation — don't idle waiting on a run you have no reason to doubt. | 2026-07-21 | [link](workflow-process.md#2026-07-21--build-and-verify-locally-then-trust-it-ci-confirms-542) |
| `ci.web-test-per-test-timeouts` | Give heavy-render web tests an explicit per-test vitest timeout (e.g. 15s); never raise the global default to fix one slow test. | 2026-07-21 | [link](workflow-process.md#2026-07-21--heavy-render-web-tests-need-explicit-per-test-vitest-timeouts-on-the-ci-vm-542) |
| `concurrency.diff-scalar-fanout` | The frozen Block optimistic-concurrency recipe (api-conventions §7a) fans out to Collection/Playout×2/MultiCollection/RerunCollection, keeping a guard-returned `PreconditionFailedError` out of any handler's generic `catch(Exception)`→422 mapping, and preserving each aggregate's existing `SaveChangesAsync() > 0` gate semantics under the new unconditional `Version++`. | 2026-07-11 | [link](optimistic-concurrency.md#2026-07-11--253-pr3-diff--scalar-concurrency-fan-out-collection--playout2--multicollection--reruncollection) |
| `concurrency.etag-rotation-completion` | Every handler that mutates a versioned root's editor-visible config state must bump `Version` (rotating the ETag) with no per-aggregate carve-outs, short-circuiting on a genuine no-op before the bump so idempotent re-submits don't fire spurious rebuild fan-out; `SaveChangesForcingVersion` rebases the retry (stored + pending delta), never adopts the stored token verbatim. | 2026-07-12 | [link](optimistic-concurrency.md#2026-07-12--cross-editor-etag-rotation-completed-for-collectionplayout-config-siblings-269) |
| `concurrency.force-write-non-ifmatch` | Any handler that leaves a versioned root `Modified` or `Deleted` but takes no `If-Match` (deletes, item add/remove bumpers, scalar-config writers) must save through `ConcurrencyExtensions.SaveChangesForcingVersion` — force-write past a concurrent `Version` bump rather than throw an unhandled `DbUpdateConcurrencyException` (500). | 2026-07-12 | [link](optimistic-concurrency.md#2026-07-12-269--non-if-match-root-writers-force-write-past-a-concurrent-version-bump) |
@@ -47,11 +58,14 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `concurrency.schedule-item-child-identity` | `PUT /api/schedules/{id}/items` reconciles by an optional round-tripped child `Id` (null/absent/0 ⇒ new item), never by array position, so fill-group/shuffle state follows the logical item across reorders; an unknown or duplicate id is rejected 422 (checked after the §7a `CheckVersion`, so 412 precedes 422). | 2026-07-11 | [link](optimistic-concurrency.md#2026-07-11--stable-child-identity-for-schedule-item-replace-259-split-from-252253) |
| `docs.convention-docs-session-start` | Docs-first, not source-first: conventions (api-conventions, spa-conventions, e2e-local, blazor-route-parity, domain-model, decisions, README) are read from docs, not reverse-engineered from code, via `docs/README.md`'s task-signal map — only the sections it points to for the task at hand, not the whole set. Each doc is updated in the same PR that changes what it documents, replacing deferred/follow-up doc updates. | 2026-07-07 | [link](../decisions.md#2026-07-07--convention-docs-read-at-session-start-updated-in-pr) |
| `docs.decision-lifecycle` | every decision `##` record (active or archived) carries a 5-field metadata block (`key`, `status`, `since`, `supersedes`, `superseded-by`) checked by `scripts/decisions_validate.py`; a record is never deleted or line-edited to reverse a call — it is moved to `docs/decisions/archive/` with `status: superseded`/`retired` and a reciprocal `superseded-by`/`supersedes` key pair to its replacement. | 2026-07-21 | [link](../decisions.md#2026-07-21--decision-records-carry-a-lifecycle-schema-validated-by-a-script-append-only-by-diff-is-retired-521) |
| `docs.tracker-comment-retrofit` | When the knowledge exporter flags an over-cap tracker issue and excludes it from ingestion, triage its comments instead of assuming a retrofit is owed — and for each decision-shaped item check the **worked issue first**, because a tracker session comment is by construction a précis of the fuller closing record posted on the issue it narrates. Applied to #237 (111 comments) this yielded **zero** records, so server-management#642's "a fact found only in a #237 comment" retrieval row has no valid subject and its interim target (an already-migrated record) is permanent. | 2026-07-21 | [link](../decisions.md#2026-07-21--check-the-worked-issue-before-the-decision-corpus-a-closed-trackers-comments-need-no-retrofit-524) |
| `ffmpeg.external-logo-graphics-engine` | External-URL channel logos pass through to the graphics engine like any other watermark source; `WatermarkSelector` must never gate them on `File.Exists` (always false for a URL) and never route them through the ffmpeg-native overlay shortcut. | 2026-07-20 | [link](../decisions.md#2026-07-20--external-url-channel-logos-pass-through-to-the-graphics-engine-never-fileexists-gated-never-ffmpeg-native-502) |
| `ffmpeg.hls-cold-start-burst` | HLS cold-start latency is fixed with a bounded `-readrate_initial_burst` (gated on FFmpeg ≥6.1 capability detection), not by raising `work_ahead_limit`, which would remove the concurrency guarantee it exists for. | 2026-07-20 | [link](../decisions.md#2026-07-20--hls-cold-start-is-fixed-with--readrate_initial_burst-not-by-raising-the-work-ahead-limit-350) |
| `ffmpeg.qsv-decode-encode-split` | QSV decode is decoupled from QSV encode via a single `FFmpegProfile.QsvPreferNativeDecoder` bool (default ON, Linux-only), so a QSV encode profile can decode with the more tolerant native VA-API decoder instead of the QSV decoder, mirroring Jellyfin's hybrid decode/encode toggle instead of a general decode-family enum. | 2026-07-20 | [link](../decisions.md#2026-07-20-498--qsv-decode-is-split-from-qsv-encode-via-a-single-qsvprefernativedecoder-bool) |
| `ffmpeg.qsv-extra-hw-frames-floor` | a QSV upload never emits `extra_hw_frames` below `FFmpegState.MinimumQsvExtraHardwareFrames` (64); a stored `0` or negative value is treated as "no pool configured" rather than honored literally, because with no headroom any unthrottled read exhausts the pool and the transcode writes nothing at all. | 2026-07-21 | [link](../decisions.md#2026-07-21--qsv-hardware-frame-headroom-is-a-floor-not-an-operator-preference-529) |
| `ffmpeg.remote-image-fetcher-bounded` | remote graphics-engine images are fetched through `IRemoteImageFetcher` with a pooled `HttpClientFactory` client, a body-covering deadline, a wire-transfer size cap, and a decoder-enforced `DecoderOptions.MaxFrames` bound re-verified post-decode — never cached, re-fetched per element init. | 2026-07-20 | [link](../decisions.md#2026-07-20--remote-graphics-engine-images-are-fetched-through-a-bounded-pooled-iremoteimagefetcher-re-fetched-per-element-init-not-cached-511) |
| `ffmpeg.work-ahead-slot-atomic` | `workAheadSegmenterLimit` is enforced by a single compare-exchange claim on a shared `WorkAheadSlots` pool taken by the *caller* of `Transcode`, which then passes ownership in and gets the release in `Transcode`'s `finally` — never a `Volatile.Read` compare in one place and an `Interlocked.Increment` in another. | 2026-07-21 | [link](../decisions.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-release-never-negative` | `Release()` reads the count and compare-exchanges `current - 1` only when `current > 0`; a release against an empty pool records an unbalanced release and returns `false` **without ever writing a negative value**. It never decrements first and clamps afterward. The single caller (`HlsSessionWorker.Transcode`'s `finally`) logs a warning on the `false` return. | 2026-07-21 | [link](../decisions.md#2026-07-21--workaheadslotsrelease-clamps-before-decrementing-and-reports-unbalance-in-band-539) |
| `graphics.channel-logo-caching` | An external `http(s)` channel-logo URL is fetched, decode-budget-validated, and stored in the image cache under a content-hash name at SAVE time — becoming byte-identical to an uploaded logo — so the render path never fetches a logo over HTTP; a bad URL fails the save with a 422 (BaseError → ValidationProblemDetails). | 2026-07-21 | [link](../decisions.md#2026-07-21--external-channel-logo-urls-are-downloaded-and-cached-at-save-time-the-render-path-never-fetches-a-logo-525) |
| `iptv.base-url` | An optional advertised base URL (`iptv.base_url`) is resolved centrally via a pure Core helper (`AdvertisedBaseUrl`) inside the two IPTV generation handlers (M3U + XMLTV); unset/malformed values fall back byte-identical to the request-derived host, and it's a new `iptv` settings group distinct from `ETV_BASE_URL` and out of scope for HDHomeRun. | 2026-07-16 | [link](../decisions.md#2026-07-16--optional-advertised-iptv-base-url-iptvbase_url-resolved-centrally-in-the-two-generators-340) |
| `iptv.logo-drives-bug-preset` | One uploaded channel logo drives both the listing logo and the on-screen bug via a shared, seeded `ChannelLogo`-sourced watermark preset (`Channel Bug`), not new per-channel schema. | 2026-07-20 | [link](../decisions.md#2026-07-20--one-logo-drives-the-bug-via-a-shared-channellogo-preset-not-new-schema-67) |
@@ -61,6 +75,24 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `media.remote-stream-probe` | `ValidatePlayoutItemPath` probes the Plex/Jellyfin/Emby remote-stream URL via `IRemoteStreamProber` before returning it; only a redirected 404 fails closed (`PlayoutItemNotAvailableFromMediaServer`), everything else fails open, and there is no toggle. | 2026-07-19 | [link](../decisions.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-externaljson` | External-JSON playout channels' `StreamRemotely` now probes the remote-stream URL through the same `IRemoteStreamProber` seam as the generated-playout path, closing the #473 scope gap for a channel kind with no DB `PlayoutItem` rows. | 2026-07-20 | [link](../decisions.md#2026-07-20--external-json-playout-channels-now-probe-the-remote-stream-url-too-closing-the-473-scope-gap-480) |
| `media.source-mgmt-write-api` | Media-source management (local/Plex/Jellyfin/Emby) is a REST write API + SPA under `/app/libraries/*`, wrapping existing MediatR commands 1:1 with no new commands or DB migration; connection GETs never leak a stored `apiKey`, and each PUT-replace family's identity contract is documented per-family (not assumed uniform). | 2026-07-11 | [link](../decisions.md#2026-07-11--media-source-management-rest-write-api--spa-202) |
| `process.bom-format-detection-recipe` | Before any push touching `.cs`, detect BOMs with the `xxd` byte check and verify the format gate with `dotnet format --include` run under `bash -c`, never bare zsh. | 2026-07-21 | [link](workflow-process.md#2026-07-21--bomformat-pre-push-detection-use-the-xxd-loop-and-run-dotnet-format---include-under-bash--c-542) |
| `process.branch-off-feature-branch` | To fix work on an unmerged feature branch, branch off that branch and land by fast-forward push — and after creating a worktree, drive the first Edit/Read from ITS absolute paths and `git status` it before building. | 2026-07-21 | [link](workflow-process.md#2026-07-21--fix-onto-an-unmerged-feature-branch-by-branching-off-it-and-ff-pushing-542) |
| `process.build-concurrency-limits` | Run at most 3–4 concurrent dotnet/npm builds on this Mac, gate launches on FREE RAM rather than CPU load, and never set `ETV_UPDATE_GOLDENS` / `ETV_UPDATE_PLAYOUT_GOLDENS`. | 2026-07-21 | [link](workflow-process.md#2026-07-21--bound-parallel-builds-by-free-ram-never-regenerate-goldens-542) |
| `process.codex-cheap-worker-launch` | For bounded tool-bearing selector/recon work, launch a Codex worker with `codex exec -m gpt-5.4-mini -c model_reasoning_effort=low -s read-only`; `spawn_agent` buys parallelism but no cost savings. | 2026-07-21 | [link](workflow-process.md#2026-07-21--launch-codex-cheap-workers-via-codex-exec-not-spawn_agent-542) |
| `process.consistency-fix-new-code-scrutiny` | Review a "make X consistent with Y" change as new code, not as a mechanical copy — and for any timer or effect involved, ask explicitly "when does this fire?", including on mount. | 2026-07-21 | [link](workflow-process.md#2026-07-21--make-x-consistent-with-y-review-findings-are-new-code-and-get-new-code-scrutiny-542) |
| `process.enumerate-workaround-behaviors-before-deleting` | When an issue says "delete X", enumerate every behavior X provided before removing it — a workaround often serves a second purpose that outlives the first. | 2026-07-21 | [link](workflow-process.md#2026-07-21--before-deleting-a-workaround-enumerate-every-behavior-it-provided-542) |
| `process.foreign-worktree-plumbing-merge` | Never commit or merge inside a worktree another session created; land the merge with git plumbing against the branch ref instead. | 2026-07-21 | [link](workflow-process.md#2026-07-21--never-commit-or-merge-inside-a-worktree-you-did-not-create-542) |
| `process.harden-with-runtime-posture-not-clamp` | When a security fix constrains a capability the roadmap will later want, make the safe state the DEFAULT OF A SWITCH rather than a wall — and read the feature's own issue for its end-state first. | 2026-07-21 | [link](workflow-process.md#2026-07-21--harden-a-soon-to-grow-feature-with-a-runtime-posture-not-a-hardcoded-clamp-542) |
| `process.independent-review-rubric` | Run an independent review pass — preferably a different model family, otherwise a cold-context review-only agent — on any diff touching locks/concurrency, auth/security, API write-path handlers, or DB migrations, or larger than ~150 changed C# lines; skip only for a pure-SPA/docs leaf with no server-state effect, and state the skip and its reason in the PR or close comment. | 2026-07-21 | [link](workflow-process.md#2026-07-21--independent-cross-model-review-is-mandatory-on-risky-diffs-a-skip-is-a-stated-auditable-exemption-542) |
| `process.issue-qualification-audit` | Run `scripts/issue-qualification-audit.sh` at session end and label everything it flags, including issues you filed that session. | 2026-07-21 | [link](workflow-process.md#2026-07-21--every-open-issue-carries-a-priority-label--run-the-h12-audit-at-session-end-542) |
| `process.local-gate-before-push` | Run the local build/test gate and a cold-context, scoped "review only" adversarial review over the diff, fold the fixes, and only then push or open the PR. | 2026-07-21 | [link](workflow-process.md#2026-07-21--local-buildtest-gate--cold-context-review-run-before-the-push-not-after-542) |
| `process.lock-ownership-enumerate-producers` | Before trusting any "single owner / no double release / no cross-release" claim, grep the whole host project for every writer of that channel message (or acquirer of that lock) — the background scheduler/worker is the usual missing producer. | 2026-07-21 | [link](workflow-process.md#2026-07-21--a-lockchannel-no-cross-release-verdict-must-enumerate-every-producer-via-grep-542) |
| `process.one-worktree-one-committing-agent` | Never run two committing agents concurrently on one worktree — give each parallel slice its own worktree branched off the feature branch and merge back. | 2026-07-21 | [link](workflow-process.md#2026-07-21--one-worktree-one-committing-agent-542) |
| `process.parallel-session-claim` | Apply the `in-progress` label before starting an issue, and still read its dependency notes before touching shared surfaces — a claim prevents duplicate pickup, not overlapping code changes. | 2026-07-21 | [link](workflow-process.md#2026-07-21--claim-with-in-progress-before-working-claiming-is-not-collision-safety-542) |
| `process.pr-routine-sequence` | Worktree off origin/main → implement → regenerate API artifacts → full local tests + cold review + live-E2E ALL before the push → push, open PR, arm the CI monitor at open → fixes after the push are follow-up commits, never amend/force-push. | 2026-07-21 | [link](workflow-process.md#2026-07-21--the-pr-routine-is-a-fixed-sequence-validate-locally-then-push-then-only-follow-up-commits-542) |
| `process.review-disagreement-frontier-judge` | When independent reviews disagree on a gate PR, escalate to the frontier judge, and put the proposed fix approach in front of it — not just the disputed finding. | 2026-07-21 | [link](workflow-process.md#2026-07-21--review-disagreement-on-a-gate-pr-escalates-to-the-frontier-judge--and-the-proposed-fix-escalates-with-it-542) |
| `process.shared-tree-readonly` | Never commit in `/Users/timothy/ersatztv` and never read its `git log`/`git status`/HEAD to infer anything about `main` — work in a worktree off `origin/main`, which is the only source of truth. | 2026-07-21 | [link](workflow-process.md#2026-07-21--the-shared-tree-at-userstimothyersatztv-is-read-only-and-tells-you-nothing-about-main-542) |
| `process.subagent-drop-resume` | Treat a subagent connection drop as laptop sleep or transient network and re-resume via SendMessage — the work survives. | 2026-07-21 | [link](workflow-process.md#2026-07-21--a-dropped-subagent-connection-is-transient-resume-dont-restart-542) |
| `release.api-contract-ci-gate` | A PR touching `ErsatzTV/Controllers/Api/**` or `ErsatzTV.Core/Api/**` must ship regenerated OpenAPI artifacts (`v1.json`, `v1.d.ts`, `endpoint-index.md`) in the same diff, enforced by a blocking `api-docs` CI job that regenerates-and-diffs against a fresh build. | 2026-07-12 | [link](release-ci-governance.md#2026-07-12--blocking-ci-gate-for-api-contract-artifacts-303-h4h5) |
| `release.done-when-merge-consent` | A PR may merge only when its linked issue's `## Done-when` checklist is fully ticked and the PR's CI is green, enforced by a PreToolUse hook on the Gitea merge tool (deny/allow/ask) plus a pre-push backstop for direct pushes to main. | 2026-07-12 | [link](release-ci-governance.md#2026-07-12--merge-consent-derived-from-state-via-a--done-when-issue-checklist-303-h6) |
| `release.format-as-you-touch-rebase` | A blocking `format` CI job runs `dotnet format --verify-no-changes` scoped only to the PR's changed `.cs` files (never the legacy BOM backlog), and a PR branch must be kept current by rebasing on `origin/main` (never merging main in), enforced by `.husky/pre-push` → `prepush-rebase-check.sh`. | 2026-07-12 | [link](release-ci-governance.md#2026-07-12--formatting-as-you-touch-enforced-rebase-not-merge-for-pr-branches-311-h11--format-ci) |
@@ -86,6 +118,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `sched.shuffle-source-builder` | Shuffle-source construction moves to a static, DI-free `ShuffleSourceBuilder` (a shared seam, not a service) so Classic and Playlist stop cross-engine reaching into `PlayoutBuilder` statics; a unified Classic+Playlist enumerator factory is explicitly rejected as a god-factory. Block/Scripted/YAML duplication is left alone, deferred to a follow-up gated on #381. | 2026-07-17 | [link](../decisions.md#2026-07-17--shuffle-source-construction-extracted-to-shufflesourcebuilder-per-family-seam-not-a-god-factory-380) |
| `sched.weighted-shuffle` | Fair-share/weighted airtime distribution ships as one new `PlaybackOrder.WeightedShuffle = 9` order (equal weights = fair-share), not a retrofit of `ShuffleInOrder` (which only anti-clumps, since its padding spacers emit nothing) and not a separate orthogonal "distribution" setting; weights live on `MultiCollectionItem`/`MultiCollectionSmartItem` (DB default 1, dual-provider migration), bounded at write (1..1000) and clamped again in the enumerator, and the write path rejects `WeightedShuffle` at every dispatch site that doesn't handle it rather than let it silently degrade to unweighted random. | 2026-07-17 | [link](../decisions.md#2026-07-17--weighted--fair-share-distribution-is-a-new-weightedshuffle-order-shuffleinorder-is-anti-clumping-not-fair-share-70) |
| `sched.weightedshuffle-editor` | WeightedShuffle per-source weights are edited on the multi-collection editor (property of the MultiCollection), while the WeightedShuffle order itself is offered only on classic MultiCollection schedule items; fair-share is a "reset weights to 1" action, not a stored mode. | 2026-07-19 | [link](../decisions.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) |
| `scheduling.ondemand-guide-refresh-on-thaw` | When `PlayoutTimeShifter.TimeShift` slides an on-demand playout's materialized timeline forward on tune-in, it reports the channel numbers whose cached guide is now stale — the shifted channel **plus any channels that mirror it** — and `TimeShiftOnDemandPlayoutHandler` enqueues a `RefreshChannelData` for each, so every affected cached XMLTV fragment is regenerated from the just-shifted `PlayoutItem` rows. The guide and playback both read the same stored `PlayoutItem.Start/Finish`, but the guide is served from a **cached** projection — rewriting the rows without rebuilding the cache would leave the guide advertising a stale timeline. | 2026-07-21 | [link](../decisions.md#2026-07-21--an-on-demand-time-shift-rebuilds-the-channels-cached-xmltv-so-the-guide-cant-lag-playback-68) |
| `security.artwork-content-type-sniff` | Artwork content type is always derived from the stored bytes (never the client-declared value or a `?contentType=` query param) at both upload and serve, clamped to an image allow-list, closing the unauthenticated stored-XSS chain; Kestrel `MaxRequestBodySize` bounds upload DoS. | 2026-07-12 | [link](api-auth-security.md#2026-07-12--artwork-content-type-is-sniffed-never-reflected-283-s4s9-stored-xss) |
| `security.baseline-response-headers` | `SecurityHeadersMiddleware`, registered first in the pipeline, sets `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, and `Referrer-Policy: strict-origin-when-cross-origin` on every response (CSP/HSTS deliberately deferred); API-key comparison is constant-time and playout pagination is clamped. | 2026-07-11 | [link](api-auth-security.md#2026-07-11--baseline-security-response-headers--phase-0-api-hardening-197-pr-279) |
| `security.blazor-removal-auth-posture` | Removing the Blazor UI's OIDC-challenged surface exposes nothing a user couldn't already reach via the already-open `/app` SPA (open since phase (a)); real SPA/API authentication is deliberately deferred to #197, and the removal PR must preserve `ConditionalIptvAuthorizeFilter`, `ApiKeyAuthorizationFilter`, and `JwtHelper` access_token support. | 2026-07-11 | [link](api-auth-security.md#2026-07-11--blazor-removal-auth-posture-no-new-exposure-beyond-phase-a-real-auth-deferred-to-197-206) |
@@ -95,6 +128,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `security.fail-closed-api-auth` | Every mutating `/api` request requires `X-Api-Key` (no open mode); reads are gated by `Api:RequireKeyForReads` (default true) OR `[RequiresApiKey]` on sensitive controllers; CORS is an exact-origin allowlist (`ApiCors`); `ForwardedHeaders` trust stays configurable but defaults to trust-all-with-warning. | 2026-07-12 | [link](api-auth-security.md#2026-07-12--fail-closed-api-auth--sensitive-read-tier--corsforwardedheaders-lockdown-197-bundle-a-pr-292) |
| `security.session-auth-dual-credential` | `ApiAuthorizationFilter` accepts a request when a valid `X-Api-Key` matches OR the principal is an authenticated session (cookie `ctv-session`, `HttpOnly`/`SameSite=Lax`); session-authenticated mutations require the presence-only `X-CSRF` header or are rejected 403. This narrows the OIDC-inert sub-claim of `security.blazor-removal-auth-posture` (#206) — the rest of that record's auth-surface enumeration still holds. | 2026-07-12 | [link](api-auth-security.md#2026-07-12--browser-spa-session-auth-api-accepts-session-or-machine-key-295-pr1-server-only) |
| `security.session-cutover-postify` | The browser SPA authenticates cookie-only (no more `X-Api-Key` from `web/`); the machine key is repurposed to external/MCP-only via `GET /api/auth/machine-key`; every side-effecting GET/HEAD under `/api` is converted to POST so the existing CSRF gate covers it (standing rule: never add a side-effecting GET/HEAD under `/api`). | 2026-07-12 | [link](api-auth-security.md#2026-07-12--295-pr2-spa-session-cutover--301-side-effecting-get-post-ification) |
| `session.shared-checkout-refresh` | Session end runs `scripts/refresh-shared-checkout.sh`, which fast-forwards `/Users/timothy/ersatztv` to `origin/main` (and reinstalls `web/node_modules` when the lockfile moved), refusing to touch anything unless that tree is on a clean, non-ahead `main`. | 2026-07-21 | [link](../decisions.md#2026-07-21--session-end-fast-forwards-the-shared-checkout-a-stale-tree-serves-stale-files-541) |
| `spa.add-to-layer` | All add-to-collection/playlist/schedule affordances share one component layer at `web/src/media/addTo/`; multi-select is an explicit screen-level toggle, and the per-card menu offers schedule only for the server-validated kinds. | 2026-07-10 | [link](../decisions.md#2026-07-10--shared-add-to-layer-lives-in-websrcmediaaddto-select-mode-is-an-explicit-toggle) |
| `spa.app-shell-extraction` | `App.tsx` is only the composition root over `web/src/app/routes.tsx` (stable route-object identity), `app/AppShell.tsx` (shell chrome), and `app/ScreenContent.tsx` (exhaustive screen dispatch); primary actions are one explicit `PrimaryActionProvider` registration per screen, replacing the old global `ctv:primary-action` window event. | 2026-07-15 | [link](spa-modularization.md#2026-07-15--app-shellrouting-extraction--explicit-primary-action-ownership-247) |
| `spa.autotune-detailpanel-slideover` | The Auto-Tune DetailPanel SPA is a reusable `SlideOver` primitive sharing `useOverlayBehavior` with `Dialog`, plus a shared advanced-options model extracted from ChannelBuilder; decorative panes without backend support are dropped. | 2026-07-18 | [link](../decisions.md#2026-07-18--auto-tune-detailpanel-spa-reusable-slideover--shared-advanced-options-model-decorative-panes-dropped-to-match-the-backend-386) |
@@ -118,3 +152,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `spa.topbar-primary-action` | The TopBar's primary-action "+" button renders only when the active route declares a non-empty `primaryAction`, is wired (via a shared `usePrimaryAction` hook) only on single-unambiguous-create-flow list screens, and is dropped everywhere else rather than left as a dead/no-op button. | 2026-07-12 | [link](../decisions.md#2026-07-12--topbar-primary-action-button-wire-creates-drop-the-rest-238) |
| `spa.yaml-validator-textarea` | The YAML playout validator takes pasted YAML via a `<textarea>`, not a server-side file path, since the SPA has no filesystem access. | 2026-07-09 | [link](../decisions.md#2026-07-09--yaml-playout-validator-paste-textarea-instead-of-a-server-file-path) |
| `startup.parallel-orientation` | A fresh session runs two concurrent tracks at startup — Orientation (`AGENTS.md`/`CLAUDE.md` → `docs/README.md` task-signal map → the active decisions catalog `docs/decisions/README.md`) and, only when no issue is named, Selection (`scripts/select-queue.sh N`, deterministic live-Gitea ranking). A named issue skips Selection entirely. ersatztv#237, the closed pickup tracker this replaces, is reduced to a single archival breadcrumb and MUST NOT be read for live state. | 2026-07-21 | [link](../decisions.md#2026-07-21--parallel-orientation--selection-is-the-startup-protocol-237-retired-520) |
| `testing.e2e-local-fresh-config-dir` | Always point `scripts/e2e-local.sh` at a fresh config dir — a reused one never emits the probe's ready line and the script kills a healthy server. | 2026-07-21 | [link](workflow-process.md#2026-07-21--run-scriptse2e-localsh-against-a-fresh-config-dir-a-reused-one-hangs-the-readiness-probe-542) |
| `testing.live-e2e-prepush-timing` | Run live-E2E via `scripts/e2e-local.sh` before pushing a write-path or UI change, and exercise download endpoints with curl, never a browser tab. | 2026-07-21 | [link](workflow-process.md#2026-07-21--live-e2e-runs-before-the-push-and-downloads-are-curled-not-browsed-542) |
| `testing.playwright-mcp-download-and-recovery` | In Playwright-MCP E2E, fetch file-download endpoints with curl — never a browser tab or `window.open` — and if browser tools stall repeatedly, `pkill -f ms-playwright-mcp` and drive a fresh session. | 2026-07-21 | [link](workflow-process.md#2026-07-21--playwright-mcp-curl-download-endpoints-never-open-a-tab-or-windowopen-542) |
| `testing.troubleshoot-path-cannot-test-branding` | Verify logo/watermark/bug changes through a real channel playout — a green troubleshoot run proves nothing about branding. | 2026-07-21 | [link](workflow-process.md#2026-07-21--channel-branding-is-not-testable-through-the-troubleshooting-playback-api-542) |
+526
View File
@@ -0,0 +1,526 @@
# Session-workflow, CI-triage & review-process decisions (#542)
How we *work* — orchestration, CI-run triage, review routing, worktree and parallel-session hygiene,
and the tooling gotchas that have cost real sessions. Distinct from the engineering "why the code
does X" records: nothing here decides product behaviour.
These records were extracted from `docs/handoffs/chicorytv-issue-queue.md` in #542. That doc had
become the **only** copy of most of this knowledge — an inventory found ~30 of ~38 lore bullets had
no decision record at all — so it was neither retrievable via the catalog nor safe from a careless
prune. The rules are now records; the handoff doc keeps the instruction and points here for the why.
Related: `release-ci-governance.md` (merge/release gates and hooks), `startup.parallel-orientation`
(session-start tracks), `session.shared-checkout-refresh` (session-end shared-tree refresh).
---
## 2026-07-21 — A killed CI job reports `conclusion: failure`; read the log tail before diagnosing the diff (#542)
`key: ci.killed-job-triage` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** Never trust a job's `conclusion` field alone — read the log tail and require an `❌ Failure - Main …` marker before treating a red as a real failure.
**Signals:** killed job · runner restart · conclusion failure · log truncated mid-step · `❌ Failure - Main` marker · `Unable to pull refs/heads/v4` · act cache noise · UTC vs UTC+2 · semantically-null diff · paths: n/a · issues: #542
**Mechanics:** Gitea Actions job logs + runner container uptime on the runner host.
When the operator restarts the runners (a retune, a reboot), in-flight jobs die and Gitea marks them
**`failure`**, not `cancelled`. The tell is that the log **stops mid-step with no error and no
`❌ Failure - Main …` marker** — a real failure always leaves that marker. On run 1006, `EF migration
integrity` and `Functional E2E` both "failed" on a **BOM-removal-only** diff (6 files, one line each,
zero content change) that could not possibly break them; the logs simply truncated mid-`dotnet build`
at 12:08 UTC and both runners showed `Up About an hour` — the retune had killed them.
**Log timestamps are UTC; the host is UTC+2.** Convert before correlating, or the restart looks two
hours off and a correct theory gets wrongly discarded.
Corollary: a diff that *cannot* cause a failure is evidence the failure isn't yours — when a job that
passed on the previous head fails on a semantically-null delta, suspect the environment and go read
the log rather than re-litigating the diff. Beware warnings that look fatal: `Unable to pull
refs/heads/v4: …` is act refreshing its `/root/.cache/act` action cache and is followed by `Cloned …`
— it is noise, not a cause. Grep for the failure marker, not for the word "error".
## 2026-07-21 — `cancelled` is not `failure`; a cancelled run is no verdict (#542)
`key: ci.cancelled-is-not-a-verdict` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** Treat a `cancelled` conclusion as "no verdict" — never as pass or fail — and report FAILED and CANCELLED counts separately in any CI monitor.
**Signals:** conclusion cancelled · run-level vs job-level conclusion · pre-cancel genuine failure · CI monitor state != pending · phantom failure · paths: n/a · issues: #542
**Mechanics:** Gitea Actions run/job API; monitor logic, e.g. `fail=[j for j in jobs if j['conclusion']=='failure']; canc=[j for j in jobs if j['conclusion']=='cancelled']`.
The operator cancels runs by hand — they are the only party who can (see
`ci.batch-pushes-no-cancel-route`) — and a run-level `conclusion: cancelled` means the run produced
**no verdict** about your diff. Two traps follow. First, a run whose *overall* state is `failure` may
hold a **genuine job failure that happened before the cancel**: check job-level `conclusion` plus
timestamps rather than attributing the red to the cancel. Second, a cancelled run tells you nothing,
so never claim green on one.
A monitor that only asks "is state != pending" will report a cancelled run as a failure and send the
next session debugging a phantom. Split the two counts explicitly.
## 2026-07-21 — An infra-shaped red under host load is not a code failure (#542)
`key: ci.infra-shaped-red-under-load` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** When a job dies inside a setup/cache step before your code compiles, check the runner host's load before diagnosing the diff, and never file a CI bug off one sample under pressure.
**Signals:** load average 243 · `Setup .NET` 11ms failure · `remove /root/.cache/act/…/.gitignore: no such file or directory` · buildx `DeadlineExceeded: failed to compute cache key` · `Build & push image` · `EF migration integrity` · `FactoryServer-L` game server · phantom CI bug · paths: n/a · issues: #542
**Mechanics:** `uptime` on the runner host (bumblebee); Gitea Actions job step logs.
On 2026-07-17, with bumblebee at load average **243**, two unrelated jobs died in ways that look like
code bugs but weren't: `EF migration integrity` failed in ~11ms inside **`Setup .NET`** with
`remove /root/.cache/act/…/.gitignore: no such file or directory` (act's shared cache), and main's
`Build & push image` died on buildx `DeadlineExceeded: failed to compute cache key` after ~210s of
retries. Both re-ran green at normal load, and the migration job had no model change to test in the
first place.
The diagnostic tell is the **location** of the failure: a setup or cache step, before your code
compiles. One sample under load is not evidence of a systemic problem — this nearly got filed as #390
lane-rebalance fallout, which the evidence did not support, and would have sent the next session
chasing a phantom. Note also that a game server (`FactoryServer-L`) shares that host with the
runners, so high load is not always CI's own doing.
## 2026-07-21 — Batch your pushes: there is no agent-side cancel route on Gitea 1.25.4 (#542)
`key: ci.batch-pushes-no-cancel-route` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** Hold review fixes, doc corrections and format fixes locally and push **once** — a superseded run cannot be cancelled from the agent side and holds a runner slot until it finishes.
**Signals:** cancel run 404 · Gitea 1.25.4 · `POST /api/v1/.../actions/runs/{id}/cancel` · MCP `actions_run_write` `cancel_run` · session+CSRF · `dispatch_workflow` · orphaned run · 4-slot runner · paths: n/a · issues: #542
**Mechanics:** Gitea Actions REST + MCP `actions_run_write`; operator-only cancel in the browser UI.
Cancellation is impossible from the agent side on this Gitea (**1.25.4**):
`POST /api/v1/.../actions/runs/{id}/cancel` returns **404**, MCP `actions_run_write`'s `cancel_run`
returns **404**, and the web-UI route needs a session + CSRF that does not script (login 303s with no
session cookie). **Only the operator can cancel, in the browser** — so if you must supersede a live
run, say so explicitly instead of leaving it burning. (`dispatch_workflow` is a different route and
still works for re-triggering a **main** run.)
This corrects the older "superseded runs drain on their own" framing: they do finish, but they hold
one of the 4 runner slots while doing it, which is a real cost with several parallel sessions.
## 2026-07-21 — Do not gate or throttle pushes on host health (#542)
`key: ci.no-host-health-gating` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** Push when your work is validated — never SSH to bumblebee to sample load/RAM first, and never hand-schedule around other sessions' runs.
**Signals:** trust the build queue · runner retune · don't sample load before pushing · queueing is the queue's job · paths: n/a · issues: #542
**Mechanics:** Gitea build queue (two runners, 4 slots).
The runners were retuned for stability (operator, 2026-07-17); queueing is the queue's job, not the
agent's. Sampling host load before a push is both unreliable and unnecessary, and hand-scheduling
around other sessions produces coordination that no one can verify.
Batch your pushes (see `ci.batch-pushes-no-cancel-route`) because orphaned runs cannot be cancelled —
**not** because the host needs protecting. The two rules have the same action and different reasons;
conflating them leads to load-watching behaviour that this record forbids.
## 2026-07-21 — A lone `decisions lifecycle` red is a known infra flake: do nothing (#542)
`key: ci.decisions-lifecycle-flake` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** When `decisions lifecycle` is the **only** red job, do not investigate and do not create a new run to clear it — no rebase, no `--amend`, no no-op push; the operator reruns that single job from the Gitea UI.
**Signals:** decisions lifecycle red · append-only gate · known flake · operator selective rerun · no-op push · convincing local explanation · paths: `docs/decisions.md` · issues: #542, #473, PR #479
**Mechanics:** Gitea Actions `decisions lifecycle` job; operator-driven single-job rerun in the web UI.
Operator-stated, 2026-07-19. Report it as a known flake and carry on; only if *other* jobs are red too
does the run deserve diagnosis. Same family as the killed-job rule — a spurious single-job red is
cleared by the operator's selective rerun, never by pushing, and pushes cannot be cancelled anyway.
**The trap is that a convincing local explanation is always available.** On #473/PR #479 the job went
red just after `main` landed its own `decisions.md` entry, so "mine is no longer at EOF, I must
rebase" looked airtight. The rebase happened — and the job went red **again** on a head whose diff was
a verified pure EOF append with zero deleted lines. A rebase that provably satisfied the gate's stated
rule did not turn it green, which is the proof that the red was never about the diff. Check this rule
*before* theorising about a cause.
## 2026-07-21 — Arm the CI monitor at PR-open, via the commit-status endpoint (#542)
`key: ci.monitor-armed-at-pr-open` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** Arm a CI monitor on the PR head sha the moment the PR opens, polling the commit-status endpoint — not at the end of the work.
**Signals:** arm monitor at PR open · commit status endpoint · head sha · red run sat unnoticed · MySQL-apply flake · paths: n/a · issues: #542, #216
**Mechanics:** `GET /api/v1/repos/timothy/ersatztv/commits/{sha}/status`.
CI runs concurrently with the review and E2E work that follows a push, so the cost of a late-armed
monitor is entirely wasted wall-clock. In the #216 session three PR runs sat red for roughly an hour
on a CI-only flake while review and E2E ran to completion — the reds were only discovered afterwards.
Related context for interpreting an early red: the old MySQL host-port 3306 collision is fixed on main
(`ef8915f1`), so a lone MySQL-apply red now indicates the known infra flake and warrants a rerun
rather than diagnosis.
## 2026-07-21 — Heavy-render web tests need explicit per-test vitest timeouts on the CI VM (#542)
`key: ci.web-test-per-test-timeouts` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** Give heavy-render web tests an explicit per-test vitest timeout (e.g. 15s); never raise the global default to fix one slow test.
**Signals:** vitest timeout · 5s default · 100+ item grid · CI VM slower than local · run 686 · paths: `web/` tests · issues: #542
**Mechanics:** per-test timeout argument in the vitest test declaration.
The CI VM is materially slower than a dev Mac for render-heavy work. A web test rendering a 100+ item
grid runs in about 1s locally but hit the **5s** vitest default on the CI VM (run 686) and went red
for no code reason.
Bump the timeout on the specific test. Raising the global default hides genuine hangs across the whole
suite in exchange for fixing one known-slow case.
## 2026-07-21 — Build and verify locally, then trust it; CI confirms (#542)
`key: ci.verify-locally-ci-confirms` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** Treat the local build/verify/review pass as the decision point and CI as confirmation — don't idle waiting on a run you have no reason to doubt.
**Signals:** ~9 min warm CI · docs-only PR seconds · CI VM 127 · commit status watch · local gate first · paths: n/a · issues: #542, #416
**Mechanics:** `GET /api/v1/repos/timothy/ersatztv/commits/{sha}/status`; CI on VM 127.
A warm full CI run takes roughly **9 minutes**; a docs-only PR completes in seconds since ersatztv#416's
docs-only skip. Neither duration justifies blocking on the run when the same gates already passed
locally.
Watch the run by commit status rather than by polling the UI, and continue working. CI *confirms* the
local verdict; it is not the first line of defence.
## 2026-07-21 — Independent cross-model review is mandatory on risky diffs; a skip is a stated, auditable exemption (#542)
`key: process.independent-review-rubric` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** Run an independent review pass — preferably a different model family, otherwise a cold-context review-only agent — on any diff touching locks/concurrency, auth/security, API write-path handlers, or DB migrations, or larger than ~150 changed C# lines; skip only for a pure-SPA/docs leaf with no server-state effect, and state the skip and its reason in the PR or close comment.
**Signals:** cross-model review rotation · correlated blindspots · skip rubric · self-exemption · review-only agent · quota exhaustion · reviewer does not implement · paths: n/a · issues: #542, #235, #250
**Mechanics:** rotation — Codex reviews Claude-authored changes, Claude reviews Codex-authored changes; use whatever native integration is installed (client helpers like `codex:rescue` are optional accelerators, not protocol dependencies). On quota/tool exhaustion, fall back to a cold-context review-only agent in the current client and retry cross-model next window. Independent reviewers do review and stuck-diagnosis work only — never implementation on the same change they review.
The justification is correlated blindspots: independent model families miss *different* classes of
defect, so a single reviewer on a risky diff is the gap, not a saving. #235 (a reviewer clearing a lock
as sound because it never grepped for every producer) and #250 (a vacuous negative control that
silently ran a stale dll) are both cases the second, differently-blind pass caught.
The skip clause is deliberately narrow and deliberately loud. A self-exemption is an auditable claim,
not a vibe — silence is indistinguishable from forgetting, so the exemption must be written down where
the merge record can be re-read later.
## 2026-07-21 — Local build/test gate + cold-context review run BEFORE the push, not after (#542)
`key: process.local-gate-before-push` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** Run the local build/test gate and a cold-context, scoped "review only" adversarial review over the diff, fold the fixes, and only then push or open the PR.
**Signals:** local gate before push · cold review before CI · CI is not the first line of defence · uncancellable run · queue pressure · paths: n/a · issues: #542
**Mechanics:** local build/test gate + a scoped review-only agent over `origin/main...HEAD`; CI (armed on the PR head sha at PR-open) *confirms* the result.
Gitea runners should only ever see already-validated work. CI here is a scarce shared slow resource and
a run cannot be cancelled on this Gitea version, so spending a run to surface a lint error or a
review-catchable bug is pure waste plus queue pressure on every other in-flight session.
CI confirms; it is not the first line of defence. Treating it as the first line inverts the cost: the
cheap fast private check is skipped in favour of the expensive slow shared one.
## 2026-07-21 — Review disagreement on a gate PR escalates to the frontier judge — and the proposed FIX escalates with it (#542)
`key: process.review-disagreement-frontier-judge` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** When independent reviews disagree on a gate PR, escalate to the frontier judge, and put the proposed fix approach in front of it — not just the disputed finding.
**Signals:** review disagreement · frontier judge · escalate the fix not just the verdict · gate PR reconciliation · paths: n/a · issues: #542, #235
**Mechanics:** n/a — escalation is a routing decision by the orchestrator.
The reconciliation is not only "who is right about the finding". On #235 the frontier model ratified
the disputed High *and* caught that the orchestrator's own proposed fix (a per-source lock-or-skip over
a fixed loop order) would deterministically starve the 2nd and later media source — prescribing instead
the pattern that shipped.
That is the whole argument: a judge given only the verdict question would have returned "the High is
real" and let a starving fix ship. Send the fix approach up with the finding.
## 2026-07-21 — "Make X consistent with Y" review findings are new code and get new-code scrutiny (#542)
`key: process.consistency-fix-new-code-scrutiny` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** Review a "make X consistent with Y" change as new code, not as a mechanical copy — and for any timer or effect involved, ask explicitly "when does this fire?", including on mount.
**Signals:** consistency fix · propagating the pattern propagates the bug · mount-debounce · timer/effect fire timing · paths: n/a · issues: #542, #216
**Mechanics:** n/a — a review-attention rule.
Propagating a pattern propagates its bugs. A #216 review fix copied MediaBrowseScreen's mount-debounce
selection-wipe race into SearchScreen instead of catching it — the finding said "be consistent", and
consistency was achieved, including with the defect.
The mount question is the cheap general form of the check: most of this class hides in an effect or
timer that fires once more than the author pictured.
## 2026-07-21 — A lock/channel "no cross-release" verdict must enumerate every producer via grep (#542)
`key: process.lock-ownership-enumerate-producers` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** Before trusting any "single owner / no double release / no cross-release" claim, grep the whole host project for every writer of that channel message (or acquirer of that lock) — the background scheduler/worker is the usual missing producer.
**Signals:** cross-release · lock ownership · channel message producers · scheduler enqueues too · grep the host project · paths: n/a · issues: #542, #235, #267, #250
**Mechanics:** grep for every sender of the message type across the host project (e.g. all `Synchronize{X}Collections(` call sites), not just the controllers present in the PR diff. General form of "re-derive from a grep rather than enumerating from a list": see `sched.weighted-shuffle`.
On #235/#267 a cold fork cleared a new `scan-collections` per-provider lock as sound — "only the
acquirer enqueues, so only its message triggers the scanner unlock" — having checked the three new
controllers in the diff and stopped there. Codex grepped: `SchedulerService` also enqueues those
messages periodically and unlocked, and `ScannerService`'s `finally` released the lock whenever held,
so a scheduled scan cross-releases an API scan's lock (the #250 class).
Corollary: the independent pass earns its keep even when the first reviewer returns MERGEABLE. The
first verdict was not careless — it was complete over the evidence it looked at, which is exactly the
failure mode a second, differently-scoped reviewer catches.
## 2026-07-21 — Before deleting a "workaround", enumerate every behavior it provided (#542)
`key: process.enumerate-workaround-behaviors-before-deleting` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** When an issue says "delete X", enumerate every behavior X provided before removing it — a workaround often serves a second purpose that outlives the first.
**Signals:** remove the workaround · double duty · second purpose · bounded version re-scoped · PENDING_GRACE_TICKS · paths: n/a · issues: #542, #232
**Mechanics:** n/a — a scoping rule applied when reading a removal issue.
#232 said "remove the SPA `PENDING_GRACE_TICKS` grace-tick workaround now the API is honest". The grace
ticks did double duty: compensating the lying-200 (genuinely obsolete) *and* absorbing the inherent
queue→observed-active lag and fast-completion race (still needed). Removing them wholesale wedged the
scan button disabled-until-reload for short scans; both the fork and Codex caught it.
The landing move is usually not "delete" or "keep" but a bounded version re-scoped to the purpose that
still holds.
## 2026-07-21 — The shared tree at /Users/timothy/ersatztv is read-only and tells you nothing about `main` (#542)
`key: process.shared-tree-readonly` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** Never commit in `/Users/timothy/ersatztv` and never read its `git log`/`git status`/HEAD to infer anything about `main` — work in a worktree off `origin/main`, which is the only source of truth.
**Signals:** shared checkout · "main checkout" · worktree discipline · stale HEAD · origin/main is truth · node_modules copy · paths: `/Users/timothy/ersatztv`, `.claude/worktrees/`, `.worktrees/` · issues: #542
**Mechanics:** `git worktree add -b <branch> <path> origin/main`; `git fetch origin main` and reference `origin/main` explicitly — the local `main` ref lags and nobody is obliged to update it. The one sanctioned use of the shared tree is read-only: copying `web/node_modules` out of it (keep that fresh — a stale copy broke typecheck in a #198 worktree).
There is no "main checkout". `/Users/timothy/ersatztv` is a shared mutable working tree whose HEAD is
whatever the last session left there; its name lies. The rule holds even for a one-line docs fix — make
a worktree.
This is a design flaw, not a discipline failure: do not "fix" it by telling sessions to check harder,
because a check does not stay true. It broke for two different sessions on the same day, 2026-07-17,
both doing the obvious thing. Session A assumed it was on `main` — correct when it pulled at 12:46,
stale by 14:17 — and committed; the commit landed on the CI session's `ci/604-lane-rebalance`, 24
seconds after that session's own commit at 14:17:28. Two writers, one tree; nothing tangled, by luck.
Session B ran `git log` there, read that same branch's HEAD, and concluded `main` was "4 behind origin"
— a phantom. A session-start `git status` *appears to confirm* the false assumption, which makes it
worse than a plainly wrong claim.
Cross-reference `session.shared-checkout-refresh`, which covers the complementary failure mode: any
FILE (including a pasted kickoff prompt) read out of that tree may be stale, and the session-end
refresh script that mitigates it. This record covers the git-state failure mode only.
## 2026-07-21 — One worktree, one committing agent (#542)
`key: process.one-worktree-one-committing-agent` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** Never run two committing agents concurrently on one worktree — give each parallel slice its own worktree branched off the feature branch and merge back.
**Signals:** parallel agents · concurrent commits · worktree per slice · read-only review agent · paths: n/a · issues: #542
**Mechanics:** `git worktree add` a fresh tree per slice off the feature branch; merge the slices back into the feature branch when they land.
Read-only review agents sharing a tree are fine — the constraint is on *writers*. Sequence slices only
where one genuinely depends on another's output; "backend-first narrow, SPA-wide after" worked well for
#216.
## 2026-07-21 — Never commit or merge inside a worktree you did not create (#542)
`key: process.foreign-worktree-plumbing-merge` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** Never commit or merge inside a worktree another session created; land the merge with git plumbing against the branch ref instead.
**Signals:** sibling session worktree · branch already checked out · plumbing merge · read-tree · commit-tree · GIT_INDEX_FILE · paths: n/a · issues: #542
**Mechanics:** `git read-tree -m <base> <ours> <theirs>` into a temporary `GIT_INDEX_FILE` → `git write-tree` → `git commit-tree -p <ours> -p <theirs>` → push the resulting commit to the branch ref. The owning session then `git pull`s it.
With several sessions on one machine, any given branch may already be checked out in a sibling
session's worktree. Touching that checkout writes into another agent's working state; the plumbing
route mutates only the ref, so the merge lands without disturbing the owner's tree.
## 2026-07-21 — Fix onto an unmerged feature branch by branching off it and FF-pushing (#542)
`key: process.branch-off-feature-branch` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** To fix work on an unmerged feature branch, branch off that branch and land by fast-forward push — and after creating a worktree, drive the first Edit/Read from ITS absolute paths and `git status` it before building.
**Signals:** unmerged feature branch · FF-push · path leak · sibling worktree recon · wrong tree edits · "0 new tests" · paths: n/a · issues: #542
**Mechanics:** `git worktree add -b <fix-branch> <path> <feature-branch>`; land with a fast-forward push to the feature branch. Never touch a sibling worktree that has the branch checked out (see `process.foreign-worktree-plumbing-merge`).
The path-leak trap, hit on 2026-07-12 (#289 → PR#76): reconnaissance by `Read`ing a *sibling* worktree
leaked that tree's absolute paths into subsequent `Edit`s, so the edits landed there rather than in the
fresh worktree. `dotnet` then built the unchanged tree, and the tell was a build reporting "0 new
tests". A `git status` in the new worktree before the first build catches this in one command.
## 2026-07-21 — Bound parallel builds by FREE RAM, never regenerate goldens (#542)
`key: process.build-concurrency-limits` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** Run at most 3–4 concurrent dotnet/npm builds on this Mac, gate launches on FREE RAM rather than CPU load, and never set `ETV_UPDATE_GOLDENS` / `ETV_UPDATE_PLAYOUT_GOLDENS`.
**Signals:** build concurrency · memory_pressure · RAM starvation · fan-out cap · golden regeneration · paths: n/a · issues: #542
**Mechanics:** `memory_pressure -Q` — under 20% free, do not launch more build agents; under 10%, pause or stagger. Host is an M4, 10 cores, 16 GB.
Parallelize by default when slices are genuinely disjoint: use up to the client's concurrency limit,
normally 2–3 delegated agents plus the orchestrator. Fan out bounded recon early, but do not invent
artificial slices or exceed a runtime-imposed agent cap. CPU load spikes during builds are benign and
are not a stop signal; the resource that actually runs out is memory — the historic crash was RAM
starvation from an 8–9-way fan-out, which is why 5+ concurrent builds is never acceptable.
Separately: never set the golden-regeneration env vars. Regenerating goldens to make a diff disappear
hides exactly the change the golden net exists to surface.
## 2026-07-21 — A dropped subagent connection is transient; resume, don't restart (#542)
`key: process.subagent-drop-resume` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** Treat a subagent connection drop as laptop sleep or transient network and re-resume via SendMessage — the work survives.
**Signals:** subagent disconnect · connection drop · laptop sleep · SendMessage resume · paths: n/a · issues: #542
**Mechanics:** n/a — client-side resume.
Restarting the agent from scratch discards context the agent still holds; the drop is at the transport,
not the agent.
## 2026-07-21 — Every open issue carries a `priority:` label — run the H12 audit at session end (#542)
`key: process.issue-qualification-audit` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** Run `scripts/issue-qualification-audit.sh` at session end and label everything it flags, including issues you filed that session.
**Signals:** issue qualification · priority label · queue tiering · unqualified issues · H12 · session-end check · paths: `scripts/issue-qualification-audit.sh` · issues: #542, #312
**Mechanics:** `scripts/issue-qualification-audit.sh` (H12, ersatztv#312) lists OPEN issues missing a `priority:` label. It is fail-open without Gitea creds and advisory — exit 1 when any issue is unqualified. Sibling to H11 (both #311/#312).
Queue tiering keys off `priority:` and gate labels, so an issue without one is simply invisible to the
selector — it is filed but will never be picked. "Fully qualified" means the issue has a
`priority: {high,medium,low}` label, which signals triage ran; gate-vs-backlog is then derivable from
the `review` label and milestone. A milestone is **not** required — backlog is unmilestoned.
## 2026-07-21 — Claim with `in-progress` before working; claiming is not collision safety (#542)
`key: process.parallel-session-claim` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** Apply the `in-progress` label before starting an issue, and still read its dependency notes before touching shared surfaces — a claim prevents duplicate pickup, not overlapping code changes.
**Signals:** parallel sessions · in-progress label · claim race · dependency notes · shared surfaces · lore pruning · paths: `docs/handoffs/chicorytv-issue-queue.md` · issues: #542
**Mechanics:** `in-progress` label on the Gitea issue. The tiny read→claim race window is accepted; the later claimant backs off. Runner topology: two runners (ci-runner VM 127 + bumblebee-runner), 4 slots total.
The critical nuance is what a claim does *not* buy. Two sessions on non-overlapping issues can still
collide in the same files. Before touching a shared surface, read the issue's dependency notes ("#234
after #231", "coordinate with #215").
When editing the standing lore/handoff doc, prune covered and stale bullets rather than appending — it
is not append-only, and git keeps the history. `git pull --rebase` before committing it, since it is
the single most contended file across parallel sessions.
## 2026-07-21 — BOM/format pre-push detection: use the `xxd` loop, and run `dotnet format --include` under `bash -c` (#542)
`key: process.bom-format-detection-recipe` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** Before any push touching `.cs`, detect BOMs with the `xxd` byte check and verify the format gate with `dotnet format --include` run under `bash -c`, never bare zsh.
**Signals:** UTF-8 BOM · `efbbbf` · `dotnet format --verify-no-changes` · `--include` · `mapfile` · zsh vs bash · #311 format gate · detector verification · paths: `ErsatzTV.sln`, `.editorconfig` · issues: #542, #311, #70, PR #402, PR #405
**Mechanics:** Local pre-push shell; CI's format job recipe (`ci.format-gate-folder-mode`). The BOM *policy* is `release.format-as-you-touch-rebase`; this record is the mechanics only.
Detection loop, verbatim:
```bash
for f in $(git diff --name-only origin/main...HEAD -- '*.cs'); do head -c3 "$f" | xxd -p | grep -q "^efbbbf" && echo "BOM: $f"; done
```
**Verify your detector.** An `od -An -c | grep '357 273 277'` check reported all-clean while 19 files
were actually dirty. A detector that can only say "ok" is worse than none — use the `xxd` form or read
the bytes directly, and distrust a clean result you didn't prove can go dirty.
**`dotnet format --include` DOES work here** — an earlier note claiming it silently no-ops was WRONG.
The apparent no-op was **the shell**: CI's recipe uses `mapfile`, which is bash-only, and the default
shell here is zsh → empty array → zero files → exit 0. Working form:
```bash
bash -c 'mapfile -t files < <(git diff --name-only --diff-filter=ACM origin/main...HEAD -- "*.cs")
dotnet format ErsatzTV.sln --no-restore --verify-no-changes --include "${files[@]}"' # exit 0 = gate passes
```
## 2026-07-21 — Run `scripts/e2e-local.sh` against a FRESH config dir; a reused one hangs the readiness probe (#542)
`key: testing.e2e-local-fresh-config-dir` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** Always point `scripts/e2e-local.sh` at a fresh config dir — a reused one never emits the probe's ready line and the script kills a healthy server.
**Signals:** `READY_LINE` · `Done migrating search index` · `Search index is already version 50` · readiness probe timeout · e2e-local hang · paths: `scripts/e2e-local.sh`, `docs/e2e-local.md` · issues: #542, #533, #502, #524
**Mechanics:** The script greps one fixed `READY_LINE` — there is no "wait for either line" option today.
The probe waits for `Done migrating search index`, which only a **fresh** config emits. A reused config
logs `Search index is already version 50` instead, so the line never arrives, the probe times out, and
the script kills a perfectly healthy server — a failure that reads as a broken build.
Surfaced in the #502 session (two failed launches before the cause was obvious) and recovered from the
old tracker's comment log by the #524 triage. `docs/e2e-local.md` documents the ready line and
recommends a fresh dir, but not this failure mode. Widening the probe to
`Done migrating search index|Search index is already version` is tracked as **#533**.
## 2026-07-21 — Channel branding is NOT testable through the troubleshooting-playback API (#542)
`key: testing.troubleshoot-path-cannot-test-branding` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** Verify logo/watermark/bug changes through a real channel playout — a green troubleshoot run proves nothing about branding.
**Signals:** `PrepareTroubleshootingPlaybackHandler` · synthetic channel · `Artwork = []` · `ChannelId > 0` branch · watermark · channel logo · bug overlay · paths: `ErsatzTV.Application` troubleshooting handlers · issues: #542
**Mechanics:** n/a — a property of the handler, not a toggle.
`PrepareTroubleshootingPlaybackHandler` builds a **synthetic** channel with `Artwork = []`, and its
`ChannelId > 0` branch ignores the media item. Both together mean no branding state ever reaches the
pipeline through that path.
So the troubleshoot endpoint is a false witness for this class: it exercises the transcode pipeline but
not the overlay inputs. Route branding verification through a real channel playout instead.
## 2026-07-21 — Playwright-MCP: curl download endpoints, never open a tab or `window.open` (#542)
`key: testing.playwright-mcp-download-and-recovery` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** In Playwright-MCP E2E, fetch file-download endpoints with curl — never a browser tab or `window.open` — and if browser tools stall repeatedly, `pkill -f ms-playwright-mcp` and drive a fresh session.
**Signals:** Playwright MCP · file download endpoint · window.open · browser tab stall · `ms-playwright-mcp` · paths: n/a · issues: #542
**Mechanics:** `pkill -f ms-playwright-mcp` then re-drive from a fresh MCP session.
Download endpoints hand the browser a file rather than a page; the tab has nothing to snapshot and the
tool waits on navigation that never completes. curl gives you the bytes and the status code directly.
Repeated stalls are a wedged MCP browser process, not a slow app — kill it and restart rather than
retrying the same call.
## 2026-07-21 — Launch Codex cheap workers via `codex exec`, not `spawn_agent` (#542)
`key: process.codex-cheap-worker-launch` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** For bounded tool-bearing selector/recon work, launch a Codex worker with `codex exec -m gpt-5.4-mini -c model_reasoning_effort=low -s read-only`; `spawn_agent` buys parallelism but no cost savings.
**Signals:** codex exec · `--ephemeral` · `spawn_agent` · `gpt-5.4-mini` · `gpt-5.1-codex-mini` HTTP 400 · `model_reasoning_effort` · `~/.codex/models_cache.json` · paths: `~/.codex/models_cache.json` · issues: #542
**Mechanics:** Local `codex` CLI on this ChatGPT-authenticated host. Tested 2026-07-14.
The native `spawn_agent` interface currently exposes no model or effort selector, so it parallelises at
full cost. Launch a separate worker instead:
```
codex exec --ephemeral --color never -m gpt-5.4-mini -c model_reasoning_effort=low -s read-only -C <repo> "<hard-capped scope contract>"
```
GPT-5.4-Mini is the current supported small/cost-efficient profile on this host; the older
`gpt-5.1-codex-mini` guess fails with **HTTP 400**. Keep `low` whenever any shell/MCP/web tool is
exposed; reserve `minimal` for explicitly tool-free synthesis over already-supplied bounded evidence.
On model rollover, inspect the visible entries in `~/.codex/models_cache.json` rather than guessing
names, then update this record once verified.
## 2026-07-21 — Live-E2E runs BEFORE the push, and downloads are curled, not browsed (#542)
`key: testing.live-e2e-prepush-timing` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** Run live-E2E via `scripts/e2e-local.sh` before pushing a write-path or UI change, and exercise download endpoints with curl, never a browser tab.
**Signals:** live E2E timing · pre-push validation · `scripts/e2e-local.sh` · download endpoint · browser tab · paths: `scripts/e2e-local.sh`, `docs/e2e-local.md` · issues: #542
**Mechanics:** Seeding recipe: `docs/e2e-local.md` → "Seeding a local TV library for E2E" (tiny ffmpeg testsrc MKVs + `LibraryPath` SQL rows + scan). The *requirement* itself is `release.live-e2e-required`; this record adds only the timing and the download rule.
Live-E2E after the push wastes a CI run to learn something the local harness would have said first —
and on this Gitea a run cannot be cancelled. Fold E2E findings into the branch while nothing is pushed.
Download endpoints must be curled for the same reason as the Playwright rule: a tab has no page to
settle on and the browser tool hangs.
## 2026-07-21 — The PR routine is a fixed sequence: validate locally, then push, then only follow-up commits (#542)
`key: process.pr-routine-sequence` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** Worktree off origin/main → implement → regenerate API artifacts → full local tests + cold review + live-E2E ALL before the push → push, open PR, arm the CI monitor at open → fixes after the push are follow-up commits, never amend/force-push.
**Signals:** PR routine · worktree off origin/main · `./scripts/update-openapi.sh` · `npm run generate:api` · cold-context review · CI monitor at PR open · no force-push on a pushed branch · derived-consent merge gate · paths: `scripts/update-openapi.sh`, `scripts/e2e-local.sh` · issues: #542
**Mechanics:** CI watch by commit status: `/api/v1/repos/timothy/ersatztv/commits/{sha}/status`. Merge via the derived-consent gate (`pretooluse-merge-consent.sh`).
The sequence: create a worktree off `origin/main`; implement; for **API changes build the app project
FIRST**, then `./scripts/update-openapi.sh`, then `npm run generate:api` (order matters — the generator
reads the built app's spec). Then run the full local test pass, a cold-context adversarial review
scoped to "review only", and live-E2E for UI/write-path changes — **all before the push**. Fold
pre-push fixes into the branch; amend/reword is fine while nothing is pushed.
Then push, open the PR, and arm the CI monitor **at open**, not at the end. Once the branch is pushed,
every further fix — a post-push cross-model pass, or a CI-surfaced issue — lands as a **follow-up
commit**; never amend or force-push a pushed branch. On green, merge through the derived-consent gate
and write the structured close comments the applicable `AGENTS.md` / `CLAUDE.md` protocol requires.
## 2026-07-21 — Harden a soon-to-grow feature with a runtime posture, not a hardcoded clamp (#542)
`key: process.harden-with-runtime-posture-not-clamp` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** When a security fix constrains a capability the roadmap will later want, make the safe state the DEFAULT OF A SWITCH rather than a wall — and read the feature's own issue for its end-state first.
**Signals:** runtime posture · feature toggle · `ERSATZTV_ALLOW_WRITES` · read-only default · hardcoded clamp · roadmap end-state · paths: n/a · issues: #542, #289, #58
**Mechanics:** `ERSATZTV_ALLOW_WRITES` env var, default `false`. See `mcp.server-foundation`.
The MCP hardening in #289 could have shipped a GET-only clamp. But the MCP's final design (#58) is read
**and** write, so that clamp would have been ripped out within a release — churn plus a window where
the removal, not the original fix, is the risky change.
A runtime read-only posture (`ERSATZTV_ALLOW_WRITES`, default false) gives the same safety today and
grows into the intended design without a revert. The generalisable step is the cheap one: check the
feature's issue for its end-state *before* choosing between a switch and a wall.
## 2026-07-21 — Gitea's `?milestones=` issue filter silently no-ops on names containing `:` or `+` (#542)
`key: ci.gitea-milestone-filter-noop` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** Never filter issues with the server-side `?milestones=<name>` parameter — fetch all open issues once and filter LOCALLY on each issue's `.milestone.title`.
**Signals:** `GET /issues?milestones=` · milestone name filter · returns the whole open-issue list · `:` or `+` in a milestone title · mis-tiered issues · count looks like "all open issues" · paths: `scripts/select-queue.sh` · issues: #542, #77, #72
**Mechanics:** `?state=open&type=issues&limit=50`, then filter in the client, e.g. `[i for i in issues if (i['milestone'] or {}).get('title') == NAME]`. `scripts/select-queue.sh` already does this; the trap is for anyone writing a *new* query.
The filter does not error on a name it cannot round-trip — it returns the **entire open-issue list** as
though every issue matched. Milestone titles here routinely contain the offending characters (e.g.
`Scheduling: refactor + distribution`), so the failure is the normal case, not an edge case.
Anything trusting that response mis-tiers issues: during #77 selection it made unmilestoned #72 look
like a member of the milestone and hid #77's true sibling set. **The tell is a result count suspiciously
equal to the total number of open issues** — if a filtered query returns everything, the filter silently
failed; re-derive membership locally rather than reasoning about the result.
The dependencies API is unaffected: `POST /issues/{n}/dependencies` with `{"owner","repo","index"}` sets
blocked-by correctly, though the bare `{index}` form returns 201 without reliably attaching — verify
with the GET. (That last sentence is carried forward from an earlier revision of the handoff doc,
commit `f93458c7`, where it was dropped by a later prune rather than disproved.)
+1 -1
View File
@@ -79,7 +79,7 @@ Channel (1) ──< Playout (0..N per channel; ChannelPlayoutSource distinguishe
| **MediaItemState** | Health flag on a media item: Normal/FileNotFound/Unavailable/RemoteOnly. Drives the Trash screen. | `MediaItemState` | `/app/trash` |
| **PlayoutItem** | One materialized, built entry in a playout's timeline (the actual thing that will play at a given time). | `PlayoutItem` | (generated, not directly edited) |
| **PlayoutHistory** | Rotation/rerun bookkeeping per block (`BlockId`) + collection `Key`/`ChildKey`, used by block-playout schedulers to avoid repeats; inspectable via Troubleshooting. | `PlayoutHistory` | `/app/troubleshooting/blocks` |
| **Channel concepts** | `Number` (validated by `Channel.NumberValidator` regex), `Group`, `PlayoutSource` (Generated/Mirror; Mirror channels relay another channel via `MirrorSourceChannelId`+`PlayoutOffset`), `PlayoutMode` (Continuous/OnDemand), `TranscodeMode` (OnDemand only, today), `IdleBehavior` (StopOnDisconnect/KeepRunning), `StreamingMode` (TransportStream/HttpLiveStreamingDirect/HttpLiveStreamingSegmenter/TransportStreamHybrid). | `Channel` | `/app/channels`, `/app/edit-channel/{id}`, `/app/new-channel`, `/app/auto-tune` (bulk-generate from library metadata, #69; per-channel DetailPanel content-source members read via `GET /api/v1/channels/auto-tune/members`, #384; per-channel `templateId`/`advanced`/`logo` overrides accepted by `POST /api/v1/channels/auto-tune`, #385; per-source rotation weights + query corrections via an optional `sources: [{sourceId, weight, excluded}]` on that same request, #425 — a customized channel is backed by a system-owned `MultiCollection` of per-source `SmartCollection`s with `PlaybackOrder.WeightedShuffle`, `OwnedByChannelId`-tagged so it's hidden from collection lists and cleaned up on channel delete) |
| **Channel concepts** | `Number` (validated by `Channel.NumberValidator` regex), `Group`, `PlayoutSource` (Generated/Mirror; Mirror channels relay another channel via `MirrorSourceChannelId`+`PlayoutOffset`), `PlayoutMode` (Continuous/OnDemand — OnDemand is the per-channel "resume where I left off" freeze/thaw mode, #68; see `channels.md` → On-demand resume), `TranscodeMode` (OnDemand only, today), `IdleBehavior` (StopOnDisconnect/KeepRunning), `StreamingMode` (TransportStream/HttpLiveStreamingDirect/HttpLiveStreamingSegmenter/TransportStreamHybrid). | `Channel` | `/app/channels`, `/app/edit-channel/{id}`, `/app/new-channel`, `/app/auto-tune` (bulk-generate from library metadata, #69; per-channel DetailPanel content-source members read via `GET /api/v1/channels/auto-tune/members`, #384; per-channel `templateId`/`advanced`/`logo` overrides accepted by `POST /api/v1/channels/auto-tune`, #385; per-source rotation weights + query corrections via an optional `sources: [{sourceId, weight, excluded}]` on that same request, #425 — a customized channel is backed by a system-owned `MultiCollection` of per-source `SmartCollection`s with `PlaybackOrder.WeightedShuffle`, `OwnedByChannelId`-tagged so it's hidden from collection lists and cleaned up on channel delete) |
| **Channel health / `PlayoutCount`** (#72) | Whether a channel can play at all. `PlayoutCount` (channel's own playouts, **plus the mirror source's** when `PlayoutSource is Mirror` — computed by `Mapper.GetPlayoutsCount`) rides on both `ChannelResponseModel` (list) and `ChannelDetailResponseModel`; `0` ⇒ the channel can never play, rendered as a "No playout" badge + a matching **No playout** filter on the channels list (both name only the one fault the API can prove — a broader "Problems" label would read as a false all-clear to a user whose *other* fault classes below are uncomputed). It is a **raw fact, not a status enum** — see `decisions.md` 2026-07-17. Distinct from `/api/v1/channels/state`'s `OnAir`, which is runtime liveness ("someone is streaming right now"), not "would play if tuned". Empty-schedule, broken-source and auto-tuned-vs-user origin are deliberately **not** computed (see that decision entry for why each is unsafe today). | `Channel.Playouts` | `/app/channels` (read-only signal) |
| **Guide / EPG (XMLTV)** | Per-channel programme guide generated from playout items; channels with `ShowInEpg=false` are excluded. The SPA's JSON guide grid is built by the sibling `GetChannelGuideDataHandler`. Both the JSON guide (`ChannelGuideChannelResponseModel`) and the channels-list DTO (`ChannelResponseModel`) expose a rooted, directly-usable `Logo` URL (#464) via `Mapper.GetLogoUrl` — `/iptv/logos/{file}` for an uploaded logo, the absolute URL passed through for an external one, `null` when unset (SPA then draws the generated initials "bug"). | `GetChannelGuideHandler`, `GetChannelGuideDataHandler` | `/app/guide` (viewer); settings at `/app/settings/xmltv` |
| **M3U** | The channel lineup playlist Jellyfin/Dispatcharr consume. | `ChannelPlaylist.ToM3U()` | — |
+94 -353
View File
@@ -15,7 +15,7 @@ history in `docs/decisions/archive/startup.md`). Do not read it for current queu
The ChicoryTV rewrite + go-live arc is **COMPLETE** and closed out. The project is now in
**maintenance / backlog mode**. This is a *terminal* fact (the arc will not re-open), so it belongs
in this standing file — unlike any "current frontier issue", which must never be written here (read
it live; see the queue-drift lesson in the archived section below).
it live — status embedded in prose always drifts).
So a fresh session stops re-discovering this every time:
- The **arc / gate / open-`review` tiers are normally EMPTY now.** An empty arc frontier is the
@@ -115,8 +115,8 @@ never orchestrator-tier work:
`GET /issues/{n}/dependencies` on every candidate (dropping any with an OPEN blocker), tiers by
LOCAL `.milestone.state`/`review`/`priority:` filters (never the server `?milestones=` name filter,
which no-ops on `:`/`+`), and orders by (tier, priority, issue#). **Its DEPS/tiering/ordering are
deterministic — trust them, do not re-derive them by hand** (see the archived section below for why
that used to be necessary and isn't anymore). It also raises two JUDGMENT flags it deliberately does
deterministic — trust them, do not re-derive them by hand** (the hand-derivation failure modes the
script encodes are recorded at `ci.gitea-milestone-filter-noop`). It also raises two JUDGMENT flags it deliberately does
not decide — `CLAIM?` (a claim can precede the label) and `UMBRELLA?` (an epic whose children are the
real pickups) — resolve ONLY those by reading the flagged issue's comments/body. The script does not
rank an arc tier (there is no active arc right now — maintenance/backlog mode, see above); if a new
@@ -233,6 +233,10 @@ Then work the queue:
record` (template below) on each issue you closed or substantially progressed; remove your
`in-progress` labels; and complete the per-issue Task Completion Protocol from the applicable
`AGENTS.md` / `CLAUDE.md` instructions (including the `done` workflow when required).
6. **Leave the shared checkout fresh** (H13): run `scripts/refresh-shared-checkout.sh`. It
fast-forwards `/Users/timothy/ersatztv` to `origin/main` and refreshes `web/node_modules` if the
lockfile moved; it refuses and changes nothing unless that tree is on a clean, non-ahead `main`.
A NO-OP is a normal result — report it and carry on.
## Closing record (session-end / issue-close artifact)
@@ -251,363 +255,100 @@ is both the human-readable summary and the per-issue unit MemPalace mines for re
```
HARD CONSTRAINTS:
- Work in worktrees off `origin/main` — ALWAYS, including for a one-line docs commit. `/Users/timothy/ersatztv`
is a **shared mutable tree, not a `main` mirror**: never commit in it, and never read its HEAD/`git log`
to learn anything about `main` (you'll get another session's branch). Its only sanctioned use is
**read-only**: copying `web/node_modules` out of it. `origin/main` is the source of truth — `git fetch
origin main` and reference it explicitly. See the lore bullet for the two sessions this bit on 2026-07-17.
- PARALLELIZE BY DEFAULT when there are genuinely disjoint slices: use up to the client's concurrency
limit, normally 2–3 delegated agents plus the orchestrator. Fan out bounded recon early, but do not
invent artificial slices or exceed a runtime-imposed agent cap. At most 3–4 concurrent
dotnet/npm builds are fine on this Mac (M4, 10 cores, 16 GB); the go/no-go signal is FREE
RAM, not CPU load (`memory_pressure -Q`: <20% free → don't launch more build agents; <10% →
pause/stagger). CPU load spikes during builds are benign. Never 5+ builds (the historic
crash was RAM starvation from an 8–9-way fan-out). NEVER set ETV_UPDATE_GOLDENS.
- Never two committing agents on ONE worktree — give each parallel slice its own worktree
branched off the feature branch and merge back. Sequence only where a slice genuinely depends
on another's output (backend-first narrow, SPA-wide after worked well for #216).
- **BATCH YOUR PUSHES — you cannot cancel a CI run** (operator-requested, 2026-07-17). Hold review
fixes, doc corrections and format fixes locally, verify, and push **once**; each push spawns a run
that orphans the previous one on a 4-slot runner. Cancellation is impossible from the agent side on
this Gitea (**1.25.4**): `POST /api/v1/.../actions/runs/{id}/cancel` → **404**, MCP
`actions_run_write` `cancel_run` → **404**, and the web-UI route needs a session+CSRF that does not
script (login 303s, no session cookie). **Only the operator can cancel, in the browser** — so if you
must supersede a live run, SAY SO explicitly instead of leaving it burning. (`dispatch_workflow` is
a different route and still works for re-triggering a **main** run.) Corrects the older "superseded
runs drain on their own" note below: they do finish, but they hold a slot while doing it.
- **A lone `decisions lifecycle` red is a KNOWN INFRA FLAKE — do NOTHING** (operator-stated,
2026-07-19). When it is the **only** red: do not investigate it, and do **not** create a new run or
commit to clear it — no rebase, no `--amend`, no no-op push. **The operator reruns that single job
from the Gitea UI.** Report it as a known flake and carry on; only if *other* jobs are red too does
the run deserve diagnosis. Same family as the killed-job rule below (a spurious single-job red is
cleared by the operator's selective rerun, never by pushing — and pushes can't be cancelled, see the
batching constraint above). **The trap is that a convincing local explanation is always available.**
On #473/PR #479 the job went red just after `main` landed its own `decisions.md` entry, so "mine is
no longer at EOF, I must rebase" looked airtight — I rebased, and it went red **again** on a head
whose diff was a verified pure EOF append with zero deleted lines. A rebase that provably satisfied
the gate's stated rule did not turn it green, which is the proof the red was never about the diff.
**Check this rule before theorising about the cause.**
- **Do NOT gate or throttle your push on host health — trust the Gitea build queue.** The runners were
retuned for stability (operator, 2026-07-17); queueing is the queue's job, not yours. Don't SSH to
bumblebee to sample load/RAM before pushing, and don't hand-schedule around other sessions' runs. Batch
your pushes (above) because orphaned runs can't be cancelled — not because the host needs protecting.
- **An infra-shaped red under host pressure is not a code failure — check the host before diagnosing the
diff** (2026-07-17). While bumblebee was saturated, two unrelated jobs died in ways that look like code
bugs: `EF migration integrity` failed in ~11ms inside **`Setup .NET`** with
`remove /root/.cache/act/…/.gitignore: no such file or directory` (act's shared cache), and main's
`Build & push image` died on buildx `DeadlineExceeded: failed to compute cache key` after ~210s of
retries. Both re-ran green once the host recovered; the migration job had no model change to test in the
first place. Tell: the failure is in a *setup/cache* step, before your code compiles. Don't file a CI bug
off one sample under pressure — you'll send the next session chasing a phantom (this nearly got filed as
#390 lane-rebalance fallout, which the evidence did not support).
- **Before any push touching `.cs`, check the touched set for a UTF-8 BOM.** The #311 gate is
fix-as-you-touch: a legacy file's inherited BOM becomes *your* problem the moment you touch it, and
`charset=utf-8` means no BOM.
`for f in $(git diff --name-only origin/main...HEAD -- '*.cs'); do head -c3 "$f" | xxd -p | grep -q "^efbbbf" && echo "BOM: $f"; done`
Strip any hit (1-line diff, zero content change). Bit **two sessions on 2026-07-17** — PR #405 (6
files) and #70/PR #402 (19 files, where Python `io.open(..., encoding='utf-8-sig')` wrote BOMs back
while "preserving existing style"). **Verify your detector**: an `od -An -c | grep '357 273 277'`
check reported all-clean while 19 files were dirty — a detector that can only say "ok" is worse than
none. Use the `xxd` form or read bytes directly.
**`dotnet format --include` DOES work here** (corrected 2026-07-17, #70/PR #402 — an earlier note
claiming it silently no-ops was WRONG). The apparent no-op was **the shell**: CI's recipe uses
`mapfile`, which is **bash-only**, and the default shell here is **zsh** → empty array → zero files
→ exit 0. Run it under `bash -c`:
```bash
bash -c 'mapfile -t files < <(git diff --name-only --diff-filter=ACM origin/main...HEAD -- "*.cs")
dotnet format ErsatzTV.sln --no-restore --verify-no-changes --include "${files[@]}"' # exit 0 = gate passes
```
- Merge consent is DERIVED, not asked: the H6/H10 gate (`pretooluse-merge-consent.sh`) auto-grants a
merge when the linked issue's `## Done-when` boxes are all ticked, a fresh positive `Review-verdict`
references the current head, and CI is green — no conversational confirmation on that satisfied path
(ersatztv#314). Confirm in-conversation only when the gate ASKS (state not derivable: no creds, no
linked issue, no Done-when/verdict, Gitea down). Prior pre-approvals still do NOT carry over.
- **Local gate + cold review run BEFORE the push to Gitea CI, not after** (Timothy, 2026-07-17):
run the local build/test gate AND a cold-context adversarial review agent (SCOPED "review only")
over the diff, fold any fixes, and only THEN push / open the PR. Gitea runners should only ever
see already-validated work — CI is a scarce shared slow resource and a run cannot be cancelled on
this Gitea, so spending a run to surface a lint error or a review-caught bug is pure waste + queue
pressure. CI (armed at PR-open, below) *confirms*; it is not the first line of defence.
- Arm a CI monitor on the PR head sha AT PR-OPEN (commit-status endpoint), not at the end.
- Live-E2E via scripts/e2e-local.sh (also before the push for write-path / UI changes); NEVER
exercise download endpoints via browser tabs (curl them).
- **Work in a worktree off `origin/main` — always**, including a one-line docs commit. Never commit in
`/Users/timothy/ersatztv` and never read its git state to learn about `main`. → `process.shared-tree-readonly`
- **One committing agent per worktree.** Give each parallel slice its own; read-only review agents may
share. → `process.one-worktree-one-committing-agent`
- **Never commit or merge inside a worktree you did not create** — land on a sibling-owned branch with a
plumbing merge. → `process.foreign-worktree-plumbing-merge`
- **Parallelize by default** on genuinely disjoint slices (normally 2–3 delegated agents). Cap builds at
3–4 and gate on FREE RAM, not CPU. **Never set `ETV_UPDATE_GOLDENS`.** → `process.build-concurrency-limits`
- **Local gate + cold-context review BEFORE the push**, never after. → `process.local-gate-before-push`
- **Independent review is mandatory** for locks/concurrency, auth/security, API write-path handlers, DB
migrations, or >~150 changed C# lines; a skip must be stated with its reason. → `process.independent-review-rubric`
- **Batch your pushes — you cannot cancel a CI run.** Only the operator can cancel, in the browser.
→ `ci.batch-pushes-no-cancel-route`
- **Do not gate your push on host health** — trust the build queue. → `ci.no-host-health-gating`
- **Arm the CI monitor at PR-open**, on the head sha. → `ci.monitor-armed-at-pr-open`
- **Before any push touching `.cs`, BOM-check the touched set** and run the format gate under `bash -c`.
→ `process.bom-format-detection-recipe`
- **Keep a PR branch current by REBASING on `origin/main`** — never merge main in; regenerate (never
hand-resolve) generated artifacts after a conflicted rebase. → `release.format-as-you-touch-rebase`
- **Live-E2E via `scripts/e2e-local.sh` before the push** for write-path/UI changes; curl download
endpoints, never browser tabs; use a fresh config dir.
→ `testing.live-e2e-prepush-timing`, `testing.e2e-local-fresh-config-dir`
- **Merge consent is DERIVED, not asked.** The H6/H10 gate auto-grants when the `## Done-when` boxes are
ticked, a fresh `Review-verdict` references the current head, and CI is green; confirm in conversation
only when the gate ASKS. Prior pre-approvals never carry over. → `release.merge-consent-autogrant`
- **After the push, fixes are follow-up commits** — never amend or force-push a pushed branch.
→ `process.pr-routine-sequence`
---
# Lessons / workflow lore (STANDING workflow/orchestration rules only)
> **Scope**: this section is *how we work* (orchestration, CI, review, cross-session hygiene) —
> not engineering decisions. **Every "why the code does X" decision lives in `docs/decisions.md`**
> (+ `api-conventions.md` §7a/b/c for the concurrency/side-effect mechanics, `spa-conventions.md`,
> `e2e-local.md`). Do NOT re-record a code/API/SPA decision here — it will duplicate and drift.
> Pruned 2026-07-12: issue-specific engineering bullets now covered in those docs, and dead
> Blazor-parity process notes, were removed (git history has them). Pruned 2026-07-21: pre-script
> selector lore and the #237-specific queue-drift narrative moved to the archived section below —
> the mechanism they describe no longer exists.
> **Scope**: *how we work* — orchestration, CI triage, review routing, cross-session hygiene. Each rule
> below is one or two lines plus its decision `key:`; the evidence, the incident that produced it and the
> full rationale live in that record (`docs/decisions/workflow-process.md` for most of them). **Keep it
> that way** — this file is pasted into every session's kickoff, so a paragraph of narrative here is a tax
> paid by every future session, while a record is retrieved only when it is needed (#542).
>
> Editing: prune covered and stale bullets rather than appending — this is not append-only and git keeps
> the history. If you add a rule, write the record first and cite it here.
- READ docs/README.md → the convention docs FIRST; point recon/implementer agents at specific
doc sections. Only recon the task-specific delta.
- **The mechanical selection checks live in `scripts/select-queue.sh` — run it instead of
re-deriving them by hand.** It does the deterministic work in code (dependency exclusion via
`GET /dependencies`, LOCAL `.milestone.state`/`review`/`priority:` tiering, (tier, priority, issue#)
ordering, `in-progress`/`parked`/PR exclusion) and only *flags* `CLAIM?`/`UMBRELLA?` for judgment.
**Trust its DEPS/tiering/ordering; do the focused recheck only on the flagged rows.** If you extend
the tiers/filters, update the script first, then these bullets. (See the archived section below for
the pre-script failure modes that motivated writing it, if you need the history.)
- **Codex cheap-worker launch (tested 2026-07-14)**: the native `spawn_agent` interface currently
has no model/effort selector, so it provides parallelism but not cost savings. For bounded,
tool-bearing selector/recon work, launch a separate worker with
`codex exec --ephemeral --color never -m gpt-5.4-mini -c model_reasoning_effort=low -s read-only
-C <repo> "<hard-capped scope contract>"`. GPT-5.4-Mini is the current supported small/cost-efficient
profile on this ChatGPT-authenticated host; the older `gpt-5.1-codex-mini` guess fails with HTTP 400.
Keep `low` whenever any shell/MCP/web tool is exposed; reserve `minimal` for explicitly tool-free
synthesis over already-supplied bounded evidence. If the model rolls over, inspect the visible entries
in `~/.codex/models_cache.json` instead of guessing names, then update this dated note once verified.
- Keep the MAIN checkout's `web/node_modules` fresh (`npm install` after pulling a PR that
adds a dep) — worktrees copy it, and a stale copy broke typecheck in a #198 worktree.
- **Playwright-MCP E2E: never open tabs/window.open for file-download endpoints** — curl them.
If browser tools stall repeatedly, `pkill -f ms-playwright-mcp` and drive a fresh session.
- **THERE IS NO "main checkout". `/Users/timothy/ersatztv` is a shared mutable working tree whose HEAD
is whatever the last session left there — it is NOT a `main` mirror, and its name lies.** Treat it as
**read-only, and never as a source of truth**:
- **Never commit in it** — not even a one-line docs fix. Need a commit on `main`? Make a worktree off
`origin/main` (`git worktree add -b <branch> <path> origin/main`) and commit there.
- **Never read `git log` / `git status` / `HEAD` there and infer anything about `main`** — you will be
reading some other session's branch.
- **`origin/main` is the only source of truth.** `git fetch origin main` and reference `origin/main`
explicitly. Do not trust the local `main` ref either (it lags; nobody is obliged to update it).
- Its **one sanctioned use is read-only**: copying `web/node_modules` into your worktree (keep it
fresh — a stale copy broke typecheck in a #198 worktree).
**Orientation**
- Read `docs/README.md` → the convention docs first; point agents at named sections, recon only the
task-specific delta. → `docs.convention-docs-session-start`
- Run `scripts/select-queue.sh` for queue selection; trust its deps/tiering/ordering and resolve only its
`CLAIM?`/`UMBRELLA?` flags. → `startup.parallel-orientation`
- Claim with `in-progress` before working — but a claim prevents duplicate *pickup*, not overlapping code
changes. → `process.parallel-session-claim`
**This is a design flaw, not a discipline failure — do not "fix" it by telling sessions to check
harder.** A check does not stay true. Worktree discipline is healthy (10 feature worktrees on
2026-07-17: Claude under `.claude/worktrees/`, Codex under `.worktrees/`, some in `/private/tmp/`);
the shared tree is the single place the model breaks, and it broke for **two different sessions on the
same day, both doing the obvious thing**:
- Session A assumed it was on `main` (true at 12:46 when it pulled — **correct when made**, stale by
14:17) and committed → the commit landed on the #604/CI session's `ci/604-lane-rebalance`, **24
seconds after that session's own commit at 14:17:28**. Two writers, one tree. Nothing tangled, by luck.
- Session B ran `git log` there, read `ci/604-lane-rebalance`'s HEAD, and concluded `main` was "4
behind origin" — a phantom.
**Building and reviewing**
- The PR routine is a fixed sequence; for API changes build the app project FIRST, then
`./scripts/update-openapi.sh`, then `npm run generate:api`. → `process.pr-routine-sequence`
- Re-review the FIX COMMIT, not just the initial diff; loop until a clean verdict or an explicit
acceptable-defer, and file a tracked issue for anything deferred. → `release.review-verdict-gate`
- When independent reviews disagree, escalate to the frontier judge — and escalate the proposed FIX with
the finding. → `process.review-disagreement-frontier-judge`
- "Make X consistent with Y" findings are new code; for any timer/effect ask when it fires, including on
mount. → `process.consistency-fix-new-code-scrutiny`
- Before trusting a "single owner / no cross-release" claim, grep the whole host project for every
producer — the background scheduler is the usual miss. → `process.lock-ownership-enumerate-producers`
- Before deleting a "workaround", enumerate every behavior it provided.
→ `process.enumerate-workaround-behaviors-before-deleting`
- Concurrency tests need a stress loop and a negative control that inverts the real condition — never
`if (true)`. → `ffmpeg.work-ahead-slot-atomic`
- Enumerate lazy LanguageExt returns in tests; live-E2E is the only net for that class.
→ `release.live-e2e-required`
- Harden a soon-to-grow feature with a runtime posture, not a hardcoded clamp.
→ `process.harden-with-runtime-posture-not-clamp`
A session-start `git status` *appears to confirm* the false assumption, which makes it worse than a
plainly wrong claim. (Supersedes the old "the user's main checkout stays ON main and pulled each
session" note.) Same family as the sibling bullet below: never commit inside a checkout or worktree
you didn't create.
- Subagent connection drops = laptop sleep/transient; re-resume via SendMessage (work survives).
- Build/verify (and review) locally, then trust it; CI (VM 127) confirms (~9 min warm; seconds for
a docs-only PR since ersatztv#416). Watch by commit
status: `/api/v1/repos/timothy/ersatztv/commits/{sha}/status`.
- PR routine (works): worktree off origin/main → implement (strongest tier for judgment-heavy /
balanced tier for mechanical work when model routing is available; doc pointers + exact facts;
use the available C# language tooling) → API changes: build app project
FIRST then `./scripts/update-openapi.sh` then `npm run generate:api` → **full local test pass +
cold-context adversarial review (SCOPED "review only") + live-E2E for UI/write-path changes
(`scripts/e2e-local.sh`), ALL BEFORE the push** — fold pre-push review fixes into the branch
(amend/reword is fine while nothing is pushed) → push, PR (arm the CI monitor at open) → CI
*confirms* → any further review fixes (a post-push cross-model pass, or CI-surfaced issues) as
follow-up COMMITS (never amend/force-push a pushed branch) → CI green → merge (derived consent
gate) → structured close comments per the applicable `AGENTS.md` / `CLAUDE.md` protocol.
- Never run TWO committing agents on one worktree concurrently. Read-only review agents are fine.
- **A KILLED job reports `conclusion: failure` — the conclusion field is not enough. Read the log tail.**
(2026-07-17.) When the operator restarts the runners (a retune, a reboot), in-flight jobs die and Gitea
marks them **`failure`**, not `cancelled`. Tell: the log **stops mid-step with no error and no
`❌ Failure - Main …` marker** — a real failure always leaves a marker. Run 1006's `EF migration
integrity` + `Functional E2E` "failed" on a **BOM-removal-only** diff (6 files, 1 line each, zero
content change) that could not possibly break them; the logs simply truncated mid-`dotnet build` at
12:08 **UTC**, and both runners showed `Up About an hour` — the retune had killed them. **Log
timestamps are UTC; the host is UTC+2** — convert before correlating, or the restart looks 2h off and
the theory gets wrongly discarded.
Corollary: **a diff that cannot cause a failure is evidence the failure isn't yours** — when a job that
passed on the previous head fails on a semantically-null delta, suspect the environment and go read the
log, don't re-litigate the diff. Also beware log **warnings that look fatal**: `Unable to pull
refs/heads/v4: …` is act refreshing its `/root/.cache/act` action cache and is followed by `Cloned …`
— it is NOISE, not a cause. Grep for the failure marker, not for the word "error".
- **`cancelled` is NOT `failure` — never read a cancel as a CI verdict** (2026-07-17, operator-surfaced).
The operator cancels runs by hand (they're the only one who can — see the batching constraint above), and a
run-level `conclusion: cancelled` means **no verdict**, not a pass and not a fail. Two traps: (1) a
run whose overall state is `failure` may hold a *genuine* job failure that happened **before** the
cancel — check job-level `conclusion` + timestamps, don't attribute it to the cancel; (2) a
`cancelled` run tells you nothing about your diff, so don't claim green on it. Report FAILED and
CANCELLED counts **separately** in any CI monitor:
`fail=[j for j in jobs if j['conclusion']=='failure']; canc=[j for j in jobs if j['conclusion']=='cancelled']`
— a monitor that only asks "is state != pending" will call a cancelled run a failure and send you
debugging a phantom.
- **An infra-shaped red under host load is not a code failure — check `uptime` on bumblebee BEFORE
diagnosing the diff** (2026-07-17). At load average **243**, two unrelated jobs died in ways that
look like code bugs but aren't: `EF migration integrity` failed in ~11ms inside **`Setup .NET`**
with `remove /root/.cache/act/…/.gitignore: no such file or directory` (act's shared cache), and
main's `Build & push image` died on buildx `DeadlineExceeded: failed to compute cache key` after
~210s of retries. Both re-ran green at normal load; the migration job had no model change to test in
the first place. Tell: the failure is in a *setup/cache* step, before your code compiles. Don't file
a CI bug off one sample under load — you'll send the next session chasing a phantom (this nearly got
filed as #390 lane-rebalance fallout, which the evidence did not support). A game server
(`FactoryServer-L`) shares that host with the runners, so load isn't always CI's doing.
- **Arm a CI monitor the moment the PR opens** (commit-status endpoint), not at the end — in the
#216 session three PR runs sat red ~an hour on a CI-only flake while review/E2E ran. (The old
MySQL host-port 3306 collision is fixed on main, `ef8915f1`; a lone MySQL-apply red now = the
known infra flake, rerun — see MEMORY / `ci-migration-job-mysql-flake`.)
- **"Make X consistent with Y" review findings deserve new-code scrutiny** — a #216 review fix
propagated MediaBrowseScreen's mount-debounce selection-wipe race to SearchScreen instead of
catching it. For any timer/effect, ask "when does this fire?" (incl. mount).
- Live-E2E seeding recipe now lives in `docs/e2e-local.md` → "Seeding a local TV library for E2E"
(tiny ffmpeg testsrc MKVs + `LibraryPath` SQL rows + scan). Live-E2E is a **stated requirement**
for write-path handler changes — see that doc's "When live-E2E is required" + the decisions.md
entry; it's the only net for the lazy-enumeration / reload-through-read-path 500 class (#229).
- **Parallel sessions**: claim before working (`in-progress` label — the tiny read→claim race window
is accepted; later claimant backs off). Claiming prevents duplicate pickup, NOT overlapping code
changes — check the issue's dependency notes ("#234 after #231", "coordinate with #215") before
touching shared surfaces. Editing THIS lore: prune covered/stale bullets too (not append-only — git
keeps history), `git pull --rebase` before commit. Two runners (ci-runner VM 127 + bumblebee-runner,
4 slots); **no cancel route exists on this Gitea version — verified 1.25.4: REST and MCP
`cancel_run` both 404** (see the batching constraint above). Superseded runs do finish on their own,
but they hold a runner slot while doing it, so with 4 parallel sessions they are a real cost — batch
pushes rather than relying on them to drain.
- **Two sessions touching one machine**: a branch may be checked out in ANOTHER session's
worktree — never commit/merge inside a worktree you didn't create. To land a merge on such a
branch without touching their checkout: plumbing merge (`git read-tree -m base ours theirs`
into a temp GIT_INDEX_FILE → `write-tree` → `commit-tree -p ours -p theirs` → push the
commit to the branch ref); the owning session then `git pull`s.
- **Cross-model independent review rotation** (2026-07-11): use the other client/model family when
it is available — Codex reviews Claude-authored changes; Claude reviews Codex-authored changes.
Use whatever native integration is actually installed; client-specific helpers such as
`codex:rescue` are optional accelerators, not protocol dependencies. On quota/tool exhaustion,
use a cold-context review-only agent in the current client and retry cross-model review next window.
Independent reviewers do review and stuck-diagnosis work, not implementation on the same change.
**Skip rubric (self-exemption is an auditable claim, not a vibe)**: an *independent* review pass
(preferably cross-model; otherwise a cold agent) is **mandatory** for any diff touching
locks/concurrency, auth/security, API write-path handlers, or DB migrations, or larger than
~150 changed C# lines. It is **skippable** only for a pure-SPA/docs leaf with no server-state
effect — and when you skip it, **say so and why** in the PR or close comment (a stated exemption,
not silence). This is the correlated-blindspot net: independent model families miss *different* classes
(see the #235 "enumerate every producer" and #250 lessons), so a single reviewer on a risky diff
is the gap.
- **Force enumeration of lazy LanguageExt returns in tests** (2026-07-11, PR #229): a write-path 500
survived a green fixed-point test because the handler returned a lazy `Map` the test never enumerated.
Enumerate handler return values in tests; live E2E remains the only net for this class. (Write-path
reload-through-read-path mechanics: api-conventions §7.)
- **Keep a PR branch current by REBASING on origin/main — do NOT merge main in** (ersatztv#311,
enforced by the H11 pre-push hook: a push from a branch behind origin/main is blocked → `git fetch
origin main && git rebase origin/main`). A *merge* commit pulls in every file main changed, incl.
files you never touched (e.g. the ~2500 legacy-BOM `.cs`), which then trip the format hook/CI on code
that isn't yours (the #309 session). Rebasing keeps your diff to exactly what you changed. After a
rebase that hits conflicts in **generated artifacts** (v1.json, v1.d.ts, endpoint-index), REGENERATE
them (`./scripts/update-openapi.sh` + `npm run generate:api`) — never hand-resolve; git text-merges
them plausibly-but-wrong and `npm run check:api` is the guard. Escape hatch for a deliberate
non-rebased push: `ETV_SKIP_REBASE_CHECK=1 git push`.
- **H12 issue-qualification audit** (ersatztv#312, `scripts/issue-qualification-audit.sh`): a
session-end check that lists OPEN issues missing a `priority:` label — the queue tiering keys off
`priority:`/gate labels, so an unlabeled issue is invisible to it. "Fully qualified" = has a
`priority: {high,medium,low}` label (that signals triage ran; gate-vs-backlog is then derivable
from the `review` label / milestone, and a milestone is NOT required — backlog is unmilestoned).
Run it at session end and label anything it flags (esp. issues you filed this session). Fail-open
without creds; advisory (exit 1 when any are unqualified). Sibling to H11 (both #311/#312).
- **CI VM test timeouts**: heavy-render web tests (100+ item grids) need explicit vitest
timeouts (e.g. 15s) — the CI VM hit the 5s default on a test that runs in ~1s locally
(run 686). Bump per-test, don't raise the global default.
- **Concurrency tests need a negative control + a stress loop, not a single round** (2026-07-11,
#231): a one-shot `Barrier(N)` + `Task.WhenAll` "exactly one winner" test did NOT catch a
deliberately non-atomic check-then-set flag on this Mac — the load→store window is too narrow to
collide when threads release once. It green-lit broken code. Fix: hammer the race over many rounds
(N worker threads × ~20k rounds, two Barriers per round: acquire → validate one-winner → reset),
and ALWAYS prove non-vacuous by temporarily breaking the primitive and watching the test fail.
Cheap (sub-second) and deterministic-enough. Same discipline as #12's negative control.
- **Before deleting a "workaround", check it isn't serving a SECOND purpose** (2026-07-11, #232):
an issue said "remove the SPA `PENDING_GRACE_TICKS` grace-tick workaround now the API is honest".
The grace ticks did double duty — compensating the lying-200 (now genuinely obsolete) AND
absorbing the inherent queue→observed-active lag / fast-completion race (still needed). Removing
wholesale wedged the scan button disabled-until-reload for short scans; both fork + Codex caught
it. Keep a bounded version, re-scoped honestly. When an issue says "delete X", enumerate every
behavior X provided before deleting.
- **Re-review the FIX COMMIT, not just the initial PR diff, before closing** (2026-07-11, #242,
user-surfaced): the pre-fix review is only half the loop — the fix commit is new code no
independent reviewer has seen. Here the first fix (gate Edit on `saving`) only PARTIALLY closed
the in-flight-save clobber class; a Codex re-review of the fix commit caught that **Delete** was
the one schedule-switch path (`applySwitch`) still missing the guard. Fold a scoped re-review
(cross-model if quota, else a cold review agent) over the fix commit into the close protocol; loop until a clean
MERGEABLE verdict or an explicit acceptable-defer (file a tracked issue for the deferral, as #248
here). Cheap relative to shipping a half-fix that reopens the issue.
- **Negative-controlling a lock/flag fix: invert the condition, don't stub `if (true)`** (2026-07-11,
#250): to prove a lock-release test is non-vacuous I temporarily broke the fix. First attempt made
the guard `if (true)` — but that left the ownership flag "assigned but never read" → **CS0219 under
warnings-as-errors → the Application build silently FAILED → `dotnet test --no-build` ran the STALE
(fixed) dll → false PASS** that looked like the control had failed to catch the bug. The tell: build
output must show `Build succeeded` / `0 Error(s)`, not just a truncated tail. Break the primitive by
**inverting** the real condition (`if (!lockAcquired)`) so the flag stays read and the code compiles;
always grep the build for `error CS` before trusting a `--no-build` test result. (Codex-found High
this session: an outer `catch` that unlocked unconditionally cross-released another session's lock on
a pre-acquisition exception — the fix was a Handle-scoped ownership flag gating the catch.)
- **A lock/channel-ownership "no cross-release" verdict must enumerate EVERY producer of that message
type, not just the enqueuers visible in the PR diff** (2026-07-11, #235/#267). The cold fork cleared the
new `scan-collections` per-provider lock as sound — "only the acquirer enqueues, so only its message
triggers the scanner unlock" — because it checked the three new controllers but never grepped for ALL
`Synchronize{X}Collections(` senders. Codex did: `SchedulerService` also enqueues them *periodically and
unlocked*, and `ScannerService`'s `finally` released the lock whenever held → a scheduled scan
cross-releases an API scan's lock (#250 class). Rule: before trusting any "single owner / no double
release / no cross-release" claim, `grep` the whole host project for every writer of that channel message
(or acquirer of that lock) — the background scheduler/worker is the usual missing producer. This is why the
independent Codex pass earns its keep alongside the fork even when the fork returns MERGEABLE.
- **Independent-review disagreement on a gate PR → frontier judge; judge the fix, not just the verdict**
(2026-07-11, #235): the reconciliation isn't only "who's right" — Fable historically ratified Codex's High AND caught
that the orchestrator's proposed *fix* (per-source lock-or-skip) would deterministically starve the 2nd+
media source (fixed loop order), prescribing the shipped library-scan §C7 pattern instead (lock once per
provider, batch-enqueue, release on the `Unlock: isLast` message). Escalate the FIX approach too, not just
the finding.
- **Harden a soon-to-grow feature with a runtime posture/toggle, not a hardcoded clamp** (2026-07-12, #289):
the MCP's final design (#58) is read+write, so a GET-clamp would be ripped out — shipped a runtime read-only
posture (`ERSATZTV_ALLOW_WRITES`, default false) instead. When a security fix constrains a capability the
roadmap will later want, make the safe state the *default of a switch*, not a wall; check the feature's issue
for its end-state first.
- **Fixing onto an unmerged feature branch: branch off it, land by FF-push, never touch a sibling worktree that
has it checked out** (2026-07-12, #289 → PR#76). Gotcha: recon by `Read`ing a *sibling* worktree leaked its
paths into my later `Edit`s → they landed there, not the fresh worktree (`dotnet` built the unchanged tree →
"0 new tests" tipped it off). After creating a worktree, do the first Edit/Read against ITS absolute paths and
`git status` it before building.
- **`HttpCompletionOption.ResponseHeadersRead` moves the body read OUTSIDE `HttpClient.Timeout`** (2026-07-12,
#289): with a `None` read token a slow-drip upstream hangs forever. Fix = per-request linked
`CancellationTokenSource.CancelAfter(timeout)` into BOTH `SendAsync` and the stream reads +
`HttpClient.Timeout = InfiniteTimeSpan`; catch transport/timeout exceptions and turn them into a response.
(Not yet in decisions.md — #289 landed on PR#76, not main.)
**Reading a red CI run** (check these before diagnosing your diff)
- A **killed** job reports `conclusion: failure` — read the log tail for the `❌ Failure - Main` marker;
log timestamps are UTC, the host is UTC+2. → `ci.killed-job-triage`
- **`cancelled` is not a verdict** — report FAILED and CANCELLED separately. → `ci.cancelled-is-not-a-verdict`
- A failure inside a **setup/cache step**, before your code compiles, is environmental.
→ `ci.infra-shaped-red-under-load`
- A lone **`decisions lifecycle`** red is a known flake: do **nothing**, the operator reruns it. A
convincing local explanation is always available — check this rule before theorising.
→ `ci.decisions-lifecycle-flake`
- Heavy-render web tests need explicit per-test vitest timeouts. → `ci.web-test-per-test-timeouts`
- Verify locally and trust it; CI confirms (~9 min warm, seconds for docs-only).
→ `ci.verify-locally-ci-confirms`
---
**Tooling gotchas**
- Playwright-MCP: curl download endpoints, never open a tab; `pkill -f ms-playwright-mcp` on repeated
stalls. → `testing.playwright-mcp-download-and-recovery`
- Channel branding is NOT testable through the troubleshooting-playback API.
→ `testing.troubleshoot-path-cannot-test-branding`
- Codex cheap workers launch via `codex exec`, not `spawn_agent`. → `process.codex-cheap-worker-launch`
- A dropped subagent connection is transient — resume via SendMessage, don't restart.
→ `process.subagent-drop-resume`
- Fixing onto an unmerged feature branch: branch off it, FF-push, and beware the sibling-worktree path
leak. → `process.branch-off-feature-branch`
## Archived — pre-script selector history (do not follow)
> This section is history, not instruction. It explains why `scripts/select-queue.sh` exists and
> why the closed #237 tracker used to be the queue-state store; none of it is a live protocol. The
> current protocol is "Two concurrent tracks at session start" above.
- **(Pre-2026-07-19) Before the selector script existed**, a cheap model was dispatched each session
to rank the backlog by reading tracker prose directly, and the orchestrator was told to RE-DERIVE
its mechanical claims because the model kept getting them wrong: it reported blocked issues as
`deps:clear` (never called `/dependencies`), mis-tiered issues when the server-side `?milestones=`
filter silently no-ops on `:`/`+` names, ranked by issue number and ignored priority-within-tier,
and missed live `in-progress` claims. `scripts/select-queue.sh` was written to do the mechanical
parts deterministically in code instead — the evidence bullets below are kept for context, not
because you need to re-derive anything by hand anymore.
- **Gitea milestone-name issue filter silently NO-OPS on names with special chars (2026-07-17,
#77 selection)**: `GET /issues?milestones=<name>` returns the WHOLE open-issue list — not a
filtered set — when the milestone title contains `:` or `+` (e.g. `Scheduling: refactor +
distribution`), because the raw name doesn't round-trip through Gitea's filter. A selector that
trusts that response will mis-tier issues (it made #72 look like a milestone-10 member when it's
unmilestoned, and hid #77's true sibling set). Robust recipe (now encoded in the script): fetch
all open issues once and filter LOCALLY on each issue's `.milestone.title`.
- **Confirmed again + widened (2026-07-17, #72/#164 selection): the packet does not reliably see
`blocked-by`, priority-within-tier, `in-progress` claim state, or umbrella-vs-child** — that run's
dependency data was correct, yet it still ranked #383 first while missing that #384 was
`in-progress` (claimed 40 minutes earlier) and that #383 is that bundle's umbrella. The script's
`CLAIM?`/`UMBRELLA?` flags now surface exactly this instead of relying on a full manual re-check.
- **The cheap selector's dependency + priority data was NOT trustworthy — the fix was to make the
script compute them, not to re-derive the whole contested tier by hand every session** (2026-07-17,
#73 selection): the old packet reported #385/#386 as `deps:clear` when both were blocked, and
missed #383 entirely (the only `priority: medium` in tier 1). The script now runs
`GET /issues/{n}/dependencies` on every surviving candidate and tiers by priority-within-tier
deterministically, closing this class.
- **Queue-drift root cause (2026-07-12, user-surfaced, historical)**: pickups repeatedly re-picked
already-done work because DONE/OPEN status was read from **prose** (the old tracker's arc body
inline markers + session comments' "Recommended next"), which lags real issue state — worst under
parallel sessions narrating each other's merges. STANDING FIX (still the rule, just no longer tied
to any specific tracker): **live Gitea state is the ONLY source of truth for status**; a comment's
"Recommended next" is a candidate that MUST be re-verified OPEN before pickup, never propagated
when stale. Do not name today's frontier issue in this standing file — that's how the drift
started the first time.
**Session end**
- Run the H12 audit and label everything it flags. → `process.issue-qualification-audit`
- Run `scripts/refresh-shared-checkout.sh` so the next session's checkout is current.
→ `session.shared-checkout-refresh`
+17 -8
View File
@@ -431,14 +431,23 @@ string-keyed dispatcher, or generic screen-action framework participates in norm
`window.confirm` guard belongs on *screen navigation / unload* while any uncommitted edit exists,
not on panel close. Show an "Edited" badge on customised rows so the pending edits are visible.
- **Advanced channel-options overrides are shared, not duplicated** (`web/src/builder/advancedOptions.tsx`).
The 24-field `CreateChannelFromLineupAdvancedOptionsRequest` override model — the enum catalogs,
`ADVANCED_KEYS`, `effectiveValue`, and the **INHERIT/omit** field adapters (`useAdvancedOverrides`) —
is one module consumed by both the Channel Builder and the Auto-Tune DetailPanel. The contract
(a select on `INHERIT` or a cleared text input **omits** the field so the create handler coalesces
it with the template value; there is no "None" — #135) must not be re-implemented per screen. Each
screen writes its own field JSX over the shared hook; `playbackOrder`/`playoutMode` are surfaced as
dedicated controls (Builder state / Auto-Tune's Shuffle + Always-playing toggles) and merged into
`advanced` at create, not carried in the override map.
The `CreateChannelFromLineupAdvancedOptionsRequest` override model — the enum catalogs,
`ADVANCED_KEYS`, `effectiveValue`, and the field adapters (`useAdvancedOverrides`) — is one module
consumed by both the Channel Builder and the Auto-Tune DetailPanel, and must not be re-implemented
per screen. Each screen writes its own field JSX over the shared hook; `playbackOrder`/`playoutMode`
are surfaced as dedicated controls (Builder state / Auto-Tune's Shuffle + Always-playing toggles) and
merged into `advanced` at create, not carried in the override map.
- **Three per-field states: INHERIT / set / CLEAR** (#135, `api.from-lineup-clear-to-none`). A select on
`INHERIT` (or a cleared text input) **omits** the field so the create handler coalesces it with the
template value. A concrete value overrides the template. The **`CLEAR` sentinel** ("None") is stored in
the overrides map like any override but is folded into the request's `advanced.clear` list at build
time by `applyOverridesToRequest`/`collectClears` — so the sentinel never reaches the wire as a field
value, and `effectiveValue` reads a `CLEAR` override as none. **Build the request only through
`applyOverridesToRequest`**, never a raw spread of the overrides map (a spread leaks the `CLEAR`
sentinel). Today only the five id selects (watermark + fillers) expose a "None" option; the backend
`clear` enum also covers the preferred audio/subtitle language strings for machine clients, but the SPA
text inputs keep "empty = inherit" (a tri-state text control is deferred) — API-ahead-of-UI, matching
the "REST API is a real audience" posture.
## 12. Reusable rule builder (`web/src/builder/rules/`)
+206
View File
@@ -0,0 +1,206 @@
# Tracker-retrofit triage — ersatztv#237 (#524)
Evidence for the decision record `docs.tracker-comment-retrofit` (`docs/decisions.md`). That record
states the rule; this file is the audit trail — what was examined, by what method, and how every one
of the 111 comments was classified, so the result can be spot-checked or re-derived rather than taken
on trust.
Deliberately **not** under `docs/decisions/`: files there are parsed as decision records by
`scripts/decisions_lib.py`, and this is evidence, not a record. It is also not in
`docs/decisions/migration-map.md`, whose scope is the #521 legacy-heading→key migration.
## Why the triage happened
server-management#642 exports Gitea issue history into MemPalace but **excludes #237** — at 111
comments it exceeds the miner's per-file cap. #520 separately removed #237 from session startup. The
worry behind #524: a durable fact living *only* in a #237 comment would become unretrievable, and
should be curated into lifecycle records.
## Method
1. **Corpus.** All 111 comments pulled verbatim from the Gitea API with per-comment provenance (id,
author, timestamp, canonical URL). Nothing sampled — every comment read. The corpus itself is not
committed (that would be the 1:1 dump #524 rejects); to re-derive or spot-check a row below,
re-pull from the permanent primary source:
`GET /api/v1/repos/timothy/ersatztv/issues/237/comments` (returns all 111 in one call).
2. **Three-way split** (from #524), applied to each *content item*, since one session comment
routinely mixes all three:
- **decision** — a durable "we do X rather than Y because Z" that governs future behavior;
- **issue-work** — per-session/per-issue narration (closures, PR/CI numbers, review loops, claims),
which already lives on the individual Gitea issue and is ingested from there;
- **lore** — a cross-cutting workflow/orchestration lesson, belonging in the handoff lore or MEMORY.
3. **Coverage test, applied to decision-shaped items only.** Semantic candidate discovery against the
`ErsatzTV-Decisions` / `ErsatzTV-Decisions-Archive` MemPalace wings, then verification against
canonical Markdown by exact search (`docs/decisions.md`, `docs/decisions/*.md`,
`docs/decisions/archive/*.md`, `docs/handoffs/chicorytv-issue-queue.md`, convention docs). No item
was declared covered on a semantic hit alone — MemPalace was discovery, never authority.
4. **Worked-issue check.** For any item surviving step 3, the issue the comment narrates was read
live. This is the step that disposes of most apparent orphans.
## Result
**Decision-shaped orphans: zero.** No durable decision-shaped fact exists only in #237.
Item-level counts from the coverage pass (units are *items*, so totals exceed 111 — one comment
commonly carries a decision restatement, its issue-work narration, and a lore aside):
| Slice | Only-in-#237 | Already covered | Issue-work | Lore |
| --- | --- | --- | --- | --- |
| comments 1–28 | 0 | 14 | 12 | 2 |
| comments 29–56 | 0 | 18 | 22 | 14 |
| comments 57–84 | 0 | 12 | 14 | 8 |
| comments 85–111 | 0 (1 raised, disproved) | 16 | 21 | 9 |
| **total** | **0** | **60** | **69** | **33** |
Comment-level primary class (units are *comments*; sums to exactly 111): decision **14**,
issue-work **88**, lore **9**.
### The one candidate raised, and its disproof
Comment [106/111] (`issues/237#issuecomment-14077`) recorded #497's deliberate scope exclusions —
Guids (not eager-loaded by `GetOrAdd`, so reconciling would duplicate-insert every scan) and
Directors (not add-persisted for `MusicVideoMetadata`). Absent from the decision corpus, so it
survived the corpus check. It fails the worked-issue check: issue #497's comments **14013** and
**14072** state both exclusions with fuller reasoning, and the exporter ingests that issue as
`497.md`. Category 2, not a retrofit target. (`scan.musicvideo-reconciliation` covers #494's
neighbouring reconcile decision but not this field-scope question — distinct records.)
### Spot-checks verified independently
- **#357, parked player-owned-playback architecture options** — preserved on issue #357 itself (8
comments carrying the three paths and the no-go). Note the docs copy is *not* on `main`:
`docs/player-owned-playback-poc.md` and the `docs/decisions.md` verdict entry exist only on the
unmerged `feat/357-player-owned-playback` branch, so the issue is currently the sole reachable
home. Category 2 holds, but by a narrower margin than the other items.
- **#235 / PR #267, the `Unlock: isLast` batch fix** — this *was* a fresh design decision (Fable
prescribed lock-once-per-provider batching because the naive per-source fix would starve the 2nd+
media source; now live at `SchedulerService.cs`), not merely an application of
`locking.entitylocker-atomic-flags`. It is still category 2: issue #235 comment **8880** and PR
#267 comment **8794** both carry the rationale.
### Known gap in this triage
The 33 lore-class items were classified but **not** coverage-checked. That bucket is not empty: two
facts from comment [109/111] have no home anywhere and were swept into
`docs/handoffs/chicorytv-issue-queue.md` by this PR — the `e2e-local.sh` readiness-probe hang on a
reused config dir, and the troubleshooting playback API's inability to exercise channel branding. A
future tracker triage should coverage-check the lore bucket too, or state that it didn't.
## Per-comment classification
`primary-class` is the single dominant class of each comment; `durable home` names where that
content lives (the worked issue, a doc, or `ephemeral` for queue bookkeeping with no durable home).
| # | primary-class | durable home |
| --- | --- | --- |
| 1 | issue-work | #231–#235 filed (triage session); ephemeral |
| 2 | decision | triage-verdict rule (Blocker/High ⇒ priority pickup); decisions.md 2026-07-11 |
| 3 | issue-work | #213, #215, #217–#221, #236 |
| 4 | decision | tracker "Pending adversarial reviews" section; ephemeral (protocol) |
| 5 | issue-work | #126, #207, #212 closed; #230, #238 filed |
| 6 | issue-work | #231, #232 via PR #241 |
| 7 | lore | re-review the fix commit, not just the initial PR diff |
| 8 | issue-work | #244 via PR #249 (ChannelsScreen extraction) |
| 9 | issue-work | #233, #234 via PR #250; #235 remainder |
| 10 | issue-work | audit sweep filing #251–#254 |
| 11 | issue-work | arc/backlog triage for #251–#254; ephemeral |
| 12 | issue-work | #251, #252 via PR #258 |
| 13 | issue-work | adversarial-reviewer#24; #255–#257 triage |
| 14 | issue-work | #238, #243, #246 body edits; ephemeral |
| 15 | decision | #253 concurrency contract (Version, ETag, 412); optimistic-concurrency.md |
| 16 | decision | #204 redirect matcher; decisions.md `spa.legacy-redirect-matcher` |
| 17 | decision | #205, #206 auth posture + rollback tag; blazor-route-parity.md §5 |
| 18 | issue-work | #202, #255–#257 via PR #262; #264 filed |
| 19 | issue-work | #253 PR1 landed; #265 filed |
| 20 | lore | api-conventions.md §7a/§7b section-number collision on parallel merge |
| 21 | issue-work | #235 via PR #267 (F9 gate closed) |
| 22 | issue-work | #91b SPA pre-work PR #272; #271 filed |
| 23 | lore | Codex "interrupted" status unreliable; trust the rollout transcript |
| 24 | issue-work | #253 PR3 landed; #269 filed |
| 25 | decision | #91b Blazor removal; rollback tag `blazor-final` = bb310f6b |
| 26 | decision | #253 PR4 phasing — collapse Phase-1/428 flip, no separate soak release |
| 27 | issue-work | #197 decomposed into #280–#289; PR #279 Phase-0 |
| 28 | decision | #197 Bundle A — single API key, `RequireKeyForReads` default true |
| 29 | issue-work | #283, #294 |
| 30 | issue-work | #289 (PR #76) — `ERSATZTV_ALLOW_WRITES` posture |
| 31 | issue-work | #295 design ratified (OIDC + local login) |
| 32 | issue-work | #245–#247 (App.tsx modularization plan review) |
| 33 | issue-work | #287, #288 (Bundle C) |
| 34 | issue-work | #271 |
| 35 | issue-work | ephemeral (arc/tracker bookkeeping correction) |
| 36 | lore | queue-status-in-prose drift; docs/handoffs/chicorytv-issue-queue.md |
| 37 | issue-work | #295 PR1, #299, #301 |
| 38 | issue-work | #269 (500-exposure slice); #303 H6/H10 |
| 39 | decision | #303 H9 append-only + H3 root-png guard; release-ci-governance.md |
| 40 | decision | #303 H10 review-verdict merge gate; release-ci-governance.md |
| 41 | issue-work | #269 closed; #308 filed |
| 42 | decision | #311 H11 rebase-not-merge + scoped format gate; contributing.md |
| 43 | issue-work | #303 close-out; #314, #315 filed |
| 44 | issue-work | #317 merge-gate double-prompt fix; #314, #319 |
| 45 | issue-work | #295 PR2, #301; #320 filed |
| 46 | lore | merge-gate needs `ETV_GITEA_BASICAUTH` in env; #315 |
| 47 | issue-work | #319 (CSP / Permissions-Policy) |
| 48 | issue-work | #253, #265 closed |
| 49 | issue-work | #238 (TopBar primary action) |
| 50 | issue-work | #286 (`/api/v1` versioning); #289 superseded |
| 51 | issue-work | #172 (scheduling API hardening); #327 filed |
| 52 | issue-work | #314 (authenticated security scan); #330 filed |
| 53 | issue-work | #197 go-live gate closed; #334 filed |
| 54 | issue-work | #335, #336 (v26.8.0 go-live) |
| 55 | issue-work | #237 tracker closure summary; ephemeral |
| 56 | issue-work | #330 (CORP header); #340 filed |
| 57 | issue-work | #333, #344–#346 |
| 58 | issue-work | #344, #347 |
| 59 | issue-work | #245, #350, #352 |
| 60 | issue-work | #354–#357 |
| 61 | issue-work | #357, #358 (player-owned playback PoC) |
| 62 | issue-work | #246, #359, #360 |
| 63 | issue-work | #243, #247 (modularization epic) |
| 64 | issue-work | #299, #363 |
| 65 | issue-work | #99, #333, #334 (queue-selection correction) |
| 66 | issue-work | #345, #357, #367 |
| 67 | issue-work | #99, #368 |
| 68 | decision | selector tier order; docs/handoffs/chicorytv-issue-queue.md |
| 69 | issue-work | #354, server-management#599 |
| 70 | decision | selector eligibility — exclude parked / closed milestones; #334, #371, #372 |
| 71 | lore | coverlet.runsettings Migrations exclusion (exit-137 OOM); #15, #373 |
| 72 | issue-work | #333, #344, #345 |
| 73 | lore | Gitea `?milestones=` filter no-ops on `:`/`+` names |
| 74 | issue-work | #70, #73, #77, #383–#386 |
| 75 | issue-work | #383–#386, #397 |
| 76 | issue-work | #72, #164, #401, #405, #414, #415, #417 |
| 77 | issue-work | #70, #376, #383, #385, #386, #419, #421 |
| 78 | issue-work | #176, #320, #385, #386, #423 |
| 79 | issue-work | #385, #386, #425, #428 |
| 80 | issue-work | #164, #385, #425, #430, #431 |
| 81 | issue-work | #386, #425, #432 |
| 82 | issue-work | #386, #425, #439 |
| 83 | issue-work | #308, #441; optimistic-concurrency.md |
| 84 | issue-work | #176, #293, #395, #425, #442 |
| 85 | issue-work | #363, #444, #445, #447 |
| 86 | issue-work | #350, #446 |
| 87 | issue-work | #381, #383, #395, #396, #448 |
| 88 | issue-work | #310, #327, #338, #367, #401, #409, #410, #447, #458 |
| 89 | issue-work | #350, #403, #404, #412, #431 |
| 90 | issue-work | #398, #420 (upload-artifact@v3 pin) |
| 91 | issue-work | #409, #458, #460 |
| 92 | issue-work | v26.10.0 release; #350, #461 (selector script) |
| 93 | issue-work | #404, #463, #464 |
| 94 | issue-work | #431, #469 |
| 95 | issue-work | #363, #444 |
| 96 | issue-work | #350, #472–#474, server-management#630 |
| 97 | lore | verify the emitter is deployed before trusting an empty log |
| 98 | issue-work | #350, #412, server-management#604 |
| 99 | issue-work | #476, #478 |
| 100 | issue-work | #478, server-management#631 |
| 101 | issue-work | #474, #477, #480, #484, #485 |
| 102 | issue-work | #350, #472 |
| 103 | issue-work | #381, #395, #480 |
| 104 | issue-work | #474, #487, #488, #491, #494 |
| 105 | issue-work | #494, #496 |
| 106 | issue-work | #497, #498, #500 (the raised-and-disproved candidate) |
| 107 | issue-work | #25, #67, #68, #502, #503 |
| 108 | issue-work | #350, #474, #487, #510, #511 |
| 109 | issue-work | #502, #510, #511 (+ the two lore facts swept to the handoff) |
| 110 | decision | retire #237 from startup; #520, #521, server-management#642 |
| 111 | lore | independent caps don't compose into a budget; #511 |
+11 -2
View File
@@ -18,7 +18,11 @@ from pathlib import Path
import scripts.decisions_lib as dl # noqa: E402 (run with PYTHONPATH=. or as module)
SKIP_HEADINGS = {"Index", "Active catalog", "Contents"}
REQUIRED_META = ("key", "status", "since", "supersedes", "superseded_by")
# `signals` is required alongside the lifecycle fields: the `**Signals:**` line (plus `key:`) is what
# MemPalace's keyword recall matches on when surfacing a decision, so a record without it ingests with
# weak recall metadata and produces confident false-negatives for the "MemPalace to find, file to
# confirm" workflow (ersatztv#545).
REQUIRED_META = ("key", "status", "since", "supersedes", "superseded_by", "signals")
EDIT_TOKEN = "[decisions-edit]" # noqa: S105 (a commit-message marker, not a credential)
@@ -291,7 +295,12 @@ def main(argv=None) -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--base")
ap.add_argument("--head")
ap.add_argument("--budget", type=int, default=4800) # aggregate; re-baselined post-#521 migration
# Aggregate active-corpus budget. Re-baselined 2026-07-21 (#542): 4800 -> 5600. The corpus grew
# by ~530 lines because 31 workflow/CI/review rules were MOVED into it from
# docs/handoffs/chicorytv-issue-queue.md, which was their only copy. That is the corpus doing its
# job, not drift — the knowledge existed already, it just wasn't retrievable. Do not raise this
# again to accommodate genuinely new records; that is what the warning is for.
ap.add_argument("--budget", type=int, default=5600)
args = ap.parse_args(argv)
records = dl.all_active_records()
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env bash
# H13 (ersatztv#541) — session-end: leave the SHARED checkout at /Users/timothy/ersatztv fresh, so
# the next session's kickoff paste is current.
#
# Why this is a script and not a rule. The standing lore already says the shared tree is not a `main`
# mirror and its HEAD must never be read as truth — but on 2026-07-21 the trap arrived through a
# route that rule doesn't cover: a session pasted `docs/handoffs/chicorytv-issue-queue.md` FROM that
# tree, which was 81 commits behind, and the file still described a queue protocol retired the day
# before (read tracker #237 — since closed, and now explicitly "do not read for queue state"). No
# `git log` was consulted, so no discipline check would have fired. A stale checkout silently
# serves stale FILES, and the only durable fix is to stop it being stale.
#
# What it does: fast-forwards the shared tree to origin/main, and reinstalls web/node_modules when
# the lockfile moved (worktrees copy that directory; a stale copy has broken typecheck before).
#
# What it will NOT do — it is deliberately timid, because the tree is shared and another session may
# be mid-flight in it. It refuses (loudly, exit 0) and leaves the WORKING TREE untouched when it is
# not on `main`, is dirty, has local commits, or is mid-rebase/merge. It never switches branches,
# never stashes, never discards. (A refusal reached after the fetch will have advanced
# refs/remotes/origin/main — remote-tracking refs only, never your files.) A refusal is a normal
# outcome, not a failure.
#
# Run at session end (kickoff session-end protocol). Safe to run at session start too — it is
# idempotent. Fail-OPEN: a missing/unreachable tree prints a notice and exits 0.
#
# ETV_SHARED_CHECKOUT overrides the target directory.
set -uo pipefail
shared="${ETV_SHARED_CHECKOUT:-/Users/timothy/ersatztv}"
skip() {
echo "refresh-shared-checkout: NO-OP — $1"
echo " (nothing was changed)"
exit 0
}
[ -d "$shared/.git" ] || skip "no git checkout at $shared"
cd "$shared" || skip "cannot cd to $shared"
# $shared/.git existing is NOT proof $shared is the repo root: if it is an invalid/emptied .git dir
# and some ancestor IS a repo, git walks UP and every command below would silently operate on that
# ancestor instead — the worst possible outcome for a script that fast-forwards and reinstalls.
toplevel="$(git rev-parse --show-toplevel 2>/dev/null)" || skip "not a git repo: $shared"
[ "$toplevel" = "$(pwd -P)" ] || skip "$shared is not a repo root (git resolves it to $toplevel)"
# mid-operation? never interfere
git_dir="$(git rev-parse --git-dir 2>/dev/null)" || skip "not a git repo: $shared"
# NOTE: keep the `|| true` — without it this loop's `&&` tail would abort the script if anyone
# ever adds `set -e` (a non-match returns 1 as the body's last status).
for state in rebase-merge rebase-apply MERGE_HEAD CHERRY_PICK_HEAD BISECT_LOG REVERT_HEAD; do
{ [ -e "$git_dir/$state" ] && skip "a $state is in progress"; } || true
done
branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null)"
[ "$branch" = "main" ] || skip "on branch '$branch', not main — that is another session's work"
if [ -n "$(git status --porcelain 2>/dev/null)" ]; then
skip "working tree is dirty (uncommitted changes)"
fi
git fetch origin main --quiet || skip "could not fetch origin/main"
ahead="$(git rev-list --count origin/main..HEAD 2>/dev/null)" || skip "cannot compare against origin/main"
behind="$(git rev-list --count HEAD..origin/main 2>/dev/null)" || skip "cannot compare against origin/main"
if [ "$ahead" != "0" ]; then
skip "$ahead local commit(s) not on origin/main — someone committed here; resolve by hand"
fi
if [ "$behind" = "0" ]; then
echo "refresh-shared-checkout: already current with origin/main ($(git rev-parse --short HEAD))."
exit 0
fi
lock_before=""
[ -f web/package-lock.json ] && lock_before="$(git hash-object web/package-lock.json 2>/dev/null)"
if ! git merge --ff-only origin/main --quiet; then
skip "fast-forward to origin/main failed"
fi
echo "refresh-shared-checkout: fast-forwarded $behind commit(s) to $(git rev-parse --short HEAD)."
lock_after=""
[ -f web/package-lock.json ] && lock_after="$(git hash-object web/package-lock.json 2>/dev/null)"
if [ -n "$lock_after" ] && [ "$lock_before" != "$lock_after" ]; then
echo "refresh-shared-checkout: web/package-lock.json changed — refreshing node_modules..."
# `npm ci`, not `npm install`: install can REWRITE the lockfile, which would leave this shared
# tree dirty — and a dirty tree is exactly what the next run (and every worktree-hygiene check)
# refuses on. ci installs strictly from the lockfile and never writes it.
# Accepted trade-off: `ci` DELETES node_modules first, so a session copying it at that instant gets
# a partial copy. Bounded and rare — everything removed is regenerable from the lockfile, no human
# work can be lost, and this only runs on a merge that actually moved the lockfile.
if (cd web && npm ci --no-audit --no-fund >/dev/null 2>&1); then
echo "refresh-shared-checkout: node_modules refreshed."
else
echo "refresh-shared-checkout: WARNING — npm ci failed; worktrees copying web/node_modules"
echo " from here may fail typecheck. Run 'npm ci' in $shared/web by hand."
fi
fi
# belt and braces: leaving the shared tree dirty is the one outcome that would make this script a
# net negative, so assert it and say so loudly rather than exiting quietly on a bad state
if [ -n "$(git status --porcelain 2>/dev/null)" ]; then
echo "refresh-shared-checkout: WARNING — the shared tree is DIRTY after refresh:"
git status --short | sed 's/^/ /'
echo " Nothing was discarded. Clean it by hand before the next session pastes files from here."
fi
+15
View File
@@ -15,6 +15,7 @@ def _rec(**kw: Any) -> dl.Record:
since="2026-01-01",
supersedes="none",
superseded_by="none",
signals="concept · paths: a/b.py · issues: #1",
)
base.update(kw)
return dl.Record(**base)
@@ -131,6 +132,20 @@ def test_missing_since_fails():
assert any("missing required metadata since" in e for e in _v([rec]))
def test_missing_signals_fails():
# `**Signals:**` is required (ersatztv#545): it is what MemPalace's keyword recall matches on, so
# a record without it ingests with weak metadata and under-surfaces ("no convention exists").
assert any("missing required metadata signals" in e for e in _v([_rec(key="a.b", signals=None)]))
def test_empty_signals_fails():
assert any("missing required metadata signals" in e for e in _v([_rec(key="a.b", signals="")]))
def test_signals_present_passes():
assert not any("signals" in e for e in _v([_rec(key="a.b", signals="concept · paths: x.py · issues: #1")]))
def test_demoted_heading_fails():
assert any(
"demoted to legacy-unmigrated" in e for e in _v([_rec(key="a.b")], demoted=["2026-01-01 — Some decision (#1)"])
+1
View File
@@ -11,6 +11,7 @@ export type BulkMoveChannelsToGroupRequest = components['schemas']['BulkMoveChan
export type BulkDeleteChannelsRequest = components['schemas']['BulkDeleteChannelsRequest'];
export type CreateChannelFromLineupRequest = components['schemas']['CreateChannelFromLineupRequest'];
export type CreateChannelFromLineupResponseModel = components['schemas']['CreateChannelFromLineupResponseModel'];
export type CreateChannelFromLineupClearField = components['schemas']['CreateChannelFromLineupClearField'];
export type CreateChannelRequest = components['schemas']['CreateChannelRequest'];
export interface ChannelsScreenData {
+2
View File
@@ -347,7 +347,9 @@ export interface components {
"shuffleScheduleItems"?: null | boolean;
"randomStartPoint"?: null | boolean;
"fixedStartTimeBehavior"?: null | components["schemas"]["FixedStartTimeBehavior"];
"clear"?: null | Array<components["schemas"]["CreateChannelFromLineupClearField"]>;
};
"CreateChannelFromLineupClearField": "Watermark" | "FallbackFiller" | "PreRollFiller" | "MidRollFiller" | "PostRollFiller" | "PreferredAudioLanguage" | "PreferredAudioTitle" | "PreferredSubtitleLanguage";
"CreateChannelFromLineupItemRequest": {
"mediaType": components["schemas"]["LibraryBrowseMediaType"];
"collectionType": components["schemas"]["CollectionType"];
+8 -9
View File
@@ -76,6 +76,7 @@ import {
} from '../components';
import {
ADVANCED_KEYS,
applyOverridesToRequest,
FIXED_START_TIME_BEHAVIORS,
IDLE_BEHAVIORS,
MUSIC_VIDEO_CREDITS_MODES,
@@ -940,15 +941,13 @@ function ChannelBuilder({
}
}
// advanced null/omitted = inherit template value (handler coalesces with ??);
// clear-to-none is not expressible — see #135. Overrides only ever holds
// real values (inherited fields are absent), so we copy present keys as-is.
const advanced: CreateChannelFromLineupRequest['advanced'] = { playbackOrder, playoutMode };
for (const key of ADVANCED_KEYS) {
if (key in overrides) {
(advanced as Record<string, unknown>)[key] = overrides[key];
}
}
// advanced null/omitted = inherit template value (handler coalesces with ??); a field set to
// the CLEAR sentinel is translated into advanced.clear (force to none) — see #135.
// applyOverridesToRequest copies present set-values and attaches `clear`.
const advanced: CreateChannelFromLineupRequest['advanced'] = applyOverridesToRequest(
{ playbackOrder, playoutMode },
overrides
);
const body: CreateChannelFromLineupRequest = {
name: trimmedName,
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import {
applyOverridesToRequest,
CLEAR,
collectClears,
effectiveValue,
type Overrides
} from './advancedOptions';
import type { ChannelTemplate } from '../api';
// Minimal template stub: only the fields the assertions read.
const template = {
watermarkId: 21,
preRollFillerId: 2,
preferredAudioLanguageCode: 'eng'
} as unknown as ChannelTemplate;
describe('advancedOptions clear-to-none (#135)', () => {
it('effectiveValue reads a CLEAR override as none, an absent key as the template value', () => {
const overrides: Overrides = { watermarkId: CLEAR, preRollFillerId: 7 };
// Cleared -> null (renders "None").
expect(effectiveValue('watermarkId', template, overrides)).toBeNull();
// Set -> the override value.
expect(effectiveValue('preRollFillerId', template, overrides)).toBe(7);
// Absent -> inherits the template value.
expect(effectiveValue('preferredAudioLanguageCode', template, overrides)).toBe('eng');
});
it('collectClears maps CLEAR-valued clearable keys to their backend enum values', () => {
const overrides: Overrides = {
watermarkId: CLEAR,
fallbackFillerId: CLEAR,
preferredAudioLanguageCode: CLEAR,
preRollFillerId: 7
};
expect(collectClears(overrides).sort()).toEqual(
['FallbackFiller', 'PreferredAudioLanguage', 'Watermark'].sort()
);
});
it('applyOverridesToRequest copies set values and folds CLEAR into advanced.clear (never as a value)', () => {
const overrides: Overrides = { watermarkId: CLEAR, preRollFillerId: 7, midRollFillerId: 3 };
const advanced = applyOverridesToRequest({ playbackOrder: 'Shuffle' as const }, overrides);
// Set values copied through.
expect(advanced).toMatchObject({ playbackOrder: 'Shuffle', preRollFillerId: 7, midRollFillerId: 3 });
// The CLEAR sentinel never leaks onto the wire as a field value.
expect('watermarkId' in advanced).toBe(false);
// It becomes a clear-list entry instead.
expect((advanced as { clear?: string[] }).clear).toEqual(['Watermark']);
});
it('applyOverridesToRequest omits the clear key entirely when nothing is cleared', () => {
const advanced = applyOverridesToRequest({ playbackOrder: 'Chronological' as const }, { preRollFillerId: 7 });
expect('clear' in advanced).toBe(false);
});
it('a non-clearable key holding the CLEAR sentinel is copied through, never silently dropped', () => {
// Only reachable if a user literally typed the sentinel into a non-clearable text input.
const advanced = applyOverridesToRequest({}, { streamSelector: CLEAR });
expect((advanced as { streamSelector?: unknown }).streamSelector).toBe(CLEAR);
expect('clear' in advanced).toBe(false);
});
});
+84 -18
View File
@@ -2,13 +2,19 @@
// a channel template's baseline, used by BOTH the manual Channel Builder
// (ChannelBuilder.tsx) and the Auto-Tune per-channel DetailPanel (AutoTuneScreen.tsx).
//
// The tricky, bug-prone part is the INHERIT/omit contract (see #135): a field left
// on "Inherit from template" is OMITTED from the request entirely (not sent as null),
// because the create handler coalesces a missing value with the template's value and
// the API cannot express "clear to none". Both consumers MUST honour that, so it lives
// here once. Consumers render their own field JSX; only the model + adapters are shared.
// The tricky, bug-prone part is the INHERIT/omit vs CLEAR contract (see #135). Three states
// per advanced field:
// - INHERIT: the field is OMITTED from the request; the create handler coalesces the missing
// value with the template's value (the historical default for every existing client).
// - a set value: sent as-is, overriding the template.
// - CLEAR ("None"): the field is added to `advanced.clear`, forcing it to none on the new
// channel even when the template sets one. Only the id fields (watermark + the four fillers)
// expose a "None" option in the SPA today; the backend `clear` enum also covers the preferred
// audio/subtitle language strings for machine clients (API-ahead-of-UI — see spa-conventions).
// Both consumers MUST honour that, so it lives here once. Consumers render their own field JSX;
// only the model + adapters are shared.
import type { ChannelTemplate } from '../api';
import type { ChannelTemplate, CreateChannelFromLineupClearField } from '../api';
// ---- Enum unions (hand-listed from generated v1.d.ts; keep in sync) --------
export const PLAYBACK_ORDERS = [
@@ -93,19 +99,74 @@ export type AdvancedKey = (typeof ADVANCED_KEYS)[number];
export type Overrides = Partial<Record<AdvancedKey, unknown>>;
// The current effective value for an advanced field = user override (if any)
// else the selected template's value.
// Sentinel select value meaning "inherit from the template" — the field is
// omitted from `advanced` entirely (not sent as null). See #135.
export const INHERIT = '__inherit__';
// Sentinel override value meaning "clear to none" — the field is added to the request's
// `advanced.clear` list, forcing it to none even when the template sets one (#135). Stored in the
// overrides map (a clear IS a divergence from the template) and translated at request-build time.
export const CLEAR = '__clear__';
// AdvancedKeys whose "None" maps to a backend clear-field. Only these can be cleared; every other
// key set to CLEAR is ignored by collectClears. The id keys drive the SPA "None" option today; the
// string keys are covered so a machine client (MCP) can clear them via the same enum.
export const CLEARABLE_FIELDS: Partial<Record<AdvancedKey, CreateChannelFromLineupClearField>> = {
watermarkId: 'Watermark',
fallbackFillerId: 'FallbackFiller',
preRollFillerId: 'PreRollFiller',
midRollFillerId: 'MidRollFiller',
postRollFillerId: 'PostRollFiller',
preferredAudioLanguageCode: 'PreferredAudioLanguage',
preferredAudioTitle: 'PreferredAudioTitle',
preferredSubtitleLanguageCode: 'PreferredSubtitleLanguage'
};
// The current effective value for an advanced field = user override (if any) else the selected
// template's value. A CLEAR override reads as none (null), so pickers/previews render "None".
export function effectiveValue(key: AdvancedKey, template: ChannelTemplate, overrides: Overrides): unknown {
if (key in overrides) {
return overrides[key];
return overrides[key] === CLEAR ? null : overrides[key];
}
return (template as unknown as Record<string, unknown>)[key];
}
// Sentinel select value meaning "inherit from the template" — the field is
// omitted from `advanced` entirely (not sent as null). The API cannot express
// clear-to-none, so there is no "None" choice; see #135.
export const INHERIT = '__inherit__';
// The backend clear-field enum values for every key set to CLEAR in the overrides map.
export function collectClears(overrides: Overrides): CreateChannelFromLineupClearField[] {
const clears: CreateChannelFromLineupClearField[] = [];
for (const key of ADVANCED_KEYS) {
if (overrides[key] === CLEAR) {
const field = CLEARABLE_FIELDS[key];
if (field) {
clears.push(field);
}
}
}
return clears;
}
// Fold an overrides map into an `advanced` request object: copy every set (non-CLEAR) override, then
// attach `clear` for any CLEAR-valued key. Shared by both consumers so the CLEAR sentinel never leaks
// onto the wire as a field value. `base` carries consumer-specific extras (playbackOrder/playoutMode).
export function applyOverridesToRequest<T extends Record<string, unknown>>(base: T, overrides: Overrides): T {
for (const key of ADVANCED_KEYS) {
if (!(key in overrides)) {
continue;
}
// A CLEAR sentinel is folded into `clear` below, but only for clearable fields. Any other key
// holding the sentinel is only reachable if a user literally typed it into a text input — copy
// it through as its value rather than silently dropping the field from the request.
if (overrides[key] === CLEAR && key in CLEARABLE_FIELDS) {
continue;
}
(base as Record<string, unknown>)[key] = overrides[key];
}
const clear = collectClears(overrides);
if (clear.length > 0) {
(base as Record<string, unknown>).clear = clear;
}
return base;
}
// Templates carry no playbackOrder; the Shuffle toggle *is* the template's
// declared shuffle intent (template.shuffleScheduleItems -> Shuffle/Chronological).
@@ -146,25 +207,30 @@ export function useAdvancedOverrides(
const templateValueOf = (key: AdvancedKey): unknown => (template as unknown as Record<string, unknown>)[key];
// Override selects: the first option is always "Inherit from template" (the
// INHERIT sentinel -> field omitted from the request). There is deliberately
// NO "None" option for filler/watermark ids: the create handler coalesces
// null with the template value, so clear-to-none is not expressible (#135).
// Override selects: the first option is always "Inherit from template" (the INHERIT sentinel ->
// field omitted from the request), followed by an explicit "None" (the CLEAR sentinel -> field
// added to advanced.clear, forcing it to none even when the template sets one) for clearable
// fields, then the concrete rows. "None" is distinct from "Inherit": inherit keeps the template
// value, None drops it (#135).
const idSelectOptions = (key: AdvancedKey, rows: Array<{ id: number; name: string | null }>) => {
const inherited = templateValueOf(key) as number | null;
return [
inheritOption(inherited == null ? null : fillerName(rows, inherited)),
...(key in CLEARABLE_FIELDS ? [{ value: CLEAR, label: 'None' }] : []),
...rows.map((row) => ({ value: String(row.id), label: row.name ?? `#${row.id}` }))
];
};
// Selected value for an override select: the override when present, else INHERIT.
// Selected value for an override select: the override when present (CLEAR renders as the "None"
// option since String(CLEAR) === CLEAR), else INHERIT.
const selValue = (key: AdvancedKey): string => (key in overrides ? String(overrides[key]) : INHERIT);
const onSelect =
(key: AdvancedKey, map: (raw: string) => unknown) => (event: React.ChangeEvent<HTMLSelectElement>) => {
const raw = event.target.value;
if (raw === INHERIT) {
removeOverride(key);
} else if (raw === CLEAR) {
setOverride(key, CLEAR);
} else {
setOverride(key, map(raw));
}
+6 -3
View File
@@ -48,6 +48,7 @@ import {
} from '../api';
import { registerNavigationGuard } from '../navigationGuard';
import {
applyOverridesToRequest,
FIXED_START_TIME_BEHAVIORS,
IDLE_BEHAVIORS,
MUSIC_VIDEO_CREDITS_MODES,
@@ -325,9 +326,11 @@ export function AutoTuneScreen() {
}
channel.logo = logo;
}
const advanced: Overrides & { playbackOrder?: PlaybackOrder; playoutMode?: PlayoutMode } = {
...(override?.advanced ?? {})
};
// applyOverridesToRequest copies set overrides and folds any CLEAR sentinels into
// advanced.clear, so the sentinel never leaks onto the wire as a field value (#135).
const advanced = applyOverridesToRequest<
Overrides & { playbackOrder?: PlaybackOrder; playoutMode?: PlayoutMode }
>({}, override?.advanced ?? {});
if (override?.playbackOrder != null) {
advanced.playbackOrder = override.playbackOrder;
}