Compare commits

..
Author SHA1 Message Date
timothy 1ef581403c fix(609): close round-5 test gaps and a prose misattribution
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 14s
PR Gates / decisions lifecycle (pull_request) Successful in 19s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m23s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 29s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m20s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m10s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21m57s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Fable's round-5 review ran the 16 tmp_path tests that no prior round could
execute (16/16 pass) and mutation-tested every fix. Six of seven reverts were
killed; one survived, which is finding 1.

1. The exact-arity refusal in `_token_armed` had ZERO coverage -- deleting it
   passed all 55 tests, because nothing fed malformed git-log output to that
   function. Now pinned by a test that stubs `_run` with 2-field and 4-field
   output and asserts refusal, plus a 3-field control proving the refusal is
   about arity rather than the token. Verified the new test kills the mutation.

2. `test_integration_separator_in_subject_cannot_inject` did not actually pin
   the NUL framing: with `\x1f` framing restored and the arity check kept, it
   still passed, because one or two injected separators break arity and get
   absorbed. Added a case with THREE separators, which restores a multiple-of-3
   arity and would false-arm under that revert -- so it pins the framing itself.

3. The decision record attributed the "old git echoes the trailers atom" case to
   the arity check. Wrong: an echoed atom is one well-formed field, so arity
   cannot catch it -- that case is handled by the `git --version` capability
   probe. Corrected in the record.

Not changed: review also noted subject matching is now case-insensitive, so
`[DECISIONS-EDIT]` arms where the old substring check was case-sensitive.
Deliberate and harmless -- arming still requires typing the token.
2026-07-25 18:25:26 +02:00
timothy 990d32f31a fix(609): anchor and bound the git version probe
Both round-4 findings, both in the version regex introduced in round 3. Both
reproduced against the old code and confirmed closed.

1. The regex was UNANCHORED, so the first dotted number anywhere in the output
   won. `wrapper 2026.1; git version 2.20.1` read as 2026.1 -> True, enabling
   trailer parsing on a git that cannot expand the atom, whose verbatim echo then
   reads as a non-empty trailer and FALSELY ARMS. Now anchored to the canonical
   `git version X.Y` prefix.

2. Digits were unbounded, so a pathological version string raised ValueError
   instead of returning the documented safe False -- Python refuses int()
   conversion of a literal over 4300 digits. Digits are now bounded to 5 each,
   plus a try/except that the bounded regex should make unreachable.

Adds seven probe cases: the wrapper-prefix and multiline-shim strings, Apple git,
an rc suffix, a three-digit major, and the 5000-digit pathological input.
2026-07-25 18:25:26 +02:00
timothy 851dca2596 fix(609): close round-3 review findings
All four LOW; no HIGH remained. The subject_of fix from round 2 was confirmed
correct across every message shape and all 38 historical commits.

1. The old-git compat check was a VALUE sentinel: it blanked any trailer whose
   value happened to equal the atom string, so a legitimate
   `Decisions-Edit: %(trailers:key=Decisions-Edit,valueonly)` was silently
   discarded. Replaced with a capability probe on `git --version` (>= 2.22).
   Detecting by version instead of by sniffing output removes the collision
   class entirely rather than narrowing it. Unknown/unparseable version resolves
   to False -- trailers ignored, subject-only matching -- which is the safe
   direction: a trailer-only token not arming is an annoyance, whereas reading an
   unexpanded atom as a value would falsely arm and disable the guard.

2. The compat test never called `_token_armed`, so it pinned nothing -- deleting
   the guard would have left it green. Replaced with three tests that drive the
   real function through a stubbed `_run`, covering old git (trailers ignored),
   modern git (trailer arms), a tokened subject surviving an unusable trailer,
   and version-string parsing incl. unparseable input. Proven non-vacuous:
   forcing the probe True makes the old-git test fail.

3. `_repo()` still ignored return codes from init/config/base-commit and never
   checked that the base sha resolved, so a rejected base could leave it
   returning ("", <root sha>) and negative range tests would pass vacuously. All
   commands are now checked and the base sha is asserted to be a full 40 chars.

4. docs/decisions.md line 65 still said "append it, as every prior use does".
   37 of 38 append; docs(434) is mid-subject.
2026-07-25 18:25:26 +02:00
timothy aa4a8fb849 fix(609): close round-2 review findings
HIGH -- `subject_of` used `lstrip("\n")`, so it returned the first NON-EMPTY
line. `git commit --cleanup=verbatim` accepts a message that begins with a blank
line and `%B` returns it raw, so body prose on line 2 was promoted to "subject"
and armed the token. Now literally line 1: an empty first line yields "", which
arms nothing -- failing toward the guard running.

LOW -- the old-git compat guard was a PREFIX match (`startswith("%(trailers")`)
that also `continue`d before the subject was evaluated. So a legitimate trailer
value beginning with that text was discarded, and worse, a perfectly good tokened
SUBJECT was thrown away because of its trailer field. Now an exact match against
the full atom, neutralising only the trailer and leaving the subject honoured.

LOW -- docstrings still said every historical use "appends" the token. Of the 38
uses, 37 append and `docs(434)` is mid-subject.

LOW (plausible) -- the `_repo` test helper ignored every git return code, so a
rejected commit would leave HEAD at base and every negative assertion would pass
vacuously. Return codes are now checked and HEAD is asserted to have moved.

Adds a regression test for the verbatim leading-blank-line case and one pinning
the compat guard to an exact atom match.
2026-07-25 18:25:26 +02:00
timothy 243bec708d fix(609): close two false-arm holes found in cross-family review
Codex review of the first attempt found both, and both were in the git plumbing
that my unit tests never touched -- they only exercised the pure predicate.

1. HIGH: git's `%s` is the first PARAGRAPH, not the first line. It joins
   consecutive non-blank lines with spaces, so
     `fix: harmless subject`
     `This explains [decisions-edit] on line two.`
   came back as ONE line containing the token and armed it -- the exact
   false-arm this change exists to prevent. The first line is now taken from
   `%B` via `subject_of()`.

2. HIGH: the in-band `\x1f`/`\x1e` field separators were injectable. A subject
   containing a literal `\x1f` was split at the wrong place and its tail read as
   a trailer, arming the token. Framing is now NUL, which git forbids inside a
   commit message and which therefore cannot be injected, with exact-arity
   parsing (fields must be a multiple of three) that refuses to arm otherwise.

Also from the same review:
- Refuse to arm on a `%(trailers:...)` atom echoed literally by a git older than
  2.22, which would otherwise read as a non-empty trailer (exit 0, so `_run`
  returns it rather than None).
- Record corrected: 38 subject-tokened commits in ancestry, not "twenty"; and it
  no longer claims a blanket fail-safe -- `_token_armed` failing is safe, but the
  surrounding `_diff_findings` fails open earlier on an unresolvable merge-base,
  skipping every check. That predates this change.

Adds 8 integration tests that drive `_token_armed` against a real throwaway git
repo -- the gap that let both defects pass. Verified non-vacuous by
reconstructing the old implementation in memory: it arms on both inputs, the new
one does not.

Note `--format` uses git's `%x00` escape, not a literal NUL: a NUL in argv raises
ValueError from subprocess, which broke every diff-engine test until fixed.
2026-07-25 18:25:26 +02:00
timothy 4596603020 fix(609): scope the decisions edit token to the subject line or a trailer
The token was armed by a bare substring match over every commit message in the
range, so a commit that merely DESCRIBED the mechanism armed it and skipped the
entire `if not token:` block -- all three rationale-rewrite comparisons (active
survivors, active->archive laundering, archive survivors). `removed` and `demoted`
still ran, so the job printed `decisions-validate: OK` while doing nothing. It
bit in PR#605, which had hand-resolved an append-vs-append conflict inside
docs/decisions.md -- precisely the operation the guard exists to police.

Now recognized in exactly two places:
  * the commit SUBJECT line -- the established form. All twenty prior tokened
    commits append it to the subject (or place it mid-subject, as docs(434)
    does); none put it on its own line, so the obvious "own-line only" rule
    would have broken every historical use.
  * a `Decisions-Edit: <reason>` git trailer -- the forward-looking form, which
    can carry a reason the bracketed marker cannot.

Fail-open posture unchanged: unresolvable git means the token reads unarmed, so
the guard still runs.

Verified by measuring the guard rather than reading a green check -- a positive
control over the real corpus across all three placements: no token fires (exit 1),
subject token suppresses (exit 0), body-only mention fires (exit 1). Plus an
end-to-end matcher test against a throwaway git repo covering the established
form, mid-subject placement, the trailer, a merge commit quoting a tokened PR
title, a multi-commit range, and an unresolvable ref.

fixes #609
2026-07-25 18:25:26 +02:00
235 changed files with 6437 additions and 26572 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# ersatztv#521 — the line-level append-only mechanic is retired. Decision integrity is now enforced by
# the lifecycle validator. A `Decisions-Edit: yes` trailer survives ONLY for rationale-prose edits (validator
# the lifecycle validator. `[decisions-edit]` survives ONLY for rationale-prose edits (validator
# body-diff, CI). This shim runs the structural validator over the working tree; the body-diff/no-
# vanish checks run in CI where a base/head is available. Fail-open on any tooling trouble.
set -uo pipefail
+46 -148
View File
@@ -7,14 +7,6 @@
# (c) a review-verdict comment on the PR references the CURRENT head sha (H10) — proving the
# LATEST commit was reviewed, not a stale earlier diff (the ersatztv#242 failure mode:
# "re-review the fix commit, not just the initial PR diff").
#
# EVERY ONE OF THOSE IS A SNAPSHOT, taken when the merge tool is called. That is sound for an
# immediate merge and UNSOUND for a scheduled one: with merge_when_checks_succeed, Gitea merges
# later, against whatever head is green then (ersatztv#622). So the sha-bound half of H10 is
# enforced by the SERVER, not here — `review-verdict/h10` is a required status check on `main`,
# written per-sha by scripts/post-review-verdict.sh, and a new commit cannot inherit it. This hook
# additionally refuses to SCHEDULE an auto-merge unless that status is already green on head, so the
# two mechanisms agree at the only moment they can both observe the same commit.
# The "## Done-when" issue-body checklist is the convention (docs/decisions.md, CLAUDE.md Task
# Completion Protocol). One box is "adversarial review passed"; the others are per-issue.
# The H10 review-verdict convention: after reviewing a PR (or its latest fix commit), post a PR
@@ -85,41 +77,8 @@ sha=$(printf '%s' "$prjson" | jq -r '.head.sha // ""' 2>/dev/null || true)
body=$(printf '%s' "$prjson" | jq -r '.body // ""' 2>/dev/null || true)
# --- Docs-only exemption: if every changed file is docs/process, skip the gate. ---
# The file list must be enumerated EXHAUSTIVELY or the exemption is unsafe. Gitea caps this
# endpoint at 50 rows per page and silently ignores a larger `limit` (verified: PR #619 has 194
# changed files and `?limit=100` returns exactly 50), so the previous single-page read could see 50
# docs files, miss the code in positions 51+, and exempt a PR that is not remotely docs-only.
# Page until a short page proves the end; anything else leaves `files_complete=no`, which withholds
# the exemption and falls through to the full gate (ersatztv#622).
files=""; files_complete=no; page=1
while [ "$page" -le 40 ]; do
raw=$(gq "repos/$owner/$repo/pulls/$pr/files?limit=50&page=$page")
# A transport/parse failure must not look like a legitimate short final page: `gq` returns empty
# on any error, which counts as zero rows and would set files_complete=yes over a PARTIAL list —
# failing OPEN into the exemption.
#
# Checking only the top-level type leaves the same hole one level down: `[{}]` is a valid array
# whose rows carry no `filename`, so it yields no paths, looks like a short page, and completes
# the enumeration from a partial list. Require every row to carry a non-empty string `filename`
# (an empty array is still valid — that is a genuine end-of-pagination). This also rejects arrays
# of scalars, which would otherwise make the `.filename` extraction below fail under `set -e`.
if ! printf '%s' "$raw" \
| jq -e 'type == "array" and all(.[]; (.filename | type == "string" and length > 0) and (if .status == "renamed" then (.previous_filename | type == "string" and length > 0) else true end))' \
>/dev/null 2>&1; then
files_complete=no; break
fi
# BOTH sides of a rename: Gitea reports a `git mv` as ONE row whose `filename` is the DESTINATION,
# with the source in `previous_filename`. Reading only `filename` would let a PR move code into
# docs/ and claim the docs-only exemption. Page size is measured in ROWS, not paths — one renamed
# row is one row but two paths.
n=$(printf '%s' "$raw" | jq -r 'length')
chunk=$(printf '%s' "$raw" | jq -r '.[] | (.filename // empty), (.previous_filename // empty)')
[ -n "$chunk" ] && files=$(printf '%s\n%s' "$files" "$chunk")
if [ "$n" -lt 50 ]; then files_complete=yes; break; fi
page=$((page + 1))
done
files=$(printf '%s\n' "$files" | grep -v '^$' || true)
if [ "$files_complete" = yes ] && [ -n "$files" ] && ! printf '%s\n' "$files" | grep -qvE '^(docs/|\.claude/|\.husky/|\.gitea/|.*\.md$)'; then
files=$(gq "repos/$owner/$repo/pulls/$pr/files?limit=100" | jq -r '.[].filename // empty' 2>/dev/null || true)
if [ -n "$files" ] && ! printf '%s\n' "$files" | grep -qvE '^(docs/|\.claude/|\.husky/|\.gitea/|.*\.md$)'; then
# Docs/process-only PR: the Done-when + review-verdict gate doesn't apply — but this exemption is a
# file-TYPE bypass, NOT the a+b+c "provably reviewed & ready" proof, so it does NOT auto-grant. It
# passes through to normal permissioning (one prompt). This deliberately keeps a human in the loop for
@@ -154,71 +113,11 @@ done
# --- (a) CI combined status must be green (unless deferring to Gitea's own check-gate). ---
if [ "$mwcs" != "true" ]; then
[ -n "$sha" ] || decide ask "H6 merge gate: could not resolve PR #$pr head sha to check CI. Verify CI is green before merging."
cistatus=$(gq "repos/$owner/$repo/commits/$sha/status?limit=100")
state=$(printf '%s' "$cistatus" | jq -r '.state // ""' 2>/dev/null || true)
state=$(gq "repos/$owner/$repo/commits/$sha/status" | jq -r '.state // ""' 2>/dev/null || true)
case "$state" in
success) : ;;
"") decide ask "H6 merge gate: could not read CI status for PR #$pr ($sha). Verify CI is green before merging." ;;
*)
# `review-verdict/h10` is itself one of the contexts folded into the COMBINED state, so a PR
# awaiting its verdict reports combined 'pending' and would otherwise be reported as a CI
# problem — sending the reader to build logs when the missing thing is the review. Name the
# real blocker when the verdict is the only thing outstanding.
#
# "Not green" is anything that is not `success`, NOT just pending/failure: Gitea also has
# `error` (and `warning`), and omitting those would let an errored build hide behind the
# verdict and produce the flatly false claim "every CI check is green". `skipped` IS treated
# as green — the image-push job skips on every PR (ersatztv#593: a skipped context is not red).
nongreen=$(printf '%s' "$cistatus" \
| jq -r '[.statuses[]? | select(.status != "success" and .status != "skipped")]
| map("\(.context)=\(.status)") | join(", ")' 2>/dev/null || true)
# The verdict's OWN state decides the wording: absent/pending means nobody has reviewed this
# head, while failure/error means someone reviewed it and said no. Telling a reviewer to "post
# a verdict" when they already posted a BLOCKED one would be actively misleading.
vonly=$(printf '%s' "$cistatus" \
| jq -r '[.statuses[]? | select(.status != "success" and .status != "skipped")]
| if (length == 1 and .[0].context == "review-verdict/h10") then .[0].status else "" end' 2>/dev/null || true)
case "$vonly" in
pending)
decide deny "H6/H10 merge gate: BLOCKED — every CI check on PR #$pr is green; the only outstanding context is 'review-verdict/h10' on head ${sha:0:7}, i.e. this head has no review verdict yet. Review it and run: scripts/post-review-verdict.sh $pr MERGEABLE" ;;
failure|error)
decide deny "H6/H10 merge gate: BLOCKED — every CI check on PR #$pr is green, but 'review-verdict/h10' is '$vonly' on head ${sha:0:7}: this head was reviewed and REJECTED. Resolve the findings, then run: scripts/post-review-verdict.sh $pr MERGEABLE" ;;
esac
decide deny "H6 merge gate: BLOCKED — PR #$pr CI status is '$state', not 'success' (not green: ${nongreen:-unknown}). Wait for a green build (or pass merge_when_checks_succeed to let Gitea gate it) before merging."
;;
esac
else
# --- SCHEDULED auto-merge: everything this hook proves is a SNAPSHOT (ersatztv#622). ----------
# With merge_when_checks_succeed, Gitea performs the merge later, against whatever head is green
# at THAT moment — but (b) and (c) below are evaluated against the head that exists right now.
# Any commit pushed in between would merge with no verdict covering it. Demonstrated as a
# controlled A/B (#622): with a slow CI check pending so Gitea waits, an unreviewed commit pushed
# after scheduling MERGED without the required verdict context and was REFUSED with it.
#
# The durable fix is server-side and lives outside this hook: `review-verdict/h10` is a REQUIRED
# status check on `main`, and a commit status belongs to exactly ONE sha, so a later commit cannot
# inherit it and Gitea's own gate refuses to merge until that head is re-reviewed.
#
# What we add HERE is the matching precondition at SCHEDULING time: refuse to arm an auto-merge
# unless the sha-bound status already exists on this head. Checking the comment alone (condition
# (c) below) is not enough for this path — the comment is what a human reads, the status is what
# the server enforces, and only the latter survives a new push. Deny rather than ask: the remedy
# is a single documented command, so there is nothing here for a human to adjudicate.
[ -n "$sha" ] || decide ask "H6 merge gate: could not resolve PR #$pr head sha to check the review-verdict status. Verify the review covered the latest commit before scheduling an auto-merge."
# Read the COMBINED endpoint, not `/statuses/{sha}`: the latter returns one row per status POST
# rather than per context and pages at 50, so a head with a few CI reruns can push the verdict off
# the first page and read as absent — a confusing false deny. The combined endpoint returns
# latest-per-context, which is exactly the question being asked.
vjson=$(gq "repos/$owner/$repo/commits/$sha/status?limit=100")
if ! printf '%s' "$vjson" | jq -e '.statuses | type == "array"' >/dev/null 2>&1; then
decide ask "H6/H10 merge gate: could not read the 'review-verdict/h10' status for PR #$pr head ${sha:0:7} (Gitea unreachable or an unexpected response). Confirm the current head is reviewed before scheduling an auto-merge."
fi
vstate=$(printf '%s' "$vjson" | jq -r '[.statuses[] | select(.context == "review-verdict/h10")] | first | .status // ""')
case "$vstate" in
success) : ;;
"") decide deny "H6/H10 merge gate: BLOCKED — PR #$pr has no 'review-verdict/h10' commit status on head ${sha:0:7}, so scheduling an auto-merge would freeze consent at a head Gitea may not be the one to merge (ersatztv#622). Review the current head and run: scripts/post-review-verdict.sh $pr MERGEABLE" ;;
pending) decide deny "H6/H10 merge gate: BLOCKED — 'review-verdict/h10' is still pending on PR #$pr head ${sha:0:7} (no verdict posted for this commit yet). Review the current head and run: scripts/post-review-verdict.sh $pr MERGEABLE" ;;
*) decide deny "H6/H10 merge gate: BLOCKED — 'review-verdict/h10' is '$vstate' on PR #$pr head ${sha:0:7}. Resolve the findings, then run: scripts/post-review-verdict.sh $pr MERGEABLE" ;;
*) decide deny "H6 merge gate: BLOCKED — PR #$pr CI status is '$state', not 'success'. Wait for a green build (or pass merge_when_checks_succeed to let Gitea gate it) before merging." ;;
esac
fi
@@ -233,53 +132,52 @@ comments=$(gq "repos/$owner/$repo/issues/$pr/comments?limit=100")
if [ -z "$comments" ]; then
decide ask "H10 merge gate: could not fetch PR #$pr comments to verify a head-referencing review verdict ($short). Confirm the adversarial/Codex review covered the latest commit before merging."
fi
# Classification is delegated to `scripts/check-review-verdict.sh` — the single source of truth for
# the H10 grammar, extracted in #629 so it could be TESTED. While it lived here it had none, and three
# false-opens survived in it: a prefix-matched token (`MERGEABLE-LATER` graded positive), a verdict
# inside a fenced code block (documentation showing the convention counted as a real verdict), and a
# sha taken from the first `@<hex>` anywhere on the line (a markdown link could supply it). Every
# decision the classifier makes is documented there; this file only maps a class onto a hook decision.
verdict_script="${CLAUDE_PROJECT_DIR:-.}/scripts/check-review-verdict.sh"
if [ ! -x "$verdict_script" ]; then
decide ask "H10 merge gate: verdict classifier not found at $verdict_script, so the review state can't be derived. Confirm the review covered the latest commit before merging."
# Verdict lines across all comment bodies: a real verdict line STARTS with the marker (after optional
# leading whitespace). Anchoring to line-start is deliberate — it rejects a comment that merely QUOTES
# the positive template mid-sentence (an instruction "please post: Review-verdict: MERGEABLE @ <sha>",
# or the gate's own suggestion text echoed back), which would otherwise self-approve the merge.
verdicts=$(printf '%s' "$comments" | jq -r '.[].body // empty' 2>/dev/null | grep -iE '^[[:space:]]*review-verdict:' || true)
if [ -z "$verdicts" ]; then
decide ask "H10 merge gate: no 'Review-verdict:' comment found on PR #$pr referencing head $short. Post the adversarial/Codex verdict (e.g. 'Review-verdict: MERGEABLE @ $short'), or confirm the review covered the latest commit and approve."
fi
# An input error (exit 2) is NOT a classification — fall through to a human rather than guessing.
if ! class=$(printf '%s' "$comments" | "$verdict_script" --head "$sha" 2>/dev/null); then
decide ask "H10 merge gate: could not classify the review verdicts on PR #$pr (malformed comments payload or unreadable head). Confirm the review covered the latest commit ($short) before merging."
fi
case "$class" in
negative)
# A negative verdict on head wins over a positive one (a later BLOCKED retracts an earlier
# MERGEABLE on the SAME head; if the head were fixed the sha would change, so this can't
# wrongly block).
decide deny "H10 merge gate: BLOCKED — a review verdict for the current head ($short) is negative (BLOCKED/NOT-MERGEABLE). Resolve the findings and post a fresh 'Review-verdict: MERGEABLE @ $short' before merging PR #$pr." ;;
stale)
decide deny "H10 merge gate: BLOCKED — a review-verdict comment references an older commit, not the current head ($short). The latest commit(s) are unreviewed (ersatztv#242: re-review the fix commit, not just the initial diff). Re-review the head and post 'Review-verdict: MERGEABLE @ $short'." ;;
unknown)
decide ask "H10 merge gate: a 'Review-verdict:' comment on PR #$pr uses an unrecognized verdict token (not MERGEABLE/APPROVED/LGTM/BLOCKED/NOT-MERGEABLE). It is deliberately NOT read as approval. Post a verdict using the documented vocabulary — e.g. 'Review-verdict: MERGEABLE @ $short'." ;;
no-sha)
# Marker(s) exist but reference no sha at all -> ask (don't mislabel as a stale older-commit review).
decide ask "H10 merge gate: a 'Review-verdict:' comment on PR #$pr references no commit sha in its own '@ <sha>' field. Post one referencing the current head ($short) — e.g. 'Review-verdict: MERGEABLE @ $short' — or confirm the review covered the latest commit and approve." ;;
absent)
decide ask "H10 merge gate: no 'Review-verdict:' comment found on PR #$pr referencing head $short. Post the adversarial/Codex verdict (e.g. 'Review-verdict: MERGEABLE @ $short'), or confirm the review covered the latest commit and approve." ;;
positive) : ;;
*)
decide ask "H10 merge gate: unrecognized verdict classification '$class' for PR #$pr. Confirm the review covered the latest commit ($short) before merging." ;;
# Classify each verdict line by the sha it references (its "@ <sha>" field) and its verdict word.
# A line references the CURRENT head iff head BEGINS WITH that sha token AND the token is >=7 chars
# (git short-sha prefix semantics) — NOT a loose substring test: an older sha that merely contains
# the head prefix, or the head prefix appearing in an unrelated URL on the line, must NOT count
# (adversarial false-opens). The verdict token must sit right after the marker on the same line.
head_pos=0; head_neg=0; stale=0
while IFS= read -r line; do
[ -n "$line" ] || continue
# The sha the line references: the hex token in its "@ <sha>" field (>=7 chars), lowercased.
ref=$(printf '%s' "$line" | grep -ioE '@[[:space:]]*[0-9a-f]{7,40}' | head -1 \
| grep -oiE '[0-9a-f]{7,40}' | tr 'A-F' 'a-f' || true)
is_pos=0
# Positive iff the line's OWN leading verdict word (right after the line-start marker) is positive —
# anchored so a second, later `review-verdict: mergeable` substring on a BLOCKED line can't flip it.
if printf '%s' "$line" | grep -iqE '^[[:space:]]*review-verdict:[[:space:]]*(mergeable|approved|lgtm)'; then is_pos=1; fi
[ -z "$ref" ] && continue # marker present but no @<sha> -> falls through to the final ask
case "$sha" in
"$ref"*) if [ "$is_pos" = 1 ]; then head_pos=1; else head_neg=1; fi ;;
*) stale=1 ;;
esac
done <<VERDICTS
$verdicts
VERDICTS
if [ "$class" = "positive" ]; then
# (a) CI + (b) all Done-when ticked + (c) positive verdict @ current head -> SATISFIED. Auto-grant.
# The reason string must not claim more than was actually checked: on the merge_when_checks_succeed
# path this hook never read the CI status at all (it is delegated to Gitea), so saying "CI green"
# there was a plain falsehood in the one message a human reads to decide whether to trust the gate.
if [ "$mwcs" = "true" ]; then
decide grant "H6/H10 merge gate: satisfied — all Done-when boxes ticked, and both a positive Review-verdict comment and the 'review-verdict/h10' status cover the current head ($short). CI is gated by Gitea (merge_when_checks_succeed), and because the verdict status is bound to this sha, a commit pushed before Gitea merges will clear it and block the merge (ersatztv#622). Auto-granted."
# A negative verdict on head wins over a positive one (a later BLOCKED retracts an earlier MERGEABLE
# on the SAME head; and if the head were fixed the sha would change, so this can't wrongly block).
if [ "$head_neg" = 1 ]; then
decide deny "H10 merge gate: BLOCKED — a review verdict for the current head ($short) is negative (BLOCKED/NOT-MERGEABLE). Resolve the findings and post a fresh 'Review-verdict: MERGEABLE @ $short' before merging PR #$pr."
fi
if [ "$head_pos" = 1 ]; then
# (a) CI green + (b) all Done-when ticked + (c) positive verdict @ current head -> SATISFIED. Auto-grant.
decide grant "H6/H10 merge gate: satisfied — CI green, all Done-when boxes ticked, and a positive Review-verdict references the current head ($short). Auto-granted (no separate confirmation needed)."
fi
if [ "$stale" = 1 ]; then
decide deny "H10 merge gate: BLOCKED — a review-verdict comment references an older commit, not the current head ($short). The latest commit(s) are unreviewed (ersatztv#242: re-review the fix commit, not just the initial diff). Re-review the head and post 'Review-verdict: MERGEABLE @ $short'."
fi
# Marker(s) exist but reference no sha at all -> ask (don't mislabel as a stale older-commit review).
decide ask "H10 merge gate: a 'Review-verdict:' comment on PR #$pr references no commit sha. Post one referencing the current head ($short) — e.g. 'Review-verdict: MERGEABLE @ $short' — or confirm the review covered the latest commit and approve."
# Unreachable: the `case` above exits on every class, and `positive` exits in the block above. Kept as
# a fail-safe so a future class added to the classifier without a branch here cannot fall off the end
# of the script (which would exit 0 = silent passthrough, the one outcome a gate must never produce).
decide ask "H10 merge gate: verdict classification for PR #$pr produced no decision. Confirm the review covered the latest commit ($short) before merging."
# All derivable and satisfied -> auto-grant (defensive: the head_pos branch above already exits here).
decide grant "H6/H10 merge gate: satisfied — auto-granted."
-10
View File
@@ -408,16 +408,6 @@ jobs:
done
echo "::endgroup::"
# NOTE (ersatztv#491 -> #627): running the LibraryFolder dedupe fixture against the live `mysql`
# service was implemented here and then REMOVED. The coverage gap it closes is real — the two
# checks above only ever apply migrations to a fresh EMPTY database, so they execute no rows of any
# data-migration logic, and two MySql-only collation defects escaped exactly this gate. But the
# fixture proved non-deterministic in CI across three attempts (stale pooled session after a drop,
# then lost isolation from a shared database name, then a connect-before-create), and an
# intermittently-red gate is worse than none: it trains everyone to re-run instead of read, which is
# how the original defects escaped. The fixture itself is retained and is opt-in via
# ETV_TEST_MYSQL_CONNECTION (skipped, visibly, without it). Re-arming it here is tracked by #627.
functional-e2e:
name: Functional E2E (curl + UI contracts)
runs-on: ubuntu-latest
+1 -2
View File
@@ -160,8 +160,7 @@ jobs:
# 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 a Decisions-Edit: yes git
# trailer (ersatztv#609 — never a bare substring, which prose about the marker could arm), no record
# 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.
-193
View File
@@ -1,193 +0,0 @@
name: Review verdict
# ersatztv#622 — server-side H10 enforcement.
#
# THE INVARIANT. `review-verdict/h10` is a REQUIRED status check on `main`, and a Gitea commit
# status belongs to exactly ONE sha. So a commit that did not exist when a verdict was written can
# never inherit that verdict: push a new head and the required context is simply absent, which
# Gitea's merge-requirement check treats as not-passing. `merge_when_checks_succeed` therefore
# refuses to fire until someone re-reviews THAT head. This is what closes the ersatztv#622 hole,
# where the PreToolUse hook proved conditions (b) and (c) against the head at SCHEDULING time and
# Gitea then merged whatever head happened to be green minutes later.
#
# WHAT THIS WORKFLOW DOES — and, importantly, does NOT do. It does NOT decide whether code was
# reviewed; only a human/agent review does that, via `scripts/post-review-verdict.sh`, which writes
# the `review-verdict/h10` status directly. This workflow only handles the two EXEMPT classes that
# would otherwise deadlock, and marks everything else `pending` so the PR shows an explicit,
# actionable blocking reason instead of a silently-missing check:
#
# 1. Bot-authored PRs (Renovate). Renovate uses `platformAutomerge: true` — i.e. Gitea's OWN
# auto-merge — to land patch bumps unattended. A required verdict context with no exemption
# would stall every dependency PR forever waiting on a human verdict.
# 2. Docs-only PRs, mirroring the merge-consent hook's existing docs-only carve-out.
#
# BOTH exemptions are void when the PR touches a PROTECTED path (see PROTECTED below): the gate,
# the CI definition, the git hooks, the scripts they call, and the CI toolchain image. A PR that
# weakens the merge gate must never be able to exempt itself from the merge gate — that is the one
# self-referential failure worth spending an explicit rule on. Note this also (deliberately) means
# Renovate's `docker/ci/Dockerfile` base bumps need a real verdict; those already require the
# manual publish-then-pin two-step (docs/ci-cd.md -> "CI toolchain image"), so unattended merge was
# never correct for them anyway.
#
# WHY ITS OWN FILE, not a job in pr-checks.yml: that workflow sets `cancel-in-progress: true`, so a
# superseding push cancels its runs. A cancelled run here would leave an EXEMPT PR with no success
# status and no further pushes to re-trigger it — Renovate would stall silently. This workflow
# therefore takes no cancelling concurrency group.
#
# This job's OWN status context ("Review verdict / Set review-verdict status (pull_request)") is
# NOT the required check and is not what gates merges — `review-verdict/h10`, the status it POSTS,
# is. Keeping them distinct is deliberate: a workflow cannot be allowed to satisfy the gate merely
# by running successfully.
on:
pull_request:
types: [opened, reopened, synchronize, ready_for_review]
defaults:
run:
shell: bash
jobs:
set-verdict-status:
name: Set review-verdict status
runs-on: small # a few API calls; keep it off the build runners
steps:
- name: Classify the PR and post the review-verdict status
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
BASE_URL: ${{ github.server_url }}/api/v1
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
SHA: ${{ github.event.pull_request.head.sha }}
AUTHOR: ${{ github.event.pull_request.user.login }}
PR_URL: ${{ github.event.pull_request.html_url }}
run: |
set -euo pipefail
CONTEXT="review-verdict/h10"
# Accounts whose PRs may merge without a human verdict. Renovate only — keep this list
# minimal and explicit; every entry is an account that can land code unreviewed.
BOTS="renovate"
# Paths where NEITHER exemption applies, because a change here can alter the gate itself,
# what CI runs, or what the hooks enforce.
PROTECTED='^(\.claude/|\.gitea/|\.husky/|scripts/|docker/ci/)'
# Docs-only: prose and decision records. Deliberately narrower than the hook's pattern,
# which also lets .claude/.gitea/.husky through — that carve-out is safe there only
# because it falls through to a HUMAN PROMPT, whereas here it would post a green status
# with nobody in the loop.
DOCS_ONLY='^(docs/|[^/]*\.md$)'
if [ -z "${GITEA_TOKEN:-}" ]; then
echo "::error::No GITEA_TOKEN available, so the ${CONTEXT} status cannot be written. An exempt (bot/docs-only) PR will stall until this is fixed; a normal PR is unaffected — post its verdict with scripts/post-review-verdict.sh."
exit 1
fi
gh() { curl -sf -H "Authorization: token $GITEA_TOKEN" "$@"; }
# --- Already decided for THIS sha? Never overwrite a real verdict. -------------------
# A human/agent verdict for this exact head may already exist (the reviewer ran the
# script before this workflow finished, or a rerun). Re-posting `pending` over it would
# un-approve a reviewed head and stall the PR.
#
# Read the COMBINED endpoint, not `/statuses/{sha}`: the latter returns one row per
# status POST (not per context) and pages at 50, so a head with a few CI reruns can push
# an earlier verdict off the first page. Missing it here is NOT harmless — we would post
# `pending` (or worse, an exemption `success`) over a real human verdict. The combined
# endpoint returns latest-per-context, which is both what we mean and ~11 rows.
#
# An unreadable/unparseable response must NOT be read as "no verdict exists": fail the
# job WITHOUT posting anything, so a transient API error can never overwrite a verdict.
statusjson=$(gh "$BASE_URL/repos/$REPO/commits/$SHA/status?limit=100") || statusjson=""
if ! printf '%s' "$statusjson" | jq -e '.statuses | type == "array"' >/dev/null 2>&1; then
echo "::error::Could not read existing commit statuses for ${SHA:0:7}. Refusing to post anything rather than risk overwriting an existing verdict."
exit 1
fi
existing=$(printf '%s' "$statusjson" \
| jq -r --arg c "$CONTEXT" '[.statuses[] | select(.context == $c)] | first | .status // ""')
if [ "$existing" = "success" ] || [ "$existing" = "failure" ]; then
echo "${CONTEXT} is already '${existing}' on ${SHA:0:7} — leaving the existing verdict alone."
exit 0
fi
# --- Changed files: PAGE to exhaustion, and fail CLOSED if we cannot. ----------------
# Gitea caps this endpoint at 50 rows per page and SILENTLY IGNORES a larger `limit`
# (verified: PR #619 has 194 changed files and `?limit=100` returns exactly 50). A
# single-page read is therefore a silent false negative: a protected path sitting at
# position 51+ would simply not be seen, and a bot-authored PR that edits the gate could
# exempt itself from the gate. Page until a short page proves the end.
PAGE_SIZE=50
MAX_PAGES=40 # 2000 files; beyond this we refuse rather than guess
files=""
page=1
complete=no
while [ "$page" -le "$MAX_PAGES" ]; do
raw=$(gh "$BASE_URL/repos/$REPO/pulls/$PR/files?limit=${PAGE_SIZE}&page=${page}") || raw=""
# A transport/parse failure must NOT masquerade as a legitimate short final page.
# Empty output counts as zero rows, which would otherwise read as "end of list" and set
# complete=yes over a PARTIAL enumeration — failing OPEN at the exact point this guard
# exists to fail closed.
#
# Validating only the TOP-LEVEL type is not enough: a page like `[{}]` is a well-formed
# array whose rows carry no `filename`, so it contributes no paths, counts as a short
# page, and completes the enumeration from a partial list — the same failure one level
# down. Require every row to carry a non-empty string `filename`; an empty array stays
# valid, since that is what a genuine end-of-pagination looks like.
if ! printf '%s' "$raw" \
| jq -e 'type == "array" and all(.[]; (.filename | type == "string" and length > 0) and (if .status == "renamed" then (.previous_filename | type == "string" and length > 0) else true end))' \
>/dev/null 2>&1; then
complete=no; break
fi
# Page-size termination is measured in ROWS; the path set collects BOTH sides of a
# rename. Gitea reports a `git mv` as ONE row whose `filename` is the DESTINATION, with
# the source only in `previous_filename` — so reading `filename` alone lets a PR move a
# protected file INTO docs/ and pass as docs-only (verified live:
# `.gitea/workflows/renovate.yml` -> `docs/innocuous-note.md` showed no protected path).
# One renamed row thus contributes ONE to `n` and TWO to the path set, which is why
# these two counts are deliberately computed differently.
n=$(printf '%s' "$raw" | jq -r 'length')
chunk=$(printf '%s' "$raw" | jq -r '.[] | (.filename // empty), (.previous_filename // empty)')
[ -n "$chunk" ] && files=$(printf '%s\n%s' "$files" "$chunk")
if [ "$n" -lt "$PAGE_SIZE" ]; then complete=yes; break; fi
page=$((page + 1))
done
files=$(printf '%s\n' "$files" | grep -v '^$' || true)
count=$(printf '%s\n' "$files" | grep -c . || true)
echo "Changed files (${count}, complete=${complete}, pages=${page}):"
printf '%s\n' "$files" | sed 's/^/ /'
exempt=no
reason=""
if [ "$complete" != yes ]; then
reason="could not enumerate the changed files exhaustively (stopped at ${count}) — no exemption"
elif [ "${count:-0}" -eq 0 ]; then
reason="no changed files could be read from the API — no exemption"
elif printf '%s\n' "$files" | grep -qE "$PROTECTED"; then
reason="touches a protected path (gate/CI/hooks/scripts/ci-image) — exemptions do not apply"
elif printf '%s\n' "$BOTS" | tr ' ' '\n' | grep -qxF "$AUTHOR"; then
exempt=yes
reason="authored by the '$AUTHOR' bot account and touches no protected path"
elif ! printf '%s\n' "$files" | grep -qvE "$DOCS_ONLY"; then
exempt=yes
reason="docs-only change (no code, no protected path)"
else
reason="awaiting an H10 review verdict for head ${SHA:0:7}"
fi
if [ "$exempt" = yes ]; then
state=success
desc="Exempt: $reason"
else
state=pending
desc="Awaiting review verdict for ${SHA:0:7}"
fi
echo "Decision: state=${state} — ${reason}"
payload=$(jq -n --arg s "$state" --arg c "$CONTEXT" --arg d "$desc" --arg u "$PR_URL" \
'{state:$s, context:$c, description:$d, target_url:$u}')
gh -X POST -H 'Content-Type: application/json' -d "$payload" \
"$BASE_URL/repos/$REPO/statuses/$SHA" >/dev/null
echo "Posted ${CONTEXT}=${state} on ${SHA:0:7}."
if [ "$state" = "pending" ]; then
echo "::notice::This PR needs an H10 review verdict for head ${SHA:0:7} before it can merge. After reviewing, run: scripts/post-review-verdict.sh ${PR} MERGEABLE"
fi
+3 -4
View File
@@ -42,7 +42,7 @@ docker build -f docker/Dockerfile -t ersatztv:dev .
| Migrate / add / redirect a route (new `web/src/screens/*.tsx`, `LegacyUiRedirects.cs`) | `docs/blazor-route-parity.md` + `docs/domain-model.md` |
| Add / change a `/api/*` endpoint | `docs/api-conventions.md` checklist, then regenerate `v1.json` + `endpoint-index.md` via `./scripts/update-openapi.sh` |
| Change a SPA screen convention | `docs/spa-conventions.md` |
| Establish / reverse a convention or decision | a new `docs/decisions/records/<area>/<topic>.md` (filename = key; lifecycle: add record, `git mv` predecessor to `archive/<area>/`) + regenerate the catalog + the affected doc |
| Establish / reverse a convention or decision | `docs/decisions.md` (lifecycle: add record, relocate predecessor to archive/) + the affected doc |
| Add / remove / retitle a doc | `docs/README.md` index |
The `docs-reminder` CI job flags a screen/route change that skips `blazor-route-parity.md`, but it's a **non-blocking** nudge — the rule is on you, not the check.
@@ -52,7 +52,7 @@ docker build -f docker/Dockerfile -t ersatztv:dev .
- Test with **NUnit** + Shouldly + NSubstitute (the existing `*.Tests` projects); xUnit is **not** used here
- **Dependencies use Central Package Management**: versions live in the repo-root `Directory.Packages.props`; csproj reference packages by name only. Add/upgrade by editing the central `<PackageVersion>` — never put `Version=` back on a `<PackageReference>` (trips `NU1008`). See `docs/ci-cd.md` → Dependency management.
- **DB migrations target BOTH providers**: a `TvContext` model change needs a migration in `ErsatzTV.Infrastructure.Sqlite` **and** `ErsatzTV.Infrastructure.MySql` — run `scripts/add-migration.sh <Name>` (does both). CI's `migrations` job enforces model-drift + apply-to-fresh-DB per provider. See `docs/ci-cd.md` → Migration integrity.
- **Renovate** is live (`.gitea/workflows/renovate.yml`, weekly + `workflow_dispatch`): opens dependency-update + OSV vuln-fix PRs and a Dependency Dashboard issue; patch bumps to test/dev-only packages auto-merge once `Build & test` passes (their `review-verdict/h10` required check is auto-passed as a bot PR — unless they touch `.claude/`/`.gitea/`/`.husky/`/`scripts/`/`docker/ci/`, which need a real verdict), the rest are manual. Cross-repo rollout: server-management#484. See `docs/ci-cd.md` → Dependency management.
- **Renovate** is live (`.gitea/workflows/renovate.yml`, weekly + `workflow_dispatch`): opens dependency-update + OSV vuln-fix PRs and a Dependency Dashboard issue; patch bumps to test/dev-only packages auto-merge once `Build & test` passes, the rest are manual. Cross-repo rollout: server-management#484. See `docs/ci-cd.md` → Dependency management.
- **Versioning**: release tags are `vYY.<release-seq>.<patch>` (year · sequential release-within-year · patch) — inherited from upstream, **not** year.month. `v26.3.1` = our infra rebuild of upstream 26.3.0 (no app changes); `v26.4.0` is reserved for the first release with app changes. Never `[skip ci]` a commit you'll tag (it suppresses the release build). Full policy: `docs/ci-cd.md` → Versioning & releases.
- Backlog tracked via [Gitea Issues](http://192.168.1.95:3000/timothy/ersatztv/issues)
@@ -61,8 +61,7 @@ docker build -f docker/Dockerfile -t ersatztv:dev .
Every task that closes a Gitea issue MUST complete ALL of these before it is considered done. Use `/done <issue>` to run through this automatically.
**Merge-consent is derived from state, not asserted (`## Done-when` convention — ersatztv#303 H6 + H10).** Any issue whose PR will merge to `main` should carry a `## Done-when` section in its **issue body** — a checklist of completion criteria (always include an "adversarial review passed" box; add per-issue criteria like tests-green, docs-updated, live-E2E). Two hooks derive merge-consent from it so a premature merge is blocked *by construction*, not by memory:
- `pretooluse-merge-consent.sh` (Claude PreToolUse on the Gitea merge tool) — **auto-grants** a merge (emits `permissionDecision: allow`, so **no** redundant mechanical prompt fires) only when the PR's CI is green **and** every `## Done-when` box on the linked issue (`fixes #N`) is ticked **and** a `Review-verdict:` comment references the PR's *current head sha* (**H10**); **denies** on an unticked box, red CI, or a stale/negative review verdict; **asks** (falls back to a human prompt) when it can't derive state (no linked issue, no `## Done-when` section, no `Review-verdict:` comment yet, no creds, Gitea down). On the auto-grant (satisfied) path the derived state **is** the consent — do not also ask conversationally to merge; a separate human confirmation is warranted only when the gate **asks** (ersatztv#314). **The H10 review-verdict convention**: after an adversarial/Codex review of a PR (or its latest fix commit), run **`scripts/post-review-verdict.sh <pr> <MERGEABLE|APPROVED|BLOCKED|NOT-MERGEABLE> [note]`** — it posts both the `Review-verdict: … @ <head-sha>` comment and the sha-bound `review-verdict/h10` commit status, proving the *latest* commit was reviewed rather than a stale earlier diff (ersatztv#242). Do not hand-write the comment: the **status** is the required check branch protection enforces, and a comment alone leaves it absent.
- **The gate is enforced server-side, per sha (ersatztv#622).** `review-verdict/h10` is a required status check on `main`. Because a commit status belongs to one sha, a commit pushed *after* an auto-merge is scheduled clears it and blocks the merge — closing the hole where `merge_when_checks_succeed` froze consent at scheduling time and Gitea later merged an unreviewed head. Renovate-authored and docs-only PRs are auto-passed by `.gitea/workflows/review-verdict.yml`, **except** when they touch `.claude/`, `.gitea/`, `.husky/`, `scripts/` or `docker/ci/`. See `docs/ci-cd.md` → Review-verdict gate.
- `pretooluse-merge-consent.sh` (Claude PreToolUse on the Gitea merge tool) — **auto-grants** a merge (emits `permissionDecision: allow`, so **no** redundant mechanical prompt fires) only when the PR's CI is green **and** every `## Done-when` box on the linked issue (`fixes #N`) is ticked **and** a `Review-verdict:` comment references the PR's *current head sha* (**H10**); **denies** on an unticked box, red CI, or a stale/negative review verdict; **asks** (falls back to a human prompt) when it can't derive state (no linked issue, no `## Done-when` section, no `Review-verdict:` comment yet, no creds, Gitea down). On the auto-grant (satisfied) path the derived state **is** the consent — do not also ask conversationally to merge; a separate human confirmation is warranted only when the gate **asks** (ersatztv#314). **The H10 review-verdict convention**: after an adversarial/Codex review of a PR (or its latest fix commit), post a PR comment with a line `Review-verdict: <MERGEABLE|APPROVED|BLOCKED> @ <head-sha>` — this proves the *latest* commit was reviewed, not a stale earlier diff (ersatztv#242).
- `.husky/pre-push``prepush-donewhen.sh` — a fail-open backstop that blocks a direct `git push origin main` whose commits `fix #N` an issue with unticked boxes.
Both need Gitea read creds in the env to enforce (**`ETV_GITEA_BASICAUTH=user:pass`** or `ETV_GITEA_TOKEN`; `ETV_GITEA_URL` overrides the base). Without them the merge hook asks and the push backstop is a no-op — the gate degrades to today's manual confirmation, never a silent pass. Docs-only PRs/pushes are exempt.
@@ -1,104 +0,0 @@
using System.Text.RegularExpressions;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Architecture.Tests;
/// <summary>
/// ersatztv#491: <c>TvContext</c> carries settable provider statics (<c>LastInsertedRowId</c>,
/// <c>CaseInsensitiveCollation</c>, <c>IsUniqueConstraintViolation</c>, …) that Infrastructure code
/// reads at runtime. There are TWO composition roots that execute that Infrastructure code —
/// <c>ErsatzTV/Startup.cs</c> (the host) and <c>ErsatzTV.Scanner/Program.cs</c> (a separate
/// executable launched per scan by <c>CallLibraryScannerHandler</c>) — and each wires the statics in
/// its own copy of the provider branch.
/// <para>
/// The failure mode this guards is "a static nobody assigned": #491 added
/// <c>IsUniqueConstraintViolation</c> to <c>Startup</c> only, so every production caller of
/// <c>GetOrAddFolder</c> (all of which live in the scanner) silently kept the conservative
/// <c>_ =&gt; false</c> default and the new catch was inert. Nothing about that is visible in a
/// unit test, because every test harness wires the classifier itself.
/// </para>
/// <para>
/// Source-level rather than reflective on purpose: the wiring lives inside a host-builder
/// lambda that cannot be invoked without standing up a real application, and the thing being
/// asserted is precisely that a line of code exists in both files.
/// </para>
/// </summary>
[TestFixture]
public class ProviderStaticsWiringTests
{
/// <summary>
/// Statics the host wires that the scanner deliberately does not. Add to this only with a reason:
/// the default must be provably harmless in the scanner process.
/// </summary>
private static readonly Dictionary<string, string> ScannerExemptions = new()
{
// Only read by DbInitializer / DatabaseMigratorService, which run in the host exclusively; no
// Infrastructure code on a scan path reads it. Pre-dates #491.
["IsSqlite"] = "read only by DbInitializer + DatabaseMigratorService, both host-only"
};
private static string HostSource => ReadRepoFile(Path.Combine("ErsatzTV", "Startup.cs"));
private static string ScannerSource => ReadRepoFile(Path.Combine("ErsatzTV.Scanner", "Program.cs"));
[Test]
public void Scanner_should_wire_every_TvContext_provider_static_the_host_wires()
{
HashSet<string> host = AssignedStatics(HostSource);
HashSet<string> scanner = AssignedStatics(ScannerSource);
// sanity: the parser found the wiring at all, so a rename can't turn this test into a no-op
host.ShouldContain("LastInsertedRowId");
host.ShouldContain("IsUniqueConstraintViolation");
scanner.ShouldContain("LastInsertedRowId");
List<string> missing = host
.Except(scanner)
.Except(ScannerExemptions.Keys)
.OrderBy(name => name, StringComparer.Ordinal)
.ToList();
missing.ShouldBeEmpty(
"ErsatzTV.Scanner/Program.cs does not assign TvContext static(s) that ErsatzTV/Startup.cs "
+ $"assigns: {string.Join(", ", missing)}. The scanner is a separate process, so an unassigned "
+ "static keeps its default in every library scan. Wire it in BOTH provider branches, or add "
+ "it to ScannerExemptions with a reason if the default is provably harmless there.");
}
[Test]
public void Both_hosts_should_wire_the_unique_constraint_classifier_for_both_providers()
{
// The specific #491 regression, asserted directly rather than via set arithmetic: the classifier
// must be pointed at a real provider implementation on BOTH branches of BOTH composition roots.
foreach ((string name, string source) in new[] { ("host", HostSource), ("scanner", ScannerSource) })
{
source.ShouldContain(
"TvContext.IsUniqueConstraintViolation = SqliteErrorClassifier.IsUniqueConstraintViolation",
customMessage: $"{name} does not wire the Sqlite unique-constraint classifier");
source.ShouldContain(
"TvContext.IsUniqueConstraintViolation = MySqlErrorClassifier.IsUniqueConstraintViolation",
customMessage: $"{name} does not wire the MySql unique-constraint classifier");
}
}
private static HashSet<string> AssignedStatics(string source) =>
Regex.Matches(source, @"\bTvContext\.(?<name>[A-Za-z_][A-Za-z0-9_]*)\s*=[^=]")
.Select(m => m.Groups["name"].Value)
.ToHashSet(StringComparer.Ordinal);
private static string ReadRepoFile(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "ErsatzTV.sln")))
{
directory = directory.Parent;
}
directory.ShouldNotBeNull("could not locate the repository root (no ErsatzTV.sln above the test binary)");
string path = Path.Combine(directory!.FullName, relativePath);
File.Exists(path).ShouldBeTrue($"expected source file not found: {path}");
return File.ReadAllText(path);
}
}
@@ -8,7 +8,6 @@ public record PlayoutResponseModel(
PlayoutScheduleKind ScheduleKind,
string ChannelName,
string ChannelNumber,
int ChannelId,
ChannelPlayoutMode PlayoutMode,
string ScheduleName,
string? ScheduleFile,
@@ -24,7 +23,6 @@ public record PlayoutResponseModel(
PlayoutScheduleKind scheduleKind,
string channelName,
string channelNumber,
int channelId,
ChannelPlayoutMode playoutMode,
string scheduleName,
string? scheduleFile,
@@ -39,7 +37,6 @@ public record PlayoutResponseModel(
scheduleKind,
channelName,
channelNumber,
channelId,
playoutMode,
scheduleName,
scheduleFile,
+1 -11
View File
@@ -1,19 +1,9 @@
namespace ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Domain;
public class LibraryFolder
{
public int Id { get; set; }
public string Path { get; set; }
/// <summary>
/// SHA-256 hex of <see cref="Path" /> (<see cref="ErsatzTV.Core.PathUtils.GetPathHash" />), the
/// indexable stand-in for the unbounded <see cref="Path" /> column that backs the unique
/// <c>(LibraryPathId, PathHash)</c> constraint — the same shape as <c>MediaFile.PathHash</c>.
/// Nullable: rows created before ersatztv#491 carry <c>null</c> until a scan heals them, and a
/// unique index treats nulls as distinct so those legacy rows never collide.
/// </summary>
public string PathHash { get; set; }
public int LibraryPathId { get; set; }
public LibraryPath LibraryPath { get; set; }
public int? ParentId { get; set; }
@@ -1,172 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_LibraryFolder_PathHash_UniqueIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// ersatztv#491 — audit/clean pre-existing duplicate LibraryFolder rows before the unique index.
// Mirrors the Sqlite migration; see it for the full rationale. The one provider difference is
// that every Path comparison here is forced BYTE-EXACT with CONVERT(... USING binary), because
// MySql's string comparison differs from Sqlite's on two independent axes and the dedupe
// deletes rows irreversibly:
// * case — the server default (utf8mb4_general_ci) is case-INsensitive, so grouping under it
// would collapse sibling folders differing only in case, legal on a case-sensitive fs;
// * trailing spaces — utf8mb4_bin, the obvious fix for the case half, is a PAD SPACE
// collation (verified on 8.4: '/media/Foo' = '/media/Foo ' is TRUE under it), so it would
// still collapse "/media/Foo" and "/media/Foo ", two distinct legal directories.
// Binary comparison is NO PAD and byte-exact, which is exactly what PathUtils.GetPathHash does
// — so the dedupe now destroys only rows the unique index would actually have rejected, and
// the two providers' migrations are semantically equivalent. (utf8mb4_0900_bin is also NO PAD
// but carries a server-version floor; CONVERT USING binary does not.)
// DROP TABLE IF EXISTS makes a retry after a partial failure safe (DDL implicitly commits on
// MySql, so the migration is not atomic).
migrationBuilder.Sql("DROP TABLE IF EXISTS `__LibraryFolderDedupe`");
migrationBuilder.Sql(
"""
CREATE TABLE `__LibraryFolderDedupe` (
LoserId INT NOT NULL PRIMARY KEY,
KeeperId INT NOT NULL
)
""");
migrationBuilder.Sql(
"""
INSERT INTO `__LibraryFolderDedupe` (LoserId, KeeperId)
SELECT l.Id, k.KeeperId
FROM LibraryFolder l
INNER JOIN (
SELECT LibraryPathId, CONVERT(Path USING binary) AS BinPath, MIN(Id) AS KeeperId
FROM LibraryFolder
GROUP BY LibraryPathId, CONVERT(Path USING binary)
) k ON k.LibraryPathId = l.LibraryPathId AND k.BinPath = CONVERT(l.Path USING binary)
WHERE l.Id <> k.KeeperId
""");
// media files recorded against a duplicate folder follow the keeper (MediaFile.LibraryFolderId
// is Restrict, so the delete below would fail otherwise)
migrationBuilder.Sql(
"""
UPDATE MediaFile
SET LibraryFolderId = (
SELECT KeeperId FROM `__LibraryFolderDedupe` WHERE LoserId = MediaFile.LibraryFolderId)
WHERE LibraryFolderId IN (SELECT LoserId FROM `__LibraryFolderDedupe`)
""");
// child folders parented on a duplicate follow the keeper (ParentId is Restrict as well)
migrationBuilder.Sql(
"""
UPDATE LibraryFolder
SET ParentId = (
SELECT KeeperId FROM `__LibraryFolderDedupe` WHERE LoserId = LibraryFolder.ParentId)
WHERE ParentId IN (SELECT LoserId FROM `__LibraryFolderDedupe`)
""");
// A folder parented on its OWN duplicate would become its own parent above. Unreachable from
// any code path today, but this is a tree the scanner walks, so remove the cycle class rather
// than reason about it.
migrationBuilder.Sql("UPDATE LibraryFolder SET ParentId = NULL WHERE ParentId = Id");
// Clear the survivor's etag. Which duplicate the scanner was actually writing to was
// arbitrary, so MIN(Id)'s etag may describe a stale view of the folder and would suppress the
// next rescan. A null etag costs exactly one rescan and cannot be wrong.
migrationBuilder.Sql(
"UPDATE LibraryFolder SET Etag = NULL WHERE Id IN (SELECT KeeperId FROM `__LibraryFolderDedupe`)");
// ImageFolderDuration is 1:1 with LibraryFolder (unique index on LibraryFolderId), so the
// duplicates' rows cannot all be repointed. Keep the keeper's own setting when it has one;
// otherwise promote exactly one loser's (lowest Id) and drop the rest.
migrationBuilder.Sql("DROP TABLE IF EXISTS `__LibraryFolderDedupeIfd`");
migrationBuilder.Sql(
"""
CREATE TABLE `__LibraryFolderDedupeIfd` (
KeeperId INT NOT NULL PRIMARY KEY,
IfdId INT NOT NULL
)
""");
migrationBuilder.Sql(
"""
INSERT INTO `__LibraryFolderDedupeIfd` (KeeperId, IfdId)
SELECT d.KeeperId, MIN(i.Id)
FROM `__LibraryFolderDedupe` d
INNER JOIN ImageFolderDuration i ON i.LibraryFolderId = d.LoserId
WHERE NOT EXISTS (
SELECT 1 FROM ImageFolderDuration ki WHERE ki.LibraryFolderId = d.KeeperId)
GROUP BY d.KeeperId
""");
migrationBuilder.Sql(
"""
DELETE FROM ImageFolderDuration
WHERE LibraryFolderId IN (SELECT LoserId FROM `__LibraryFolderDedupe`)
AND Id NOT IN (SELECT IfdId FROM `__LibraryFolderDedupeIfd`)
""");
migrationBuilder.Sql(
"""
UPDATE ImageFolderDuration
SET LibraryFolderId = (
SELECT KeeperId FROM `__LibraryFolderDedupeIfd` WHERE IfdId = ImageFolderDuration.Id)
WHERE Id IN (SELECT IfdId FROM `__LibraryFolderDedupeIfd`)
""");
migrationBuilder.Sql(
"DELETE FROM LibraryFolder WHERE Id IN (SELECT LoserId FROM `__LibraryFolderDedupe`)");
migrationBuilder.Sql("DROP TABLE `__LibraryFolderDedupeIfd`");
migrationBuilder.Sql("DROP TABLE `__LibraryFolderDedupe`");
migrationBuilder.AddColumn<string>(
name: "PathHash",
table: "LibraryFolder",
type: "varchar(64)",
maxLength: 64,
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
// Existing rows keep a null hash on purpose: a unique index treats nulls as distinct, so the
// index applies cleanly to any database, and LibraryRepository.GetOrAddFolder heals each row
// (SHA-256 of Path) the first time a scan touches it. Those rows are deduplicated above and are
// still found by the Path lookup, so no insert can race them in the meantime.
//
// Order matters on MySql, and EF scaffolds it the other way round: InnoDB refuses to drop the
// FK's only backing index ("Cannot drop index 'IX_LibraryFolder_LibraryPathId': needed in a
// foreign key constraint"). Create the composite first — LibraryPathId is its leftmost column,
// so it takes over as the FK's backing index — then drop the now-redundant single-column one.
migrationBuilder.CreateIndex(
name: "IX_LibraryFolder_LibraryPathId_PathHash",
table: "LibraryFolder",
columns: new[] { "LibraryPathId", "PathHash" },
unique: true);
migrationBuilder.DropIndex(
name: "IX_LibraryFolder_LibraryPathId",
table: "LibraryFolder");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// mirror of Up: restore the single-column index before dropping the composite one, so the
// foreign key is never left without a backing index
migrationBuilder.CreateIndex(
name: "IX_LibraryFolder_LibraryPathId",
table: "LibraryFolder",
column: "LibraryPathId");
migrationBuilder.DropIndex(
name: "IX_LibraryFolder_LibraryPathId_PathHash",
table: "LibraryFolder");
migrationBuilder.DropColumn(
name: "PathHash",
table: "LibraryFolder");
}
}
}
@@ -1353,16 +1353,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<string>("Path")
.HasColumnType("longtext");
b.Property<string>("PathHash")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.HasKey("Id");
b.HasIndex("ParentId");
b.HasIndex("LibraryPathId");
b.HasIndex("LibraryPathId", "PathHash")
.IsUnique();
b.HasIndex("ParentId");
b.ToTable("LibraryFolder", (string)null);
});
@@ -1,157 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_LibraryFolder_PathHash_UniqueIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// ersatztv#491 — audit/clean pre-existing duplicate LibraryFolder rows before the unique index.
// Duplicates were reachable before #488 (the folder lookup read a scan-start in-memory snapshot,
// so a folder created earlier in the SAME scan was invisible and inserted again) and via the
// check-then-insert race the index now closes. Keep the lowest Id per (LibraryPathId, Path) and
// repoint every dependent row at it before deleting the losers. The helper tables keep the
// statements readable; DROP TABLE IF EXISTS makes a retry after a partial failure safe.
migrationBuilder.Sql("DROP TABLE IF EXISTS __LibraryFolderDedupe");
migrationBuilder.Sql(
"""
CREATE TABLE __LibraryFolderDedupe (
LoserId INTEGER NOT NULL PRIMARY KEY,
KeeperId INTEGER NOT NULL
)
""");
migrationBuilder.Sql(
"""
INSERT INTO __LibraryFolderDedupe (LoserId, KeeperId)
SELECT l.Id, k.KeeperId
FROM LibraryFolder l
INNER JOIN (
SELECT LibraryPathId, Path, MIN(Id) AS KeeperId
FROM LibraryFolder
GROUP BY LibraryPathId, Path
) k ON k.LibraryPathId = l.LibraryPathId AND k.Path = l.Path
WHERE l.Id <> k.KeeperId
""");
// media files recorded against a duplicate folder follow the keeper (MediaFile.LibraryFolderId
// is Restrict, so the delete below would fail otherwise)
migrationBuilder.Sql(
"""
UPDATE MediaFile
SET LibraryFolderId = (
SELECT KeeperId FROM __LibraryFolderDedupe WHERE LoserId = MediaFile.LibraryFolderId)
WHERE LibraryFolderId IN (SELECT LoserId FROM __LibraryFolderDedupe)
""");
// child folders parented on a duplicate follow the keeper (ParentId is Restrict as well)
migrationBuilder.Sql(
"""
UPDATE LibraryFolder
SET ParentId = (
SELECT KeeperId FROM __LibraryFolderDedupe WHERE LoserId = LibraryFolder.ParentId)
WHERE ParentId IN (SELECT LoserId FROM __LibraryFolderDedupe)
""");
// A folder parented on its OWN duplicate would become its own parent above. Unreachable from
// any code path today, but this is a tree the scanner walks, so remove the cycle class rather
// than reason about it.
migrationBuilder.Sql("UPDATE LibraryFolder SET ParentId = NULL WHERE ParentId = Id");
// Clear the survivor's etag. Which duplicate the scanner was actually writing to was
// arbitrary, so MIN(Id)'s etag may describe a stale view of the folder and would suppress the
// next rescan. A null etag costs exactly one rescan and cannot be wrong.
migrationBuilder.Sql(
"UPDATE LibraryFolder SET Etag = NULL WHERE Id IN (SELECT KeeperId FROM __LibraryFolderDedupe)");
// ImageFolderDuration is 1:1 with LibraryFolder (unique index on LibraryFolderId), so the
// duplicates' rows cannot all be repointed. Keep the keeper's own setting when it has one;
// otherwise promote exactly one loser's (lowest Id) and drop the rest.
migrationBuilder.Sql("DROP TABLE IF EXISTS __LibraryFolderDedupeIfd");
migrationBuilder.Sql(
"""
CREATE TABLE __LibraryFolderDedupeIfd (
KeeperId INTEGER NOT NULL PRIMARY KEY,
IfdId INTEGER NOT NULL
)
""");
migrationBuilder.Sql(
"""
INSERT INTO __LibraryFolderDedupeIfd (KeeperId, IfdId)
SELECT d.KeeperId, MIN(i.Id)
FROM __LibraryFolderDedupe d
INNER JOIN ImageFolderDuration i ON i.LibraryFolderId = d.LoserId
WHERE NOT EXISTS (
SELECT 1 FROM ImageFolderDuration ki WHERE ki.LibraryFolderId = d.KeeperId)
GROUP BY d.KeeperId
""");
migrationBuilder.Sql(
"""
DELETE FROM ImageFolderDuration
WHERE LibraryFolderId IN (SELECT LoserId FROM __LibraryFolderDedupe)
AND Id NOT IN (SELECT IfdId FROM __LibraryFolderDedupeIfd)
""");
migrationBuilder.Sql(
"""
UPDATE ImageFolderDuration
SET LibraryFolderId = (
SELECT KeeperId FROM __LibraryFolderDedupeIfd WHERE IfdId = ImageFolderDuration.Id)
WHERE Id IN (SELECT IfdId FROM __LibraryFolderDedupeIfd)
""");
migrationBuilder.Sql(
"DELETE FROM LibraryFolder WHERE Id IN (SELECT LoserId FROM __LibraryFolderDedupe)");
migrationBuilder.Sql("DROP TABLE __LibraryFolderDedupeIfd");
migrationBuilder.Sql("DROP TABLE __LibraryFolderDedupe");
migrationBuilder.AddColumn<string>(
name: "PathHash",
table: "LibraryFolder",
type: "TEXT",
maxLength: 64,
nullable: true);
// Existing rows keep a null hash on purpose: a unique index treats nulls as distinct, so the
// index applies cleanly to any database, and LibraryRepository.GetOrAddFolder heals each row
// (SHA-256 of Path) the first time a scan touches it. Those rows are deduplicated above and are
// still found by the Path lookup, so no insert can race them in the meantime.
//
// Create-then-drop rather than EF's scaffolded drop-then-create, matching the MySql copy, where
// InnoDB refuses to drop the foreign key's only backing index.
migrationBuilder.CreateIndex(
name: "IX_LibraryFolder_LibraryPathId_PathHash",
table: "LibraryFolder",
columns: new[] { "LibraryPathId", "PathHash" },
unique: true);
migrationBuilder.DropIndex(
name: "IX_LibraryFolder_LibraryPathId",
table: "LibraryFolder");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_LibraryFolder_LibraryPathId",
table: "LibraryFolder",
column: "LibraryPathId");
migrationBuilder.DropIndex(
name: "IX_LibraryFolder_LibraryPathId_PathHash",
table: "LibraryFolder");
migrationBuilder.DropColumn(
name: "PathHash",
table: "LibraryFolder");
}
}
}
@@ -1298,16 +1298,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<string>("Path")
.HasColumnType("TEXT");
b.Property<string>("PathHash")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ParentId");
b.HasIndex("LibraryPathId");
b.HasIndex("LibraryPathId", "PathHash")
.IsUnique();
b.HasIndex("ParentId");
b.ToTable("LibraryFolder", (string)null);
});
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
@@ -10,19 +10,6 @@ public class LibraryFolderConfiguration : IEntityTypeConfiguration<LibraryFolder
{
builder.ToTable("LibraryFolder");
// ersatztv#491: GetOrAddFolder is a check-then-insert, so two callers racing the same folder both
// miss the lookup and both insert. The unique index makes the duplicate impossible at the storage
// layer (the loser gets a constraint violation the repository catches and re-reads).
// Indexed on PathHash rather than Path because Path is unbounded (MySQL longtext, which cannot be
// indexed without a prefix length, and whose default collation is case-INsensitive — a prefix
// index would also false-collide sibling folders differing only in case on a case-sensitive
// filesystem). This mirrors the existing MediaFile.Path/PathHash pair.
builder.Property(f => f.PathHash)
.HasMaxLength(64);
builder.HasIndex(f => new { f.LibraryPathId, f.PathHash })
.IsUnique();
builder.HasOne(f => f.Parent)
.WithMany(p => p.Children)
.HasForeignKey(f => f.ParentId)
@@ -1,6 +1,5 @@
using System.IO.Abstractions;
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Extensions;
@@ -111,36 +110,16 @@ public class LibraryRepository(IFileSystem fileSystem, IDbContextFactory<TvConte
if (knownFolder.IsNone)
{
var newFolder = new LibraryFolder
await dbContext.LibraryFolders.AddAsync(
new LibraryFolder
{
Path = path,
PathHash = PathUtils.GetPathHash(path),
Etag = etag,
LibraryPathId = libraryPath.Id
};
});
try
{
await dbContext.LibraryFolders.AddAsync(newFolder);
await dbContext.SaveChangesAsync();
}
catch (DbUpdateException ex) when (TvContext.IsUniqueConstraintViolation(ex))
{
// ersatztv#491: a concurrent caller created this folder between the caller's lookup and
// this insert. The etag write is the whole point of the call, so apply it to the winner's
// row rather than failing the scan.
dbContext.Entry(newFolder).State = EntityState.Detached;
LibraryFolder winner = await GetFolder(dbContext, libraryPath.Id, path);
if (winner is null)
{
throw;
}
await dbContext.Connection.ExecuteAsync(
"UPDATE LibraryFolder SET Etag = @Etag WHERE Id = @Id",
new { winner.Id, Etag = etag });
}
}
}
public async Task CleanEtagsForLibraryPath(LibraryPath libraryPath)
@@ -195,74 +174,11 @@ public class LibraryRepository(IFileSystem fileSystem, IDbContextFactory<TvConte
// local scan path (via GetLibrary) and is null on the remote (Jellyfin) sync path, which used
// to NRE every Jellyfin music-video scan here (ersatztv#488). The local scanners already hit
// the db once per folder via GetParentFolderId, so this adds no new query pattern.
LibraryFolder knownFolder = await GetFolder(dbContext, libraryPath.Id, folder);
// add new folder to library path
if (knownFolder is null)
{
LibraryFolder newFolder = CreateNewFolder(libraryPath, maybeParentFolder, folder);
try
{
await dbContext.LibraryFolders.AddAsync(newFolder);
await dbContext.SaveChangesAsync();
knownFolder = newFolder;
}
catch (DbUpdateException ex) when (TvContext.IsUniqueConstraintViolation(ex))
{
// ersatztv#491: the lookup above is not atomic with this insert, so a concurrent caller
// scanning the same folder can slip its row in between. The unique index on
// (LibraryPathId, PathHash) turns that lost race into a constraint violation instead of a
// duplicate row; adopt the winner's row rather than failing the scan. Detach first so the
// failed insert is not retried by anything reusing this context.
dbContext.Entry(newFolder).State = EntityState.Detached;
knownFolder = await GetFolder(dbContext, libraryPath.Id, folder);
if (knownFolder is null)
{
// no winner to adopt — the violation came from somewhere else, so don't swallow it
throw;
}
}
}
else if (string.IsNullOrEmpty(knownFolder.PathHash))
{
// Heal a row created before the PathHash column existed, so it participates in the unique
// index from here on (a null hash is distinct from every other value, so it does not).
//
// This is opportunistic maintenance on a hot scan path, so it must never be able to abort a
// scan. It goes through EF rather than a raw Dapper UPDATE precisely so a collision surfaces
// as a classifiable DbUpdateException instead of a bare provider exception, and a lost heal
// is simply left for the next scan. Reachable only if some other row already owns
// (LibraryPathId, hash) — a legacy duplicate the migration's dedupe could not see (e.g. one
// with a NULL Path, which `NULL = NULL` excludes from its grouping).
string pathHash = PathUtils.GetPathHash(folder);
LibraryFolder tracked = null;
try
{
// the predicate must agree with the IsNullOrEmpty guard above, or a PathHash = '' row would
// enter this branch, match nothing, and silently never heal
tracked = await dbContext.LibraryFolders
.FirstOrDefaultAsync(f => f.Id == knownFolder.Id && (f.PathHash == null || f.PathHash == ""));
if (tracked is not null)
{
tracked.PathHash = pathHash;
await dbContext.SaveChangesAsync();
knownFolder.PathHash = pathHash;
}
}
catch (DbUpdateException ex) when (
TvContext.IsUniqueConstraintViolation(ex) || ex is DbUpdateConcurrencyException)
{
// Either another row already owns this hash, or the row was deleted out from under us by a
// concurrent library edit (DbUpdateConcurrencyException derives from DbUpdateException but
// carries no provider exception, so the classifier does NOT recognize it). Both mean "the
// heal is moot" — leave the row unhealed rather than fail the scan, per the invariant above.
if (tracked is not null)
{
// drop the failed change so it cannot be replayed by a later save on this context
dbContext.Entry(tracked).State = EntityState.Detached;
}
}
}
LibraryFolder knownFolder = await dbContext.LibraryFolders
.AsNoTracking()
.Filter(f => f.LibraryPathId == libraryPath.Id && f.Path == folder)
.FirstOrDefaultAsync()
?? CreateNewFolder(libraryPath, maybeParentFolder, folder);
// update parent folder if not present
foreach (int parentFolder in maybeParentFolder)
@@ -277,6 +193,13 @@ public class LibraryRepository(IFileSystem fileSystem, IDbContextFactory<TvConte
}
}
// add new folder to library path
if (knownFolder.Id < 1)
{
await dbContext.LibraryFolders.AddAsync(knownFolder);
await dbContext.SaveChangesAsync();
}
return knownFolder;
}
@@ -298,71 +221,6 @@ public class LibraryRepository(IFileSystem fileSystem, IDbContextFactory<TvConte
new { Path = normalizedLibraryPath, libraryPath.Id });
}
/// <summary>
/// The in-memory half of <see cref="GetFolder" />, lifted out so the ordinal decision is pinned by a
/// test with no database at all: the collation behaviour that makes it necessary is MySQL-only, so a
/// SQLite-backed test cannot exercise it (SQLite's <c>=</c> on TEXT is already binary and never
/// returns the case-differing candidate). Given the candidates a case-INsensitive server may return,
/// pick the one whose path matches ordinally; callers pass them lowest <c>Id</c> first.
/// </summary>
public static LibraryFolder ResolveExact(IReadOnlyList<LibraryFolder> candidates, string folder)
{
for (var i = 0; i < candidates.Count; i++)
{
if (string.Equals(candidates[i].Path, folder, StringComparison.Ordinal))
{
return candidates[i];
}
}
return null;
}
/// <summary>
/// Resolve a folder by its exact path within a library path.
/// <para>
/// The SQL equality is only a *narrowing* filter, not the identity test. On MySQL, `Path` is a
/// `longtext` whose collation the schema does not pin — only the `utf8mb4` charset — so the
/// effective comparison is whatever the server defaults to, and it differs from byte equality:
/// <list type="bullet">
/// <item>
/// always case-INsensitive: both plausible defaults are `_ci` (8.4 verified:
/// `utf8mb4_0900_ai_ci`; older servers `utf8mb4_general_ci`), which is why
/// <see cref="TvContext.CaseInsensitiveCollation" /> exists at all;
/// </item>
/// <item>
/// possibly PAD SPACE, making trailing spaces insignificant — true of
/// `utf8mb4_general_ci`, but NOT of `utf8mb4_0900_ai_ci`, which is NO PAD. So this axis
/// is server-dependent rather than guaranteed, and must be tolerated rather than
/// assumed either way.
/// </item>
/// </list>
/// `Path = @folder` can therefore also match siblings differing only in case, or (on a PAD
/// SPACE server) in trailing whitespace — all legal on a case-sensitive filesystem, and all
/// preserved by the #491 migration. Crucially the SQL predicate is a *superset*: every such
/// quirk makes it more permissive, never less, so it cannot miss a byte-exact match. Identity
/// is then settled in memory by <see cref="ResolveExact" /> with an ORDINAL comparison,
/// matching <c>PathUtils.GetPathHash</c>, which hashes the exact bytes. Without this the lookup
/// and the hash disagree, and the PathHash heal could stamp one sibling's hash onto the other's
/// row.
/// </para>
/// <para>
/// Ordered by Id so the result is deterministic: an unordered <c>FirstOrDefault</c> may return a
/// different candidate run to run as the query plan changes (adding the composite index alone
/// can flip it), which would make the heal non-idempotent.
/// </para>
/// </summary>
private static async Task<LibraryFolder> GetFolder(TvContext dbContext, int libraryPathId, string folder)
{
List<LibraryFolder> candidates = await dbContext.LibraryFolders
.AsNoTracking()
.Filter(f => f.LibraryPathId == libraryPathId && f.Path == folder)
.OrderBy(f => f.Id)
.ToListAsync();
return ResolveExact(candidates, folder);
}
private static LibraryFolder CreateNewFolder(LibraryPath libraryPath, Option<int> maybeParentFolder, string folder)
{
int? parentId = null;
@@ -374,7 +232,6 @@ public class LibraryRepository(IFileSystem fileSystem, IDbContextFactory<TvConte
return new LibraryFolder
{
Path = folder,
PathHash = PathUtils.GetPathHash(folder),
Etag = null,
LibraryPathId = libraryPath.Id,
ParentId = parentId
-45
View File
@@ -202,51 +202,6 @@ public class ToolCatalogTests
(tool.QueryParameters ?? new HashSet<string>()).ShouldNotContain("ifMatch");
}
// #616: the catalog described pageNum as "1-based" while every paged REST controller defaults it
// to 0 and skips pageNum * pageSize. A caller that trusted the description started at page 1 and
// silently lost the first page — no error, just a short set that reads like missing data. The
// description is the whole contract an MCP client has, so it is pinned here for EVERY paged tool.
[Test]
public void Paged_Tools_Should_Document_PageNum_As_Zero_Based()
{
ToolDefinition[] paged = ToolCatalog.All
.Where(t => t.InputSchema.RootElement.GetProperty("properties").TryGetProperty("pageNum", out _))
.ToArray();
// Guard the guard twice over. An emptiness check alone is not enough: this test filters on
// tools that ALREADY declare pageNum, so a tool wrapping a paged endpoint while declaring no
// paging args escapes the filter entirely and the test still passes. That is not
// hypothetical — ersatztv_list_playouts and ersatztv_get_playout_items did exactly that, and
// because ToolArgumentValidator rejects undeclared arguments, an MCP caller was hard-capped
// at the first 100 rows with no way to ask for more. So the expected set is named here: a
// new tool over a paged endpoint must be added to it, and dropping paging from any of these
// fails the test rather than silently shrinking its scope.
string[] mustDeclarePaging =
[
"ersatztv_get_collection_items",
"ersatztv_get_playout_items",
"ersatztv_list_playouts",
"ersatztv_search",
"ersatztv_search_all_items"
];
paged.Select(t => t.Name).OrderBy(n => n, StringComparer.Ordinal)
.ShouldBe(mustDeclarePaging.OrderBy(n => n, StringComparer.Ordinal));
foreach (ToolDefinition tool in paged)
{
string description = tool.InputSchema.RootElement
.GetProperty("properties")
.GetProperty("pageNum")
.GetProperty("description")
.GetString()
.ShouldNotBeNull();
description.ShouldContain("0-based");
description.ShouldNotContain("1-based");
}
}
[Test]
public void Scan_Library_Tool_Should_Register_Deep_As_A_Query_Parameter()
{
+5 -26
View File
@@ -29,14 +29,9 @@ public static class ToolCatalog
Get("ersatztv_list_schedules", "List schedules.", "/api/v1/schedules"),
Get("ersatztv_get_schedule", "Get a schedule by id.", "/api/v1/schedules/{id}", IdPath("Schedule id.")),
Get("ersatztv_get_schedule_items", "Get a schedule's items. Emits the schedule ETag.", "/api/v1/schedules/{id}/items", IdPath("Schedule id.")),
Get("ersatztv_list_playouts", "List playouts (paged).", "/api/v1/playouts", [], Page()),
Get("ersatztv_list_playouts", "List playouts.", "/api/v1/playouts"),
Get("ersatztv_get_playout", "Get a playout by id.", "/api/v1/playouts/{id}", IdPath("Playout id.")),
Get(
"ersatztv_get_playout_items",
"Get upcoming items (and unscheduled gaps) for a playout (paged).",
"/api/v1/playouts/{id}/items",
[IdPath("Playout id.")],
Page()),
Get("ersatztv_get_playout_items", "Get upcoming items (and unscheduled gaps) for a playout.", "/api/v1/playouts/{id}/items", IdPath("Playout id.")),
Get("ersatztv_list_ffmpeg_profiles", "List FFmpeg profiles.", "/api/v1/ffmpeg/profiles"),
Get("ersatztv_get_ffmpeg_profile", "Get an FFmpeg profile by id.", "/api/v1/ffmpeg/profiles/{id}", IdPath("FFmpeg profile id.")),
Get(
@@ -166,12 +161,7 @@ public static class ToolCatalog
"ersatztv_reset_channel_playout",
"Queue a rebuild of a channel's playout (202 Accepted; 409 if a build is already running).",
"/api/v1/channels/{id}/playout/reset",
[
IdPath(
"CHANNEL id — not the playout id. The two id spaces overlap numerically, so "
+ "passing a playout id here silently resets a different channel; take this "
+ "value from a playout row's channelId field (issue #616).")
],
[IdPath("Channel id.")],
[Str("mode", "Optional playout build mode; omit for the default. GET a playout to see valid values.", arg: In.Query)]),
Delete("ersatztv_delete_channel", "Delete a channel.", "/api/v1/channels/{id}", IdPath("Channel id.")),
Put(
@@ -240,21 +230,10 @@ public static class ToolCatalog
private static Arg ObjArray(string name, string description, bool required = false) =>
new(name, "array", description, required, In.Body, ItemType: "object");
// Paging mirrors the REST API it wraps, which is 0-BASED everywhere (issue #616): every paged
// controller defaults pageNum to 0 and skips `pageNum * pageSize`. The description said "1-based",
// so a caller that trusted it silently skipped the first page and read the result as data loss.
// Documented rather than translated: a 1-based MCP over a 0-based API would make the same
// parameter name mean two different things depending on which surface you were reading.
// pageSize is clamped server-side and the cap is PER-ENDPOINT (100 for most reads, 200 for
// auto-tune members, 1000 for search/all-items). The invariant is not a single number: the
// offset always derives from the EFFECTIVE page size, never the requested one.
private static Arg[] Page() =>
[
Int("pageNum", "0-based page number; the first page is 0 (optional, default 0).", arg: In.Query),
Int(
"pageSize",
"Page size (optional). Clamped server-side, so pages may be narrower than requested.",
arg: In.Query)
Int("pageNum", "1-based page number (optional).", arg: In.Query),
Int("pageSize", "Page size (optional).", arg: In.Query)
];
// The channel create/update body (CreateChannelRequest / UpdateChannelRequest — the id comes from
-7
View File
@@ -24,7 +24,6 @@ using ErsatzTV.Infrastructure.Emby;
using ErsatzTV.Infrastructure.Images;
using ErsatzTV.Infrastructure.Jellyfin;
using ErsatzTV.Infrastructure.Metadata;
using ErsatzTV.Infrastructure.MySql.Data;
using ErsatzTV.Infrastructure.Plex;
using ErsatzTV.Infrastructure.Runtime;
using ErsatzTV.Infrastructure.Search;
@@ -153,15 +152,10 @@ public class Program
}
});
// Keep this block in sync with ErsatzTV/Startup.cs — the scanner is a SEPARATE process
// (launched by CallLibraryScannerHandler), so any TvContext provider static the host wires
// has to be wired here too or it silently keeps its default in every scan.
// ProviderStaticsWiringTests enforces that parity.
if (databaseProvider == Provider.Sqlite.Name)
{
TvContext.LastInsertedRowId = "last_insert_rowid()";
TvContext.CaseInsensitiveCollation = "NOCASE";
TvContext.IsUniqueConstraintViolation = SqliteErrorClassifier.IsUniqueConstraintViolation;
SqlMapper.AddTypeHandler(new DateTimeOffsetHandler());
SqlMapper.AddTypeHandler(new GuidHandler());
@@ -172,7 +166,6 @@ public class Program
{
TvContext.LastInsertedRowId = "last_insert_id()";
TvContext.CaseInsensitiveCollation = "utf8mb4_general_ci";
TvContext.IsUniqueConstraintViolation = MySqlErrorClassifier.IsUniqueConstraintViolation;
}
services.AddHttpClient();
@@ -61,93 +61,6 @@ public class GetCollectionItemsHandlerTests
page.Page.Select(i => i.Title).ShouldBe(["Zeta", "Alpha", "Beta"]);
}
// Paging semantics, pinned because #616 reported them as two bugs that measurement did not
// support. pageNum is 0-BASED (the trap: the MCP catalog documented it as 1-based, so a caller
// starting at 1 silently skipped the first page and read a short set as data loss).
[Test]
public async Task Handle_Should_Treat_PageNum_As_Zero_Based()
{
await SeedNumberedCollection(150);
var handler = new GetCollectionItemsHandler(_db.Factory);
Either<BaseError, PagedLibraryBrowseItemsResponseModel> first =
await handler.Handle(new GetCollectionItems(10, 0, 10), CancellationToken.None);
Either<BaseError, PagedLibraryBrowseItemsResponseModel> second =
await handler.Handle(new GetCollectionItems(10, 1, 10), CancellationToken.None);
// Page 0 is the FIRST page, not a skipped one; page 1 is the second.
first.RightToSeq().Single().Page.Select(i => i.Title).First().ShouldBe("Item 001");
second.RightToSeq().Single().Page.Select(i => i.Title).First().ShouldBe("Item 011");
}
// The second half of #616's claim was that an over-large pageSize caps the returned page but
// leaves the OFFSET computed from the requested value, so page 1 at pageSize=500 would land past
// item 500. It does not: the size is clamped first and the offset derives from the clamped value.
[Test]
public async Task Handle_Should_Derive_Offset_From_The_Clamped_PageSize()
{
await SeedNumberedCollection(150);
var handler = new GetCollectionItemsHandler(_db.Factory);
// pageSize 500 clamps to 100, so page 1 starts at item 101 and runs to the end (50 items).
// If the offset honored the requested 500, this page would start past the collection and be
// empty — which is exactly what the mutation of this fix produces.
Either<BaseError, PagedLibraryBrowseItemsResponseModel> result =
await handler.Handle(new GetCollectionItems(10, 1, 500), CancellationToken.None);
PagedLibraryBrowseItemsResponseModel page = result.RightToSeq().Single();
page.TotalCount.ShouldBe(150);
page.Page.Count.ShouldBe(50);
page.Page.Select(i => i.Title).First().ShouldBe("Item 101");
page.Page.Select(i => i.Title).Last().ShouldBe("Item 150");
}
private async Task SeedNumberedCollection(int count)
{
await using TvContext context = _db.CreateContext();
var library = new LocalLibrary
{
Id = 1,
Name = "Library",
MediaKind = LibraryMediaKind.Movies,
Paths = []
};
var path = new LibraryPath
{
Id = 1,
Path = "/media",
Library = library,
LibraryFolders = [],
MediaItems = []
};
library.Paths.Add(path);
var collection = new Collection
{
Id = 10,
Name = "Manual",
UseCustomPlaybackOrder = false,
MediaItems = [],
CollectionItems = [],
MultiCollections = [],
MultiCollectionItems = []
};
var movies = new List<Movie>();
for (var i = 1; i <= count; i++)
{
// Zero-padded so the handler's title ordering matches numeric order.
movies.Add(MakeMovie(1000 + i, path, $"Item {i:D3}"));
collection.CollectionItems.Add(new CollectionItem { MediaItemId = 1000 + i });
}
context.LocalLibraries.Add(library);
context.Movies.AddRange(movies);
context.Collections.Add(collection);
await context.SaveChangesAsync();
}
private async Task SeedCollectionGraph(bool useCustomPlaybackOrder)
{
await using TvContext context = _db.CreateContext();
@@ -166,25 +166,6 @@ public class PlayoutControllerTests
.Seed.ShouldBe(4242);
}
// #616: the playout DETAIL response carried channelName/channelNumber but no channelId, while
// reset_channel_playout takes a CHANNEL id. The two id spaces overlap numerically, so a caller
// that reached for the row's `id` reset a different channel and got a plausible 202 back. The
// list rows gained channelId in #297; this pins the same field on the detail response, and the
// distinct ids below prove it is the channel's, not the playout's.
[Test]
public async Task GetById_Should_Surface_ChannelId_Distinct_From_PlayoutId()
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ChannelId = 400 }));
IActionResult result = await _controller.GetById(9, CancellationToken.None);
PlayoutResponseModel body = result.ShouldBeOfType<OkObjectResult>()
.Value.ShouldBeOfType<PlayoutResponseModel>();
body.Id.ShouldBe(9);
body.ChannelId.ShouldBe(400);
}
[Test]
public async Task GetAll_Should_Surface_Seed()
{
@@ -1451,7 +1432,6 @@ public class PlayoutControllerTests
vm.ScheduleKind,
vm.ChannelName,
vm.ChannelNumber,
vm.ChannelId,
vm.PlayoutMode,
vm.ScheduleName,
vm.ScheduleFile,
@@ -1,302 +0,0 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Data.Repositories;
using ErsatzTV.Infrastructure.Sqlite.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using IFileSystem = System.IO.Abstractions.IFileSystem;
namespace ErsatzTV.Tests.Integration;
/// <summary>
/// ersatztv#491: <c>ILibraryRepository.GetOrAddFolder</c> is a check-then-insert, so two callers
/// racing the same <c>(LibraryPathId, Path)</c> both miss the lookup and both insert. The fix is a
/// unique index on <c>(LibraryPathId, PathHash)</c> plus a catch-and-re-read in the repository, so
/// the loser adopts the winner's row instead of creating a duplicate.
/// </summary>
[TestFixture]
public class LibraryFolderConcurrencyTests
{
private const string LibraryPathValue = "/data/music";
private const string FolderPath = "/data/music/artist1";
private static LibraryRepository Repository(IDbContextFactory<TvContext> factory) =>
new(Substitute.For<IFileSystem>(), factory);
private static async Task<int> SeedLibraryPath(Func<TvContext> createContext)
{
await using TvContext context = createContext();
var libraryPath = new LibraryPath { Path = LibraryPathValue };
await context.LibraryPaths.AddAsync(libraryPath);
await context.SaveChangesAsync();
return libraryPath.Id;
}
private static async Task<int> FolderCount(Func<TvContext> createContext, int libraryPathId, string path)
{
await using TvContext context = createContext();
return await context.LibraryFolders.CountAsync(f => f.LibraryPathId == libraryPathId && f.Path == path);
}
private static async Task InsertFolderRaw(
SharedCacheTvContext db,
int libraryPathId,
string path,
string pathHash,
CancellationToken cancellationToken = default)
{
await using SqliteConnection connection = db.OpenConnection();
await using SqliteCommand command = connection.CreateCommand();
command.CommandText =
"INSERT INTO \"LibraryFolder\" (\"LibraryPathId\", \"Path\", \"PathHash\", \"Etag\", \"ParentId\") " +
"VALUES ($libraryPathId, $path, $pathHash, NULL, NULL)";
command.Parameters.AddWithValue("$libraryPathId", libraryPathId);
command.Parameters.AddWithValue("$path", path);
command.Parameters.AddWithValue("$pathHash", (object?)pathHash ?? DBNull.Value);
await command.ExecuteNonQueryAsync(cancellationToken);
}
/// <summary>
/// Simulates the concurrent "winner": exactly once, on a SEPARATE connection, insert the same
/// folder and commit — AFTER the intercepted context read the (stale) absent lookup but BEFORE its
/// own INSERT runs. This interposes the race deterministically instead of hoping for a timing
/// window. <see cref="Fired" /> proves the race actually happened (non-vacuity).
/// </summary>
private sealed class InsertConflictingFolderOnce(SharedCacheTvContext db, int libraryPathId, string path)
: SaveChangesInterceptor
{
private int _fired;
public int Fired => _fired;
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
if (Interlocked.Exchange(ref _fired, 1) == 0)
{
await InsertFolderRaw(db, libraryPathId, path, PathUtils.GetPathHash(path), cancellationToken);
}
return result;
}
}
/// <summary>Counts insert attempts so the multi-threaded test can prove it really raced.</summary>
private sealed class CountSaveAttempts : SaveChangesInterceptor
{
private int _attempts;
public int Attempts => _attempts;
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref _attempts);
return ValueTask.FromResult(result);
}
}
// ----- Negative control #1: the index itself. Without the new unique index this test fails, because
// the second insert simply succeeds and there is no violation to classify. -----
[Test]
public async Task Duplicate_LibraryFolder_Insert_Throws_A_Classified_UniqueViolation()
{
await using SharedCacheTvContext db = await SharedCacheTvContext.CreateAsync("etv491-index");
int libraryPathId = await SeedLibraryPath(db.CreateContext);
string hash = PathUtils.GetPathHash(FolderPath);
await InsertFolderRaw(db, libraryPathId, FolderPath, hash);
await using TvContext context = db.CreateContext();
await context.LibraryFolders.AddAsync(
new LibraryFolder { LibraryPathId = libraryPathId, Path = FolderPath, PathHash = hash });
DbUpdateException ex = await Should.ThrowAsync<DbUpdateException>(() => context.SaveChangesAsync());
SqliteErrorClassifier.IsUniqueConstraintViolation(ex).ShouldBeTrue();
// and the classifier is not a blanket "true"
SqliteErrorClassifier.IsUniqueConstraintViolation(
new DbUpdateException("nope", new InvalidOperationException())).ShouldBeFalse();
}
// The migration leaves pre-#491 rows with a null hash; a unique index treats nulls as distinct, so
// applying the index to an existing database can never fail on them. Documents that premise.
[Test]
public async Task Legacy_Null_PathHash_Rows_Do_Not_Collide()
{
await using SharedCacheTvContext db = await SharedCacheTvContext.CreateAsync("etv491-nulls");
int libraryPathId = await SeedLibraryPath(db.CreateContext);
await InsertFolderRaw(db, libraryPathId, "/data/music/a", null);
await InsertFolderRaw(db, libraryPathId, "/data/music/b", null);
await using TvContext context = db.CreateContext();
(await context.LibraryFolders.CountAsync(f => f.LibraryPathId == libraryPathId)).ShouldBe(2);
}
// ----- The deterministic cross-connection race through the real repository -----
[Test]
public async Task GetOrAddFolder_Losing_The_Race_Adopts_The_Winner_Instead_Of_Duplicating()
{
await using SharedCacheTvContext db = await SharedCacheTvContext.CreateAsync("etv491-race");
int libraryPathId = await SeedLibraryPath(db.CreateContext);
var libraryPath = new LibraryPath { Id = libraryPathId, Path = LibraryPathValue, LibraryFolders = null };
var racer = new InsertConflictingFolderOnce(db, libraryPathId, FolderPath);
LibraryRepository repository = Repository(db.Factory(racer));
LibraryFolder result = await repository.GetOrAddFolder(libraryPath, Option<int>.None, FolderPath);
racer.Fired.ShouldBe(1); // the race genuinely occurred — this assertion is the vacuity guard
result.ShouldNotBeNull();
result.Id.ShouldBeGreaterThan(0);
result.Path.ShouldBe(FolderPath);
(await FolderCount(db.CreateContext, libraryPathId, FolderPath)).ShouldBe(1);
// the returned row is the winner's persisted row, not a phantom
await using TvContext context = db.CreateContext();
LibraryFolder persisted = await context.LibraryFolders.SingleAsync(f => f.LibraryPathId == libraryPathId);
persisted.Id.ShouldBe(result.Id);
persisted.PathHash.ShouldBe(PathUtils.GetPathHash(FolderPath));
}
// The loser must still apply the parent id it was asked to set — to the WINNER's row.
[Test]
public async Task GetOrAddFolder_Losing_The_Race_Still_Persists_The_ParentId()
{
await using SharedCacheTvContext db = await SharedCacheTvContext.CreateAsync("etv491-race-parent");
int libraryPathId = await SeedLibraryPath(db.CreateContext);
var libraryPath = new LibraryPath { Id = libraryPathId, Path = LibraryPathValue, LibraryFolders = null };
LibraryFolder parent = await Repository(db.Factory())
.GetOrAddFolder(libraryPath, Option<int>.None, LibraryPathValue);
var racer = new InsertConflictingFolderOnce(db, libraryPathId, FolderPath);
LibraryFolder result = await Repository(db.Factory(racer))
.GetOrAddFolder(libraryPath, Option<int>.Some(parent.Id), FolderPath);
racer.Fired.ShouldBe(1);
result.ParentId.ShouldBe(parent.Id);
await using TvContext context = db.CreateContext();
LibraryFolder persisted = await context.LibraryFolders.SingleAsync(f => f.Path == FolderPath);
persisted.Id.ShouldBe(result.Id);
persisted.ParentId.ShouldBe(parent.Id);
}
// ----- Negative control #2: invert the real condition (the provider classifier) and the SAME race
// must blow up, proving the catch in GetOrAddFolder is load-bearing rather than decorative. -----
[Test]
public async Task GetOrAddFolder_Rethrows_When_The_Provider_Does_Not_Classify_The_Violation()
{
Func<DbUpdateException, bool> original = TvContext.IsUniqueConstraintViolation;
try
{
await using SharedCacheTvContext db = await SharedCacheTvContext.CreateAsync("etv491-negctl");
int libraryPathId = await SeedLibraryPath(db.CreateContext);
var libraryPath = new LibraryPath { Id = libraryPathId, Path = LibraryPathValue, LibraryFolders = null };
var racer = new InsertConflictingFolderOnce(db, libraryPathId, FolderPath);
LibraryRepository repository = Repository(db.Factory(racer));
TvContext.IsUniqueConstraintViolation = _ => false;
await Should.ThrowAsync<DbUpdateException>(
() => repository.GetOrAddFolder(libraryPath, Option<int>.None, FolderPath));
racer.Fired.ShouldBe(1);
}
finally
{
TvContext.IsUniqueConstraintViolation = original;
}
}
// ----- N threads x rounds over a single (LibraryPathId, Path) -----
[Test]
public async Task Concurrent_GetOrAddFolder_Never_Produces_Duplicate_Rows()
{
const int threads = 8;
const int rounds = 10;
await using SharedCacheTvContext db = await SharedCacheTvContext.CreateAsync("etv491-threads");
int libraryPathId = await SeedLibraryPath(db.CreateContext);
var counter = new CountSaveAttempts();
IDbContextFactory<TvContext> factory = db.Factory(counter);
for (var round = 0; round < rounds; round++)
{
string path = $"{LibraryPathValue}/round{round}";
using var gate = new Barrier(threads);
var tasks = new Task<LibraryFolder>[threads];
for (var thread = 0; thread < threads; thread++)
{
tasks[thread] = Task.Run(async () =>
{
// every thread carries its own detached LibraryPath, as the scanners do
var libraryPath = new LibraryPath
{
Id = libraryPathId, Path = LibraryPathValue, LibraryFolders = null
};
gate.SignalAndWait();
return await Repository(factory).GetOrAddFolder(libraryPath, Option<int>.None, path);
});
}
LibraryFolder[] results = await Task.WhenAll(tasks);
// every caller got the same single row back...
results.Select(f => f.Id).Distinct().Count().ShouldBe(1);
results[0].Id.ShouldBeGreaterThan(0);
// ...and exactly one row exists for it
(await FolderCount(db.CreateContext, libraryPathId, path)).ShouldBe(1);
}
await using TvContext context = db.CreateContext();
(await context.LibraryFolders.CountAsync(f => f.LibraryPathId == libraryPathId)).ShouldBe(rounds);
// Vacuity guard: one insert attempt per round would mean the threads never actually collided and
// the test proved nothing. More attempts than rounds means at least one caller lost the race and
// was rescued by the index + catch.
counter.Attempts.ShouldBeGreaterThan(rounds);
}
// ----- SetEtag is the repository's other check-then-insert on LibraryFolder -----
[Test]
public async Task SetEtag_Losing_The_Race_Updates_The_Winner_Instead_Of_Duplicating()
{
await using SharedCacheTvContext db = await SharedCacheTvContext.CreateAsync("etv491-setetag");
int libraryPathId = await SeedLibraryPath(db.CreateContext);
var libraryPath = new LibraryPath { Id = libraryPathId, Path = LibraryPathValue, LibraryFolders = null };
var racer = new InsertConflictingFolderOnce(db, libraryPathId, FolderPath);
LibraryRepository repository = Repository(db.Factory(racer));
await repository.SetEtag(libraryPath, Option<LibraryFolder>.None, FolderPath, "etag-1");
racer.Fired.ShouldBe(1);
await using TvContext context = db.CreateContext();
LibraryFolder persisted = await context.LibraryFolders.SingleAsync(f => f.Path == FolderPath);
persisted.Etag.ShouldBe("etag-1");
}
}
@@ -1,445 +0,0 @@
using Dapper;
using ErsatzTV.Infrastructure;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.MySql.Data;
using ErsatzTV.Infrastructure.Sqlite.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using MySqlConnector;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Integration;
public enum TestProvider
{
Sqlite,
MySql
}
/// <summary>
/// ersatztv#491: the unique index on <c>LibraryFolder(LibraryPathId, PathHash)</c> ships with an
/// audit/cleanup of pre-existing duplicate rows (reachable before #488, when the folder lookup read a
/// scan-start in-memory snapshot and could not see a folder created earlier in the same scan). This
/// drives the REAL migration against a database seeded at the previous migration, so the cleanup SQL
/// is exercised rather than restated.
/// <para>
/// Runs against BOTH providers from ONE fixture body. The dedupe deletes rows irreversibly and its
/// correctness turns on string-comparison semantics that differ per provider — two MySQL-only
/// collation defects escaped review in the #491 session (a case-insensitive grouping, then a PAD
/// SPACE one), and neither was reachable from a SQLite-only test, nor from CI's MySQL job, which
/// only applies migrations to a fresh EMPTY database and so executes no dedupe rows at all.
/// Parameterizing one fixture is what makes "the two providers agree" a checked property rather
/// than an assumption; a separate MySQL-only copy would drift and recreate the gap.
/// </para>
/// <para>
/// MySQL needs a live server, supplied via <c>ETV_TEST_MYSQL_CONNECTION</c>. Without it the MySQL
/// fixture <b>ignores</b> — a visible skip, never a silent pass — so local runs need no MySQL. CI
/// sets <c>ETV_REQUIRE_MYSQL_TESTS=1</c>, which turns that skip into a hard failure, so the gate
/// cannot quietly degrade into "connected to nothing and passed".
/// </para>
/// </summary>
[TestFixture(TestProvider.Sqlite)]
[TestFixture(TestProvider.MySql)]
[NonParallelizable]
public class LibraryFolderDedupeMigrationTests(TestProvider provider)
{
// the migration immediately preceding Add_LibraryFolder_PathHash_UniqueIndex
private const string PreviousMigration = "Add_Channel_Origin";
private const string MySqlConnectionVariable = "ETV_TEST_MYSQL_CONNECTION";
private const string MySqlRequiredVariable = "ETV_REQUIRE_MYSQL_TESTS";
private string _databasePath = null!;
private string? _mySqlConnectionString;
// Seeding writes deliberately partial object graphs (a LibraryPath with no Library, a MediaFile with
// no MediaVersion), so it runs with foreign keys OFF.
private DbContextOptions<TvContext> _seedOptions = null!;
// The migration itself runs with foreign keys ON, matching production. This matters: the single most
// dangerous statement in the #491 migration is DELETE FROM LibraryFolder against two Restrict foreign
// keys (MediaFile.LibraryFolderId, LibraryFolder.ParentId). With enforcement off, a wrong repoint
// order would still pass; with it on, the delete fails loudly.
private DbContextOptions<TvContext> _migrateOptions = null!;
[SetUp]
public async Task SetUp()
{
if (provider is TestProvider.Sqlite)
{
TvContext.IsSqlite = true;
TvContext.LastInsertedRowId = "last_insert_rowid()";
TvContext.CaseInsensitiveCollation = "NOCASE";
TvContext.IsUniqueConstraintViolation = SqliteErrorClassifier.IsUniqueConstraintViolation;
_databasePath = Path.Combine(Path.GetTempPath(), $"etv491-{Guid.NewGuid():N}.sqlite3");
_seedOptions = SqliteOptions(foreignKeys: false);
_migrateOptions = SqliteOptions(foreignKeys: true);
return;
}
string? baseConnectionString = Environment.GetEnvironmentVariable(MySqlConnectionVariable);
if (string.IsNullOrWhiteSpace(baseConnectionString))
{
string message =
$"{MySqlConnectionVariable} is not set, so the MySql half of the #491 dedupe fixture cannot "
+ "run. The dedupe deletes rows irreversibly and its correctness is provider-specific, so "
+ "this coverage is not optional in CI.";
// A skip is fine locally; in CI it is the very failure mode this fixture exists to prevent.
if (IsTrue(Environment.GetEnvironmentVariable(MySqlRequiredVariable)))
{
Assert.Fail($"{message} {MySqlRequiredVariable} is set, so this is a failure, not a skip.");
}
Assert.Ignore($"{message} Set it to run this locally.");
}
// A database of our own, NOT the one the surrounding CI step migrates, and a FRESH one per test:
// isolation by construction. A name that has never been used cannot contain another test's rows,
// so no wipe has to succeed for the fixture to be correct. It is not created here — the test's own
// MigrateAsync(PreviousMigration) creates it, which keeps EF the single owner of the schema.
_mySqlConnectionString =
new MySqlConnectionStringBuilder(baseConnectionString) { Database = $"etv491_{Guid.NewGuid():N}" }
.ConnectionString;
TvContext.IsSqlite = false;
TvContext.LastInsertedRowId = "last_insert_id()";
TvContext.CaseInsensitiveCollation = "utf8mb4_general_ci";
TvContext.IsUniqueConstraintViolation = MySqlErrorClassifier.IsUniqueConstraintViolation;
// AutoDetect opens its own connection, so resolve the version once and share it between the two
// option sets instead of connecting twice per test.
ServerVersion serverVersion = ServerVersion.AutoDetect(_mySqlConnectionString);
_seedOptions = MySqlOptions(serverVersion);
_migrateOptions = MySqlOptions(serverVersion);
}
/// <summary>
/// Drop this test's database and clear the connection pool that was keyed to it.
/// <para>
/// Both halves are required, and earlier revisions of this fixture each got one wrong. Measured
/// against a real 8.4 server:
/// </para>
/// <list type="number">
/// <item>
/// <b>A pooled session outlives <c>DROP DATABASE</c>.</b> Reopening the dropped database's
/// connection string succeeds — MySqlConnector hands back the still-alive session whose
/// default schema is gone — so whether a later caller sees success or <c>Unknown database</c>
/// depends on whether the pool reuses that session or opens a fresh one (a fresh handshake
/// names the dropped schema and fails 1049). <c>ClearPool</c> after the drop removes it.
/// Note this hazard needs the connection string to be REUSED after the drop, which a
/// never-repeated database name already makes impossible; clearing the pool is the belt to
/// that brace, and closes the leak below.
/// </item>
/// <item>
/// <b>An uncleared pool leaks a server connection per test.</b> MySqlConnector keys pools by
/// connection string, so a fresh database name means a fresh pool; left uncleared it leaked
/// ~1 server thread per iteration and eventually exhausted <c>max_connections</c>.
/// <c>ClearPoolAsync</c> on that exact connection string fixes it completely — measured at
/// 0 leaked threads over 30 iterations — so per-test isolation costs nothing. A previous
/// revision instead collapsed to one shared database to stop the leak; that traded isolation
/// for a wipe that has to succeed, and when it silently did not, the second test seeded on
/// top of the first's rows and failed with a duplicate primary key.
/// </item>
/// </list>
/// <para>
/// EF owns the drop: <c>EnsureDeletedAsync</c> is guarded (a no-op when the database is absent,
/// unlike a raw <c>DROP DATABASE</c>) and uses the same connection string EF migrated with.
/// </para>
/// </summary>
private async Task DropMySqlDatabase()
{
await using (TvContext context = MigrateContext())
{
await context.Database.EnsureDeletedAsync();
}
await using var probe = new MySqlConnection(_mySqlConnectionString);
await MySqlConnection.ClearPoolAsync(probe);
}
[TearDown]
public async Task TearDown()
{
if (provider is TestProvider.Sqlite)
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
foreach (string path in new[] { _databasePath, $"{_databasePath}-wal", $"{_databasePath}-shm" })
{
if (File.Exists(path))
{
File.Delete(path);
}
}
return;
}
if (_mySqlConnectionString is not null)
{
await DropMySqlDatabase();
_mySqlConnectionString = null;
}
}
[Test]
public async Task Migration_Collapses_Duplicate_Folders_And_Repoints_Their_Dependents()
{
await using (TvContext context = SeedContext())
{
await context.Database.MigrateAsync(PreviousMigration);
await using SeedSession seed = await SeedSession.OpenAsync(context, provider);
// one library path with the SAME folder recorded three times (ids 1, 2, 3), an unrelated
// folder (4), a child parented on one of the duplicates (5), a case-differing sibling (6),
// a folder parented on its own duplicate (7/8 — the cycle the ParentId null-out guards), and
// a sibling differing only by a TRAILING SPACE (9/10). The last two pairs are distinct legal
// directories on Linux that hash differently, so the unique index accepts both and the dedupe
// must not collapse them. On MySql a case-insensitive grouping deletes 6, and a PAD SPACE one
// deletes 10 — utf8mb4_bin, the obvious fix for the first, is itself PAD SPACE, which is why
// the migration groups on CONVERT(Path USING binary) instead.
await seed.ExecuteAsync(
"INSERT INTO LibraryPath (Id, LibraryId, Path) VALUES (1, 1, '/data/music')");
await seed.ExecuteAsync(
"""
INSERT INTO LibraryFolder (Id, LibraryPathId, Path, ParentId, Etag) VALUES
(1, 1, '/data/music/artist1', NULL, 'etag-keeper'),
(2, 1, '/data/music/artist1', NULL, 'etag-dupe-a'),
(3, 1, '/data/music/artist1', NULL, 'etag-dupe-b'),
(4, 1, '/data/music/artist2', NULL, NULL),
(5, 1, '/data/music/artist1/album', 3, NULL),
(6, 1, '/data/music/ARTIST2', NULL, NULL),
(7, 1, '/data/music/artist3', 8, NULL),
(8, 1, '/data/music/artist3', NULL, NULL),
(9, 1, '/data/music/pad', NULL, NULL),
(10, 1, '/data/music/pad ', NULL, NULL)
""");
// a media file on each duplicate and on each half of the trailing-space pair, plus an
// image-folder-duration on duplicates only
await seed.ExecuteAsync(
"""
INSERT INTO MediaFile (Id, Path, PathHash, MediaVersionId, LibraryFolderId) VALUES
(1, '/data/music/artist1/a.mkv', 'hash-a', 1, 1),
(2, '/data/music/artist1/b.mkv', 'hash-b', 2, 2),
(3, '/data/music/artist1/c.mkv', 'hash-c', 3, 3),
(4, '/data/music/pad/d.mkv', 'hash-d', 4, 9),
(5, '/data/music/pad /e.mkv', 'hash-e', 5, 10)
""");
await seed.ExecuteAsync(
"""
INSERT INTO ImageFolderDuration (Id, LibraryFolderId, DurationSeconds) VALUES
(1, 2, 30.0),
(2, 3, 45.0)
""");
}
await using (TvContext context = MigrateContext())
{
await AssertForeignKeysEnforced(context);
await context.Database.MigrateAsync();
}
await using (TvContext context = MigrateContext())
{
// Read the surviving rows once and assert in memory. Deliberately NOT `WHERE Path = '...'`:
// that predicate is itself collation-dependent (on MySQL it would also match the
// case-differing and trailing-space siblings), so an assertion written that way would quietly
// mean something different on each provider — the exact class of bug this fixture guards.
List<FolderRow> folders = (await context.Connection.QueryAsync<FolderRow>(
"SELECT Id, Path, ParentId, Etag FROM LibraryFolder ORDER BY Id")).ToList();
// survivors: keeper 1, artist2 (4), the child (5), the case-differing sibling (6), artist3's
// keeper (7), and BOTH halves of the trailing-space pair (9, 10). Losers 2, 3 and 8 are gone.
folders.Select(f => f.Id).ToList().ShouldBe([1, 4, 5, 6, 7, 9, 10]);
// and their paths survive BYTE-exactly (ordinal comparison here, matching the hash)
folders.Select(f => f.Path).ToList().ShouldBe(
[
"/data/music/artist1",
"/data/music/artist2",
"/data/music/artist1/album",
"/data/music/ARTIST2",
"/data/music/artist3",
"/data/music/pad",
"/data/music/pad "
]);
// the keeper's etag is cleared: which duplicate the scanner was writing to was arbitrary, so
// MIN(Id)'s etag could suppress the rescan that repairs the collapsed folder
folders.Single(f => f.Id == 1).Etag.ShouldBeNull();
// the child folder is reparented off the deleted duplicate onto the keeper
folders.Single(f => f.Id == 5).ParentId.ShouldBe(1);
// the folder parented on its own duplicate did not become its own parent
folders.Single(f => f.Id == 7).ParentId.ShouldBeNull();
// every media file follows the keeper — nothing orphaned, nothing deleted — while the
// trailing-space pair's dependents stay attached to their OWN folder
List<int> mediaFolderIds = (await context.Connection.QueryAsync<int>(
"SELECT LibraryFolderId FROM MediaFile ORDER BY Id")).ToList();
mediaFolderIds.ShouldBe([1, 1, 1, 9, 10]);
// the keeper had no ImageFolderDuration, so exactly one duplicate's setting is promoted to it
// (the lowest id) and the rest are dropped — the 1:1 unique index cannot hold both
List<int> durationFolderIds = (await context.Connection.QueryAsync<int>(
"SELECT LibraryFolderId FROM ImageFolderDuration ORDER BY Id")).ToList();
durationFolderIds.ShouldBe([1]);
await AssertHelperTablesDropped(context);
await AssertUniqueIndexExists(context);
}
}
[Test]
public async Task Migration_Leaves_A_Database_Without_Duplicates_Alone()
{
// positive control: the cleanup must not touch rows that were already unique
await using (TvContext context = SeedContext())
{
await context.Database.MigrateAsync(PreviousMigration);
await using SeedSession seed = await SeedSession.OpenAsync(context, provider);
await seed.ExecuteAsync(
"INSERT INTO LibraryPath (Id, LibraryId, Path) VALUES (1, 1, '/data/music')");
await seed.ExecuteAsync(
"""
INSERT INTO LibraryFolder (Id, LibraryPathId, Path, ParentId, Etag) VALUES
(1, 1, '/data/music/artist1', NULL, 'etag-1'),
(2, 1, '/data/music/artist2', 1, 'etag-2')
""");
await seed.ExecuteAsync(
"INSERT INTO ImageFolderDuration (Id, LibraryFolderId, DurationSeconds) VALUES (1, 2, 30.0)");
}
await using (TvContext context = MigrateContext())
{
await AssertForeignKeysEnforced(context);
await context.Database.MigrateAsync();
}
await using (TvContext context = MigrateContext())
{
List<FolderRow> folders = (await context.Connection.QueryAsync<FolderRow>(
"SELECT Id, Path, ParentId, Etag FROM LibraryFolder ORDER BY Id")).ToList();
folders.Select(f => f.Id).ToList().ShouldBe([1, 2]);
folders.Single(f => f.Id == 1).Etag.ShouldBe("etag-1");
folders.Single(f => f.Id == 2).Etag.ShouldBe("etag-2");
folders.Single(f => f.Id == 2).ParentId.ShouldBe(1);
(await context.Connection.ExecuteScalarAsync<int>(
"SELECT LibraryFolderId FROM ImageFolderDuration WHERE Id = 1")).ShouldBe(2);
// existing rows keep a null hash — the index applies because nulls are distinct
(await context.Connection.ExecuteScalarAsync<int>(
"SELECT COUNT(*) FROM LibraryFolder WHERE PathHash IS NULL")).ShouldBe(2);
}
}
// Foreign-key enforcement is the whole point of splitting the seed and migrate contexts, so prove it
// took effect rather than trusting a connection-string keyword: a typo, a pragma reset or a leaked
// SET FOREIGN_KEY_CHECKS=0 would silently revert this to its weaker form and still pass everything.
private async Task AssertForeignKeysEnforced(TvContext context)
{
string sql = provider is TestProvider.Sqlite
? "PRAGMA foreign_keys"
: "SELECT @@SESSION.foreign_key_checks";
(await context.Connection.ExecuteScalarAsync<long>(sql)).ShouldBe(
1,
"the migration must run with foreign keys ENFORCED — otherwise DELETE FROM LibraryFolder is "
+ "not actually tested against the Restrict constraints it must not violate");
}
private async Task AssertHelperTablesDropped(TvContext context)
{
string sql = provider is TestProvider.Sqlite
? "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name LIKE '__LibraryFolderDedupe%'"
: "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() "
+ "AND TABLE_NAME LIKE '\\_\\_LibraryFolderDedupe%'";
(await context.Connection.ExecuteScalarAsync<int>(sql)).ShouldBe(0, "helper tables were left behind");
}
private async Task AssertUniqueIndexExists(TvContext context)
{
string sql = provider is TestProvider.Sqlite
? "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' "
+ "AND name = 'IX_LibraryFolder_LibraryPathId_PathHash'"
: "SELECT COUNT(DISTINCT INDEX_NAME) FROM information_schema.STATISTICS "
+ "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'LibraryFolder' "
+ "AND INDEX_NAME = 'IX_LibraryFolder_LibraryPathId_PathHash'";
(await context.Connection.ExecuteScalarAsync<int>(sql)).ShouldBe(1, "the unique index was not created");
}
private static bool IsTrue(string? value) =>
value is "1" || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
private DbContextOptions<TvContext> SqliteOptions(bool foreignKeys) =>
new DbContextOptionsBuilder<TvContext>()
.UseSqlite(
$"Data Source={_databasePath};Foreign Keys={foreignKeys}",
o => o.MigrationsAssembly("ErsatzTV.Infrastructure.Sqlite"))
.Options;
private DbContextOptions<TvContext> MySqlOptions(ServerVersion serverVersion) =>
new DbContextOptionsBuilder<TvContext>()
.UseMySql(
_mySqlConnectionString,
serverVersion,
o => o.MigrationsAssembly("ErsatzTV.Infrastructure.MySql"))
.Options;
private TvContext SeedContext() => Create(_seedOptions);
private TvContext MigrateContext() => Create(_migrateOptions);
private static TvContext Create(DbContextOptions<TvContext> options) =>
new(
options,
NullLoggerFactory.Instance,
new SlowQueryInterceptor(NullLogger<SlowQueryInterceptor>.Instance));
// Settable properties rather than a positional record: SQLite hands back INTEGER as Int64 while MySQL
// hands back INT as Int32, and Dapper only narrows for property setters, not constructor matching.
private sealed class FolderRow
{
public int Id { get; set; }
public string Path { get; set; } = null!;
public int? ParentId { get; set; }
public string Etag { get; set; } = null!;
}
/// <summary>
/// Holds one connection open for the whole seeding block. SQLite disables foreign keys through a
/// connection-string keyword, but MySQL's <c>foreign_key_checks</c> is a SESSION variable — and
/// Dapper closes a connection it had to open itself, which would reset it between statements.
/// Opening explicitly keeps the session, and therefore the setting, alive across every insert.
/// </summary>
private sealed class SeedSession : IAsyncDisposable
{
private readonly TvContext _context;
private SeedSession(TvContext context) => _context = context;
public static async Task<SeedSession> OpenAsync(TvContext context, TestProvider provider)
{
await context.Database.OpenConnectionAsync();
if (provider is TestProvider.MySql)
{
await context.Connection.ExecuteAsync("SET SESSION foreign_key_checks = 0");
}
return new SeedSession(context);
}
public Task ExecuteAsync(string sql) => _context.Connection.ExecuteAsync(sql);
public async ValueTask DisposeAsync() => await _context.Database.CloseConnectionAsync();
}
}
@@ -1,4 +1,3 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Data.Repositories;
@@ -106,225 +105,6 @@ public class LibraryRepositoryTests
count.ShouldBe(1);
}
// ersatztv#491: the unique index is on (LibraryPathId, PathHash) because Path is unbounded, so every
// new row must carry the hash or the constraint is unenforceable for it.
[Test]
public async Task GetOrAddFolder_Should_Populate_PathHash_On_New_Folder()
{
int libraryPathId = await SeedLibraryPath("/data/music");
var libraryPath = new LibraryPath { Id = libraryPathId, Path = "/data/music", LibraryFolders = null };
LibraryFolder result = await _repository.GetOrAddFolder(libraryPath, Option<int>.None, "/data/music/artist1");
result.PathHash.ShouldBe(PathUtils.GetPathHash("/data/music/artist1"));
await using TvContext context = _db.CreateContext();
LibraryFolder persisted = await context.LibraryFolders.SingleAsync(f => f.Id == result.Id);
persisted.PathHash.ShouldBe(PathUtils.GetPathHash("/data/music/artist1"));
}
// ersatztv#491: rows that predate the PathHash column are left null by the migration (nulls are
// distinct in a unique index, so the index applies cleanly); the first scan that touches one heals it.
[Test]
public async Task GetOrAddFolder_Should_Heal_A_Legacy_Null_PathHash()
{
int libraryPathId = await SeedLibraryPath("/data/music");
var libraryPath = new LibraryPath { Id = libraryPathId, Path = "/data/music", LibraryFolders = null };
int legacyId;
await using (TvContext seed = _db.CreateContext())
{
var legacy = new LibraryFolder
{
LibraryPathId = libraryPathId, Path = "/data/music/artist1", PathHash = null
};
await seed.LibraryFolders.AddAsync(legacy);
await seed.SaveChangesAsync();
legacyId = legacy.Id;
}
LibraryFolder result = await _repository.GetOrAddFolder(libraryPath, Option<int>.None, "/data/music/artist1");
result.Id.ShouldBe(legacyId);
result.PathHash.ShouldBe(PathUtils.GetPathHash("/data/music/artist1"));
await using TvContext context = _db.CreateContext();
LibraryFolder persisted = await context.LibraryFolders.SingleAsync(f => f.Id == legacyId);
persisted.PathHash.ShouldBe(PathUtils.GetPathHash("/data/music/artist1"));
(await context.LibraryFolders.CountAsync(f => f.LibraryPathId == libraryPathId)).ShouldBe(1);
}
// ersatztv#491 / H1: when the lookup matches more than one row (legacy duplicates that predate the
// unique index, which is exactly the state the migration cleans up), it must resolve deterministically
// to the lowest Id. An unordered FirstOrDefault can return a different row as the plan changes — and
// adding the composite index alone can flip it — which would make the PathHash heal non-idempotent:
// each scan would heal a different row and the second would collide on (LibraryPathId, PathHash).
[Test]
public async Task GetOrAddFolder_Should_Resolve_Legacy_Duplicates_Deterministically()
{
int libraryPathId = await SeedLibraryPath("/data/music");
var libraryPath = new LibraryPath { Id = libraryPathId, Path = "/data/music", LibraryFolders = null };
int firstId;
await using (TvContext seed = _db.CreateContext())
{
var a = new LibraryFolder { LibraryPathId = libraryPathId, Path = "/data/music/artist1" };
var b = new LibraryFolder { LibraryPathId = libraryPathId, Path = "/data/music/artist1" };
await seed.LibraryFolders.AddRangeAsync(a, b);
await seed.SaveChangesAsync();
firstId = Math.Min(a.Id, b.Id);
}
// repeated calls must agree, and must agree with MIN(Id) — the same row the migration keeps
LibraryFolder first = await _repository.GetOrAddFolder(libraryPath, Option<int>.None, "/data/music/artist1");
LibraryFolder second = await _repository.GetOrAddFolder(libraryPath, Option<int>.None, "/data/music/artist1");
first.Id.ShouldBe(firstId);
second.Id.ShouldBe(firstId);
await using TvContext context = _db.CreateContext();
// exactly one of the two got the hash — the heal is idempotent, not alternating
(await context.LibraryFolders.CountAsync(
f => f.LibraryPathId == libraryPathId && f.PathHash != null)).ShouldBe(1);
LibraryFolder healed = await context.LibraryFolders.SingleAsync(f => f.Id == firstId);
healed.PathHash.ShouldBe(PathUtils.GetPathHash("/data/music/artist1"));
}
// ersatztv#491 / H1: on MySQL the lookup's SQL equality is case-INsensitive (longtext under the
// default collation), so it hands back BOTH "/x/Foo" and "/x/foo" for a "/x/foo" scan — verified on a
// real MySQL 8.4 server, where an unordered LIMIT 1 returns "/x/Foo". PathHash is a case-SENSITIVE
// hash, so identity has to be settled ordinally or the heal stamps the wrong path's hash onto a row.
//
// This pins that decision with no database at all, because no SQLite-backed test can: SQLite's `=` on
// TEXT is binary, so the case-differing candidate never reaches the in-memory step. Feeding the
// candidate list directly is the only way to exercise it in CI.
[Test]
public void ResolveExact_Should_Pick_The_Ordinal_Match_Not_A_Case_Variant()
{
var candidates = new List<LibraryFolder>
{
new() { Id = 10, Path = "/x/Foo" },
new() { Id = 11, Path = "/x/foo" }
};
LibraryRepository.ResolveExact(candidates, "/x/foo").Id.ShouldBe(11);
LibraryRepository.ResolveExact(candidates, "/x/Foo").Id.ShouldBe(10);
// a spelling that matches nothing ordinally is absent, not "close enough"
LibraryRepository.ResolveExact(candidates, "/x/FOO").ShouldBeNull();
LibraryRepository.ResolveExact([], "/x/foo").ShouldBeNull();
}
// MySQL's comparison is always case-insensitive and, on a PAD SPACE collation, also ignores trailing
// spaces ('/x/foo' = '/x/foo ' is TRUE under utf8mb4_general_ci and utf8mb4_bin; 8.4's default
// utf8mb4_0900_ai_ci is NO PAD, so this axis is server-dependent). Where it applies, the SQL narrowing
// hands back trailing-space siblings too. Those are distinct directories on Linux and hash
// differently, so the ordinal settle has to keep them apart — same defect class as the case variant,
// second axis. Ordinal compares length first, so this holds; pin it.
[Test]
public void ResolveExact_Should_Distinguish_Paths_Differing_Only_In_Trailing_Space()
{
var candidates = new List<LibraryFolder>
{
new() { Id = 20, Path = "/x/foo" },
new() { Id = 21, Path = "/x/foo " }
};
LibraryRepository.ResolveExact(candidates, "/x/foo").Id.ShouldBe(20);
LibraryRepository.ResolveExact(candidates, "/x/foo ").Id.ShouldBe(21);
LibraryRepository.ResolveExact(candidates, "/x/foo ").ShouldBeNull();
// the two spellings must also hash differently, or the unique index would reject one of them and
// the migration's decision to keep both would be wrong
PathUtils.GetPathHash("/x/foo").ShouldNotBe(PathUtils.GetPathHash("/x/foo "));
}
// Candidates arrive ordered by Id, and the first ordinal match wins — so true duplicates resolve to
// MIN(Id), the same row the #491 migration keeps.
[Test]
public void ResolveExact_Should_Prefer_The_First_Candidate_On_A_True_Duplicate()
{
var candidates = new List<LibraryFolder>
{
new() { Id = 3, Path = "/x/Foo" },
new() { Id = 7, Path = "/x/foo" },
new() { Id = 9, Path = "/x/foo" }
};
LibraryRepository.ResolveExact(candidates, "/x/foo").Id.ShouldBe(7);
}
// End-to-end companion to the ResolveExact tests above. On SQLite this passes on pre-fix code too (the
// SQL equality already excludes the case variant); its value is guarding the in-memory step against
// later being relaxed to OrdinalIgnoreCase, and covering the heal/no-duplicate behaviour around it.
[Test]
public async Task GetOrAddFolder_Should_Not_Resolve_A_Folder_Differing_Only_In_Case()
{
int libraryPathId = await SeedLibraryPath("/data/music");
var libraryPath = new LibraryPath { Id = libraryPathId, Path = "/data/music", LibraryFolders = null };
int upperId;
await using (TvContext seed = _db.CreateContext())
{
var upper = new LibraryFolder { LibraryPathId = libraryPathId, Path = "/data/music/Foo" };
var lower = new LibraryFolder { LibraryPathId = libraryPathId, Path = "/data/music/foo" };
await seed.LibraryFolders.AddRangeAsync(upper, lower);
await seed.SaveChangesAsync();
upperId = upper.Id;
}
LibraryFolder result = await _repository.GetOrAddFolder(libraryPath, Option<int>.None, "/data/music/foo");
result.Path.ShouldBe("/data/music/foo");
result.Id.ShouldNotBe(upperId);
result.PathHash.ShouldBe(PathUtils.GetPathHash("/data/music/foo"));
await using TvContext context = _db.CreateContext();
// the case-differing sibling must be untouched — no foreign hash stamped onto it
LibraryFolder upperPersisted = await context.LibraryFolders.SingleAsync(f => f.Id == upperId);
upperPersisted.Path.ShouldBe("/data/music/Foo");
upperPersisted.PathHash.ShouldBeNull();
// and no duplicate was inserted for either spelling
(await context.LibraryFolders.CountAsync(f => f.LibraryPathId == libraryPathId)).ShouldBe(2);
}
// The heal is opportunistic maintenance on a hot scan path: if some other row already owns
// (LibraryPathId, hash) — a legacy duplicate the migration's grouping could not see — it must leave
// the row unhealed rather than abort the scan.
[Test]
public async Task GetOrAddFolder_Should_Not_Fail_The_Scan_When_The_PathHash_Heal_Collides()
{
int libraryPathId = await SeedLibraryPath("/data/music");
var libraryPath = new LibraryPath { Id = libraryPathId, Path = "/data/music", LibraryFolders = null };
string hash = PathUtils.GetPathHash("/data/music/artist1");
int legacyId;
await using (TvContext seed = _db.CreateContext())
{
// a legacy row with a null hash, plus a squatter that already owns the hash it would heal to
var legacy = new LibraryFolder { LibraryPathId = libraryPathId, Path = "/data/music/artist1" };
var squatter = new LibraryFolder
{
LibraryPathId = libraryPathId, Path = "/data/music/squatter", PathHash = hash
};
await seed.LibraryFolders.AddRangeAsync(legacy, squatter);
await seed.SaveChangesAsync();
legacyId = legacy.Id;
}
// must not throw — the scan continues and simply returns the folder
LibraryFolder result = await _repository.GetOrAddFolder(libraryPath, Option<int>.None, "/data/music/artist1");
result.Id.ShouldBe(legacyId);
await using TvContext context = _db.CreateContext();
LibraryFolder persisted = await context.LibraryFolders.SingleAsync(f => f.Id == legacyId);
persisted.PathHash.ShouldBeNull(); // heal declined, not half-applied
}
private async Task<int> SeedLibraryPath(string path)
{
await using TvContext context = _db.CreateContext();
@@ -818,7 +818,6 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
vm.ScheduleKind,
vm.ChannelName,
vm.ChannelNumber,
vm.ChannelId,
vm.PlayoutMode,
vm.ScheduleName,
vm.ScheduleFile,
-5
View File
@@ -28954,7 +28954,6 @@
"scheduleKind",
"channelName",
"channelNumber",
"channelId",
"playoutMode",
"scheduleName",
"scheduleFile",
@@ -28980,10 +28979,6 @@
"channelNumber": {
"type": "string"
},
"channelId": {
"type": "integer",
"format": "int32"
},
"playoutMode": {
"$ref": "#/components/schemas/ChannelPlayoutMode"
},
+4 -8
View File
@@ -28,7 +28,7 @@ doc below, or that changes which sections a task signal points to.**
| Adding/changing a UI-E2E browser flow | `docs/e2e-local.md` → "UI-E2E harness" + `scripts/e2e-ui.sh` |
| What does a test suite cover | `docs/testing.md` |
| Legacy Blazor route lookup | `docs/blazor-route-parity.md` (historical #91 phase (b) inventory) |
| "Why do we do X this way" / challenging a convention | **Catalog-first**: `docs/decisions/README.md` (active rows) → follow the row's link to `docs/decisions/records/<area>/<topic>.md` for full rationale. `docs/decisions/archive/<area>/` only for "what did the rule used to be." |
| "Why do we do X this way" / challenging a convention | **Catalog-first**: `docs/decisions/README.md` (active rows) → `docs/decisions.md` + `docs/decisions/*.md` for full rationale. `docs/decisions/archive/` only for "what did the rule used to be." |
## Knowledge retrieval (MemPalace + catalog + Gitea)
@@ -72,13 +72,9 @@ bounds, what's mined per issue): `docs/handoffs/chicorytv-issue-queue.md` → "K
- **`docs/blazor-route-parity.md`** — historical record of the completed #91 phase (b) cutover:
the Blazor Server UI is removed and every legacy route now 302-redirects to its SPA equivalent
(or falls through to the catch-all → `/app`). Read it for the full legacy→SPA route inventory.
- **`docs/decisions/records/<area>/<topic>.md`** — one active decision record per file, YAML
frontmatter (`key`/`title`/`status`/`since`/`supersedes`/`superseded-by`, plus optional
`stale-after`/`sources` — ersatztv#603), rationale prose in the body. The **filename is the key**,
so one-active-record-per-key is a filesystem property (ersatztv#610). `docs/decisions.md` and the
topic files remain as the lifecycle-schema narrative plus a "Records formerly in this file" index,
which is what keeps older date-based pointers resolvable. **Generated active view**:
`docs/decisions/README.md`
- **`docs/decisions.md`** + **`docs/decisions/*.md`** — active decision records (lifecycle schema:
key/status/since/supersedes/superseded-by, plus optional `stale-after`/`Sources:` —
ersatztv#603). **Generated active view**: `docs/decisions/README.md`
(catalog / task router) — start there. Superseded/retired records live in
`docs/decisions/archive/` and are read only for history, never for "what is the current rule."
- **`docs/ci-cd.md`** — build/test/release pipeline, versioning, dependency management.
+1 -6
View File
@@ -41,12 +41,7 @@ Exemplars:
- **Paged GET with clamped params**: `ErsatzTV/Controllers/Api/LogsController.cs`
`pageNum` clamped via `Math.Max(0, pageNum)`, `pageSize` via `Math.Clamp(pageSize, 1, MaxPageSize)`
(`MaxPageSize = 100`). Any new paged endpoint should clamp the same way — don't trust client
input for page math. **`pageNum` is 0-based** across the whole surface (the first page is `0`) and
the offset is always derived from the *clamped* `pageSize`, so an over-large `pageSize` yields
narrower pages — it never widens the offset. Say "0-based" in the description of any paging
parameter you expose, including on wrapper surfaces like the MCP tool catalog: describing it as
1-based makes a caller skip the first page silently, which reads as data loss rather than as an
off-by-one (ersatztv#616). See `api.paging-zero-based`.
input for page math.
- **Sortable GET with allow-listed sort params**: same file — `sortField`/`sortDirection` are
normalized against a fixed allow-list (`AllowedSortFields`) rather than trusted or rejected with
a 422: an unrecognized `sortField` silently falls back to the default field, an unrecognized
+8 -64
View File
@@ -60,8 +60,8 @@ step is now continuous. The release boundary is instead where you:
not a release gate.
5. Report the remaining `legacy-unmigrated` count (the validator prints it as a `::notice::`) so the
backlog is visible, even though it isn't required to hit zero before a release.
A genuine rationale-prose rewrite still needs a `Decisions-Edit: yes` git trailer on a **non-merge**
commit in the range (see the `decisions.md` header) — routine lifecycle metadata writes above do not.
A genuine rationale-prose rewrite still needs `[decisions-edit]` in the commit message (see the
`decisions.md` header) — routine lifecycle metadata writes above do not.
**Cutting a release:** keep build and promotion as two explicit phases (#335):
@@ -578,8 +578,7 @@ blocking `api-docs` job, and `docs/decisions.md` by the blocking `decisions-guar
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`,
reciprocal `supersedes`/`superseded-by` links, no record vanishing from the active set without an
archive copy, no rationale-prose rewrite without a `Decisions-Edit: yes` trailer on a non-merge commit
in the range (ersatztv#609),
archive copy, no rationale-prose rewrite without the `[decisions-edit]` token in the commit range,
and the generated active catalog (`docs/decisions/README.md`) in sync with source. Two steps:
`scripts/decisions_validate.py --base origin/<base> --head HEAD` (the merge-base diff checks, which
need a base/head range — CI-only) and `scripts/build_decisions_catalog.py --check` (catalog drift).
@@ -620,65 +619,10 @@ 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 `Build & test (.NET)`, `EF migration integrity`
and `review-verdict/h10` (next section) — 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`.
## Review-verdict gate (`review-verdict/h10`, required — `.gitea/workflows/review-verdict.yml`)
**A required status check named `review-verdict/h10`, written per-sha, is what actually stops an
unreviewed commit from merging** (ersatztv#622). It is not produced by a job's success/failure; it
is a commit status that `scripts/post-review-verdict.sh` POSTs onto one specific sha.
**The hole it closes.** `pretooluse-merge-consent.sh` proves its three consent conditions at the
moment the merge tool is called. Pass `merge_when_checks_succeed=true` and Gitea performs the merge
*later*, against whatever head is green then — while the Done-when and review-verdict checks were
proven against the head at **scheduling** time. Every commit pushed in between merges unreviewed.
This was demonstrated as a controlled A/B rather than inferred (`ci/fake` stands in for a slow CI
check so Gitea waits, as it really does): review head A → post its verdict → schedule auto-merge →
push an unreviewed commit B → CI greens on B. **Without** the required verdict context, B merged.
**With** it, the same sequence was refused, and merged only once B itself was reviewed.
Note the motivating anecdote in ersatztv#622 — "PR #619 merged 263 insertions with no verdict" —
is **wrong**: #619 does carry `Review-verdict: MERGEABLE @ 02c82b35`, posted six seconds before the
merge, explicitly re-reviewing the follow-up commits. It was filed from an API read that lagged.
The gap is real anyway, and structural: nothing *forced* that re-review inside the 45-minute window
where Gitea would have merged whatever went green. This turns diligence into construction.
**Why a commit status fixes it and a smarter hook cannot.** A status belongs to exactly one sha, so
a new commit *cannot inherit it*: the required context is simply absent on the new head, Gitea's
merge-requirement check reads that as not-passing, and the scheduled auto-merge refuses to fire.
The invariant self-invalidates — nothing has to notice the push. It also covers merge paths the
hook never sees (Gitea UI, raw API, another agent's session).
**Posting a verdict.** After reviewing a PR's *current* head:
```bash
ETV_GITEA_BASICAUTH=user:pass scripts/post-review-verdict.sh <pr> MERGEABLE [note...]
```
That posts both the `Review-verdict: … @ <sha>` comment (the human-readable artifact, and the
hook's condition (c)) and the `review-verdict/h10` status on the same sha. `BLOCKED` /
`NOT-MERGEABLE` post a `failure` status instead. The script re-reads the head after commenting: if
a commit landed mid-flight it writes **no** status and exits non-zero rather than retargeting your
verdict at a commit you never read.
**Exemptions** are handled by `review-verdict.yml` on every `pull_request` event, which posts the
status as `success` for **Renovate-authored** PRs (it uses `platformAutomerge: true`, so a required
verdict with no exemption would stall every dependency bump) and for **docs-only** PRs, and as
`pending` for everything else so the block has a visible reason. Both exemptions are **void when the
PR touches `.claude/`, `.gitea/`, `.husky/`, `scripts/` or `docker/ci/`** — a PR that can weaken the
gate must not be able to exempt itself from the gate. That includes Renovate's `docker/ci` base
bumps, which already need the manual publish-then-pin two-step anyway.
It lives in its **own workflow file** on purpose: `pr-checks.yml` sets `cancel-in-progress: true`,
and a cancelled run there would leave an exempt PR with no status and no further push to
re-trigger it. Its own job context (`Review verdict / Set review-verdict status`) is **not** the
required check — a workflow must not satisfy the gate merely by running successfully.
Full rationale: `docs/decisions/records/release/verdict-status-check.md`.
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`)
@@ -1118,7 +1062,7 @@ entirely in `web/`), the wiring is:
3. **`commit-msg`** — enforces the CLAUDE.md protocol: the message must carry a
`Co-Authored-By:` trailer, else the commit is rejected (merge commits are exempt, detected
via `git rev-parse --verify MERGE_HEAD`). The decision-lifecycle check lives in `pre-commit`
(above), not here — the `Decisions-Edit:` trailer is read from the commit message, but only by the CI
(above), not here — `[decisions-edit]` is read from the commit message, but only by the CI
`decisions lifecycle` job's body-diff step (`range` mode over the PR's merge-base diff), which is
the only place a base/head range exists to diff against.
+3864 -108
View File
File diff suppressed because it is too large Load Diff
-10
View File
@@ -1,10 +0,0 @@
# ersatztv#610 — decision records are filed by their key's AREA, so directory names here come
# from the key vocabulary, not from us. Several collide with the root .gitignore's build-output
# rules: `release/` already did (all 9 records under records/release/ were silently dropped from
# the migration commit, caught only by the body-diff guard), and `bin/`, `obj/`, `build/`,
# `debug/`, `x64/` would do the same the day someone coins such an area.
#
# `!*/` un-ignores every directory under docs/decisions/, which fixes the CLASS rather than the
# one instance. It must negate the DIRECTORY: git never descends into an excluded directory, so
# negating only the files inside would not work.
!*/
+166 -170
View File
@@ -8,173 +8,169 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| Key | Current rule | Since | Record |
| --- | --- | --- | --- |
| `api.artwork-rooted-urls` | API response DTOs return artwork as rooted, directly-usable URLs (plus passthrough for absolute/proxy URLs), never relative Blazor-convention paths. | 2026-07-07 | [link](records/api/artwork-rooted-urls.md) |
| `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](records/api/async-op-contract.md) |
| `api.channel-health-object` | `ChannelResponseModel`/`ChannelDetailResponseModel` carry a server-derived `health` object (`ChannelHealthResponseModel { Status, Faults[], PlayoutCount, BrokenSourceItemCount }`) computed **read-time** from the built timeline (`Playout.BuildStatus` + upcoming `PlayoutItem → MediaItem.State`, `Finish >= now`), kind-agnostic across all 5 `PlayoutScheduleKind` values; `Status`/`Faults` are const-string classes (`ChannelHealthStatus`, `ChannelFault`), not C# enums, so the SPA hand-maintains the union (mirrors `ChannelPreviewAvailability`). This supersedes #72's "raw fact only, no derived enum, empty-schedule/broken-source deliberately not computed" stance now that the auto-tune taxonomy churn (#383/#384) it was waiting on has landed (see `channel.origin-marker` sibling record, #414). | 2026-07-23 | [link](records/api/channel-health-object.md) |
| `api.channel-preview-capability` | Whether a channel can be previewed in the browser is declared by the server, not derived by the SPA, as an additive `Preview` field (`{Availability, ManifestUrl, UnavailableReason}`) on `ChannelResponseModel`. | 2026-07-21 | [link](records/api/channel-preview-capability.md) |
| `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](records/api/decode-by-id.md) |
| `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](records/api/from-lineup-clear-to-none.md) |
| `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](records/api/healthcheck-remediation-dto.md) |
| `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](records/api/healthcheck-ttl-cache.md) |
| `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](records/api/logs-sort-params.md) |
| `api.mediatr-passthrough` | The REST API is thin controllers over existing MediatR handlers, with no new service/business-logic layer. | 2026-06 | [link](records/api/mediatr-passthrough.md) |
| `api.openapi-mirrors-runtime` | The generated OpenAPI spec is made to match the runtime Newtonsoft wire contract (via `NewtonsoftSchemaNamingTransformer`), not the reverse. | 2026-07-09 | [link](records/api/openapi-mirrors-runtime.md) |
| `api.paging-zero-based` | `pageNum` is 0-based across the entire `/api/v1` surface and every wrapper of it (MCP tool catalog, SPA hooks, docs); the page offset is always derived from the EFFECTIVE (bounded) `pageSize`, never the requested one, so a `pageSize` above an endpoint's cap narrows the page without widening the offset. The cap itself is per-endpoint (100 typical, 200 auto-tune members, 1000 search/all-items) and must not be documented as one number. A paging parameter description that omits or contradicts "0-based" is a defect. | 2026-07-25 | [link](records/api/paging-zero-based.md) |
| `api.parentid-drillin` | Media drill-in (season/episode/artist/music-video) is served by an optional `parentId` query param on library-browse, not dedicated per-kind child-listing endpoints. | 2026-07-07 | [link](records/api/parentid-drillin.md) |
| `api.playout-build-lock-409` | Every id-keyed playout/channel mutation endpoint checks `IEntityLocker.IsPlayoutLocked(id)` and returns 409 Conflict while a build is in-flight, mirroring Blazor's disabled-buttons behavior; reset-all stays 202 and silently skips locked playouts. | 2026-07-10 | [link](records/api/playout-build-lock-409.md) |
| `api.postcommit-cancellation-none` | Once a mutation commits, its entire compensating side effect (enqueues, publishes, reindexes, cache refresh, and any post-commit lookup gating one of those) runs on `CancellationToken.None` so a late client disconnect can't half-abort an already-committed change. | 2026-07-11 | [link](records/api/postcommit-cancellation-none.md) |
| `api.put-replace-index-order` | PUT-replace-the-whole-list endpoints derive each item's `Index` from its request-array position, never a client-supplied field; alternate-schedule/playout-template rows are evaluated in `Index` order with the least-conditional row placed last as the catch-all default. | 2026-07 | [link](records/api/put-replace-index-order.md) |
| `api.response-dtos` | New REST response DTOs live in `ErsatzTV.Core/Api/<Domain>/*ResponseModel.cs` with a file-scoped `#nullable enable` pragma; controllers never expose Application VM types directly. | 2026-07 | [link](records/api/response-dtos.md) |
| `api.schedule-item-flat-dto` | Schedule-item GET/POST/PUT use a flat, non-polymorphic `ScheduleItemResponseModel` (every subtype field promoted to a nullable top-level member) instead of the polymorphic Application VM hierarchy, with mutation field names matching `ScheduleItemRequest` 1:1 for a lossless round-trip. | 2026-07-10 | [link](records/api/schedule-item-flat-dto.md) |
| `api.scheduling-hardening` | Create/Replace handlers guard against null/whitespace `name` (`IsNullOrWhiteSpace`, not just `Length`) to prevent NRE-500s, template-item overlap validation compares by index (not record value-equality) to catch exact-duplicate items, and unreachable 404 `ProducesResponseType` attributes on create-only actions are trimmed. | 2026-07-13 | [link](records/api/scheduling-hardening.md) |
| `api.search-allitems-paging` | `GET /api/v1/search/all-items` is paginated (capped page size, `Totals` field) to bound DoS exposure; the SPA add-all flow pages to completeness instead of relying on an unbounded response. | 2026-07-18 | [link](records/api/search-allitems-paging.md) |
| `api.search-field-values` | `GET /api/v1/search/fields/{name}/values?q=&limit=` returns distinct WHOLE values from the database for one of a narrow allow-list of catalog fields (not the Lucene term dictionary — analyzed `TextField`s store lowercased word tokens, e.g. "Science Fiction" → `science`/`fiction`, useless as a typeahead suggestion), 404 for an unknown field, a non-`text` field, or a `text` field with no distinct-value source; case-insensitive prefix-filtered on `q`, `limit` clamped to `[1, 50]` (default 50). | 2026-07-23 | [link](records/api/search-field-values.md) |
| `api.search-paging-cap` | Search stays capped at 100 items per media kind; an overflowing kind's "See all" reuses library-browse paging instead of adding new API surface. | 2026-07-11 | [link](records/api/search-paging-cap.md) |
| `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](records/api/versioning-v1.md) |
| `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](records/blazor/rollback-tag.md) |
| `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](records/blazor/ui-removed.md) |
| `channel.origin-marker` | A new `Channel.Origin` (`ChannelOrigin` enum — `Unknown`/`UserCreated`/`AutoTuned`) records how a channel row was created and is stamped exactly once at insert (`AutoTuned` in `CreateChannelFromLineupHandler`, `UserCreated` in `CreateChannelHandler`), and is never mutated on a later edit. It is surfaced as a raw `origin` field on `ChannelResponseModel`; the SPA badges only `AutoTuned`. Rows predating the column read `Unknown` — provenance is **not** back-filled. | 2026-07-23 | [link](records/channel/origin-marker.md) |
| `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](records/ci/batch-pushes-no-cancel-route.md) |
| `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](records/ci/build-once-rejected.md) |
| `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](records/ci/cancelled-is-not-a-verdict.md) |
| `ci.decisions-edit-trailer` | The body-diff exemption is armed by an affirmative `Decisions-Edit:` **git trailer** (`yes`/`true`/`1`, case-insensitive, read with `unfold`) on some NON-MERGE commit in the PR's merge-base range — never by a substring search over the message text. A non-affirmative value (`no`) does not arm it, the retired `[decisions-edit]` substring arms nothing (the validator emits a `::warning::` nudge when it sees one without a trailer), and a git error leaves the guard ON. | 2026-07-25 | [link](records/ci/decisions-edit-trailer.md) |
| `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](records/ci/decisions-lifecycle-flake.md) |
| `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](records/ci/docs-only-detect-shallow-safe.md) |
| `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](records/ci/docs-only-skip-steps.md) |
| `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](records/ci/format-gate-folder-mode.md) |
| `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 deterministic assertions (`scripts/e2e-functional.sh`) as an advisory (non-blocking) job, not a `build` dependency or required check. Originally curl-only; since #445 the same job carries a second, headless-browser step for the contracts curl cannot express — see `ci.ui-e2e-harness`. | 2026-07-16 | [link](records/ci/functional-e2e-harness.md) |
| `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](records/ci/gitea-milestone-filter-noop.md) |
| `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](records/ci/infra-shaped-red-under-load.md) |
| `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](records/ci/killed-job-triage.md) |
| `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](records/ci/monitor-armed-at-pr-open.md) |
| `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](records/ci/no-host-health-gating.md) |
| `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](records/ci/peak-anon-measurement.md) |
| `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](records/ci/root-screenshot-guard.md) |
| `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](records/ci/runner-placement.md) |
| `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](records/ci/small-lane-git-only.md) |
| `ci.ui-e2e-harness` | The UI-interactive E2E flows run as headless Playwright specs (`web/e2e/*.spec.ts`, driven by `scripts/e2e-ui.sh`) in a **second step of the existing advisory `functional-e2e` job**, never their own job; the browser is `chromium-headless-shell` **baked into the CI toolchain image** (`docker/ci/Dockerfile`, `PLAYWRIGHT_VERSION` kept equal to `web/package.json`'s EXACT `@playwright/test` pin), never installed per run; specs are `serial` with `retries: 0` and assert only contracts the curl harness structurally cannot reach. | 2026-07-25 | [link](records/ci/ui-e2e-harness.md) |
| `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](records/ci/verify-locally-ci-confirms.md) |
| `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](records/ci/web-test-per-test-timeouts.md) |
| `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](records/concurrency/diff-scalar-fanout.md) |
| `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](records/concurrency/etag-rotation-completion.md) |
| `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](records/concurrency/force-write-non-ifmatch.md) |
| `concurrency.idempotent-concurrent-add` | A concurrent duplicate `Add*ToCollection` that loses the race on the composite-key unique constraint is treated as an idempotent no-op (skip the reindex/rebuild fan-out), not a 500 — detected via a provider-specific `TvContext.IsUniqueConstraintViolation` delegate defaulting to "no". | 2026-07-18 | [link](records/concurrency/idempotent-concurrent-add.md) |
| `concurrency.ifmatch-rfc7232` | `ConcurrencyHeaders.ParseIfMatch` is a real RFC 7232 entity-tag/list parser: a syntactically-valid tag that doesn't strong-match (weak/empty/non-canonical/out-of-range/list) returns 412, and only a genuine grammar violation returns 400. | 2026-07-12 | [link](records/concurrency/ifmatch-rfc7232.md) |
| `concurrency.replace-all-contract` | Replace-all aggregate PUTs carry a uniform plain `int Version` concurrency token (EF `.IsConcurrencyToken()`), checked pre-save and enforced by the EF UPDATE guard, returning 412 (not 409) on a stale `If-Match`. | 2026-07-11 | [link](records/concurrency/replace-all-contract.md) |
| `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](records/concurrency/schedule-item-child-identity.md) |
| `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](records/docs/convention-docs-session-start.md) |
| `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](records/docs/decision-lifecycle.md) |
| `docs.decision-one-file-per-record` | Each decision record is its own file at `docs/decisions/records/<area>/<topic>.md` (archived ones at `docs/decisions/archive/<area>/<topic>.md`) with YAML frontmatter; the filename IS the key, so one-active-record-per-key is a filesystem property rather than a validator check, and supersession is a `git mv`. | 2026-07-25 | [link](records/docs/decision-one-file-per-record.md) |
| `docs.decision-optional-provenance` | Decision records gain two OPTIONAL fields — `stale-after: YYYY-MM-DD` on the metadata line and a `**Sources:**` line in the metadata block; the Open Knowledge Format (OKF) itself is NOT adopted as the record format. | 2026-07-25 | [link](records/docs/decision-optional-provenance.md) |
| `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](records/docs/tracker-comment-retrofit.md) |
| `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](records/ffmpeg/external-logo-graphics-engine.md) |
| `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](records/ffmpeg/hls-cold-start-burst.md) |
| `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](records/ffmpeg/qsv-decode-encode-split.md) |
| `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](records/ffmpeg/qsv-extra-hw-frames-floor.md) |
| `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](records/ffmpeg/remote-image-fetcher-bounded.md) |
| `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](records/ffmpeg/work-ahead-slot-atomic.md) |
| `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](records/ffmpeg/work-ahead-slot-release-never-negative.md) |
| `graphics.channel-level-attachment` | A channel can attach `GraphicsElement`s directly via a new `ChannelGraphicsElement` join table (a base layer under deco/playout-item elements), and a built-in text element (`on-now-next.yml`) is seeded once per database so the On Now/Next overlay works out of the box. | 2026-07-22 | [link](records/graphics/channel-level-attachment.md) |
| `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](records/graphics/channel-logo-caching.md) |
| `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](records/iptv/base-url.md) |
| `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](records/iptv/logo-drives-bug-preset.md) |
| `locking.entitylocker-atomic-flags` | `EntityLocker` uses `Interlocked.CompareExchange`-guarded atomic flags plus a documented single-owner-release discipline (no owner tokens/leases); `Unlock*` on an already-unlocked slot returns `false` and logs a Warning rather than throwing. | 2026-07-11 | [link](records/locking/entitylocker-atomic-flags.md) |
| `mcp.server-foundation` | `ErsatzTV.Mcp` is a fresh stdio JSON-RPC server wrapping frozen `/api/v1` with explicit narrow per-endpoint tools, read-only-by-default enforced at runtime (`ERSATZTV_ALLOW_WRITES`), machine-key auth, and opt-in `If-Match`. | 2026-07-20 | [link](records/mcp/server-foundation.md) |
| `media.lastscan-null-boundary` | A never-scanned `LastScan` surfaces as `null` at the API/MCP boundary, not the `0001-01-01` MinValue sentinel — enforced by an ongoing read-boundary coercion plus a one-time data migration cleanup. | 2026-07-18 | [link](records/media/lastscan-null-boundary.md) |
| `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](records/media/remote-stream-probe.md) |
| `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](records/media/remote-stream-probe-externaljson.md) |
| `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](records/media/source-mgmt-write-api.md) |
| `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](records/process/bom-format-detection-recipe.md) |
| `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](records/process/branch-off-feature-branch.md) |
| `process.build-concurrency-limits` | Run at most 34 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](records/process/build-concurrency-limits.md) |
| `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](records/process/codex-cheap-worker-launch.md) |
| `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](records/process/consistency-fix-new-code-scrutiny.md) |
| `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](records/process/enumerate-workaround-behaviors-before-deleting.md) |
| `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](records/process/foreign-worktree-plumbing-merge.md) |
| `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](records/process/harden-with-runtime-posture-not-clamp.md) |
| `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](records/process/independent-review-rubric.md) |
| `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](records/process/issue-qualification-audit.md) |
| `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](records/process/local-gate-before-push.md) |
| `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](records/process/lock-ownership-enumerate-producers.md) |
| `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](records/process/one-worktree-one-committing-agent.md) |
| `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](records/process/parallel-session-claim.md) |
| `process.per-agent-model-routing` | State the model tier (and effort, where the client exposes it) in the dispatch itself for every delegated agent — bounded recon → cheapest fast tier at `low`; mechanical slice against a documented contract → mid tier; judgment-heavy work → orchestrator tier; independent review → a different model family than the implementer. | 2026-07-25 | [link](records/process/per-agent-model-routing.md) |
| `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](records/process/pr-routine-sequence.md) |
| `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](records/process/review-disagreement-frontier-judge.md) |
| `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](records/process/shared-tree-readonly.md) |
| `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](records/process/subagent-drop-resume.md) |
| `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](records/release/api-contract-ci-gate.md) |
| `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](records/release/done-when-merge-consent.md) |
| `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](records/release/format-as-you-touch-rebase.md) |
| `release.live-e2e-required` | A PR that changes an API write-path handler must include a live-E2E pass (driving the real endpoint/screen and confirming the round-trip through a subsequent read), not only unit/characterization tests, and must state whether live-E2E ran or wasn't required. | 2026-07-12 | [link](records/release/live-e2e-required.md) |
| `release.merge-consent-autogrant` | When Done-when boxes are ticked, CI is green, and a fresh positive Review-verdict references head, the merge-consent hook emits `permissionDecision: allow` to actually suppress the redundant mechanical prompt — the derived state IS the consent, no separate conversational confirmation on that path. | 2026-07-12 | [link](records/release/merge-consent-autogrant.md) |
| `release.migration-rehearsal-prodcopy` | Before promoting a migration-bearing release, rehearse the new image's migrations against a throwaway copy of the latest prod backup (`scripts/migration-smoke.sh`), gating PASS on the migrator's completion log line rather than HTTP readiness alone. | 2026-07-12 | [link](records/release/migration-rehearsal-prodcopy.md) |
| `release.prepush-clean-worktree-guard` | A fail-open pre-push hook blocks a push when any file in the branch's diff vs `origin/main` also has uncommitted working-tree or index changes, since a stale-index commit (e.g. `git reset --soft` + `git add` over an edited-but-unstaged fix) can silently push, CI-test, and get reviewed a different tree than the one on disk. Scope is precise to pushed-diff files; escape hatch `ETV_ALLOW_DIRTY_PUSH=1`. | 2026-07-17 | [link](records/release/prepush-clean-worktree-guard.md) |
| `release.promotion-floating-prod` | Prod tracks the floating `:prod` image reference; a tag build's immutable `:<version>` image is scanned first, then promotion happens via a separate manual `DeployStack`, with daily auto-update only as a fallback — tag with enough runway before 03:00 to avoid an unscanned promotion. | 2026-07-13 | [link](records/release/promotion-floating-prod.md) |
| `release.review-verdict-gate` | A PR may not merge until a `Review-verdict: <MERGEABLE\|APPROVED\|BLOCKED\|NOT-MERGEABLE> @ <head-sha>` comment references the PR's current head sha (short-sha prefix match against the verdict's OWN `@ <sha>` field, marker at COLUMN 0 (no indent, so indented code blocks cannot self-approve), whole-word verdict token, fenced code blocks stripped with markdown fence-length semantics, negative wins over positive on the same head); folds into the H6 merge-consent hook as condition (c). The grammar lives in ONE tested place, `scripts/check-review-verdict.sh`#629 found three false-opens that survived because it was implemented inline and untested while this record described stricter behaviour than the code had. | 2026-07-12 | [link](records/release/review-verdict-gate.md) |
| `release.verdict-status-check` | The H10 review verdict is written as a `review-verdict/h10` Gitea **commit status** on the exact reviewed sha by `scripts/post-review-verdict.sh`, and that context is a REQUIRED status check on `main`. Because a status belongs to one sha, a later commit cannot inherit it, so Gitea's own `merge_when_checks_succeed` refuses to merge a head no one reviewed. The PreToolUse hook additionally refuses to SCHEDULE an auto-merge unless that status is already green on head. A `pull_request` workflow auto-passes the two exempt classes (Renovate-authored, docs-only) unless the PR touches a protected path (`.claude/`, `.gitea/`, `.husky/`, `scripts/`, `docker/ci/`). This extends — does not supersede — `release.review-verdict-gate` (#303 H10), whose comment convention remains the human-readable artifact and the hook's condition (c). | 2026-07-25 | [link](records/release/verdict-status-check.md) |
| `rulebuilder.relative-date-macros` | The visual rule builder's `inLast`/`notInLast` date operators compile to/parse from the pre-existing `CustomMultiFieldQueryParser` macros `released_inthelast`/`released_notinthelast` and `added_inthelast`/`added_notinthelast`, value form `"<n> day\|week\|month\|year"`; there is no backend change. | 2026-07-23 | [link](records/rulebuilder/relative-date-macros.md) |
| `scan.collections-scan-status` | `GET /api/v1/media-sources/collections-scan-status` reports a family-global (not per-source), boolean-only active-scan set read from `IEntityLocker`; the SPA reconciles authoritatively against it (with a grace-tick helper) instead of a fixed client-side timeout. | 2026-07-12 | [link](records/scan/collections-scan-status.md) |
| `scan.getoraddfolder-db-lookup` | `ILibraryRepository.GetOrAddFolder` resolves the existing folder via a DB query on `(LibraryPathId, Path)`, not the caller's `LibraryPath.LibraryFolders` in-memory navigation, since that navigation is only eager-loaded on the local scan path and is null on remote (Jellyfin) callers. | 2026-07-20 | [link](records/scan/getoraddfolder-db-lookup.md) |
| `scan.jellyfin-mixed-content-library` | A Jellyfin library whose collection type is `mixed` (or absent) maps to one ErsatzTV library of `LibraryMediaKind.Mixed`, scanned by running the movie/television/music-video scanners in sequence against that single library — a library is a place, not a media kind. | 2026-07-20 | [link](records/scan/jellyfin-mixed-content-library.md) |
| `scan.libraryfolder-unique-identity` | `LibraryFolder` uniqueness per `(LibraryPathId, Path)` is enforced by a database unique index over a SHA-256 `PathHash` (Path is unbounded and not portably indexable), and `LibraryRepository.GetOrAddFolder`/`SetEtag` tolerate the constraint violation by re-reading and adopting the winner's row. | 2026-07-25 | [link](records/scan/libraryfolder-unique-identity.md) |
| `scan.musicvideo-server-identity` | Jellyfin music videos carry a per-library server identity (`JellyfinMusicVideo : MusicVideo` with `ItemId`/`Etag`, TPT table + ItemId index), so `JellyfinMusicVideoLibraryScanner` folds onto a shared `MediaServerMusicVideoLibraryScanner` base that diffs the **server item id** and soft-trashes (`FlagFileNotFound`) instead of diffing local paths and hard-deleting. Rows predating the identity are **adopted in place** — the identity row is inserted against the same `MediaItem` id, scoped to the scanned library's own `LibraryPath` — never deleted and re-added. | 2026-07-25 | [link](records/scan/musicvideo-server-identity.md) |
| `scan.projection-failure-sweep-guard` | `MediaServerReconciliationGuard` takes a per-enumeration projection-failure count and refuses the file-not-found sweep (logged loudly) whenever it is non-zero against a non-empty existing set — at all six sweeps, including the nested per-show season and per-season episode ones #477 left unguarded (via `ShouldFlagMissingDescendants`, which applies the failure refusal but not #477's empty-fetch branch). Deliberate guard-clause skips (STRM, virtual, unsupported type) are explicitly **not** failures and never suppress a sweep. The missing-fraction / ratio threshold floated by #477 is **rejected**, not deferred. | 2026-07-25 | [link](records/scan/projection-failure-sweep-guard.md) |
| `scan.zero-item-fetch-guard` | A media-server library sweep refuses to flag missing items when a successful fetch returns zero incoming items against a non-empty existing set (`MediaServerReconciliationGuard.ShouldFlagMissing`), rather than treating an ambiguous empty result as a full-library deletion. | 2026-07-19 | [link](records/scan/zero-item-fetch-guard.md) |
| `sched.auto-tune-foundation` | Auto-tune preview enumeration uses EF distinct+count queries for exact counts, while each created channel is persisted as a live SmartCollection; coexistence with existing channels/numbers is additive-only, never mutating. | 2026-07-16 | [link](records/sched/auto-tune-foundation.md) |
| `sched.autotune-detailpanel-members` | The Auto-Tune DetailPanel's per-channel content-source list is a live `ISearchIndex.Search` roll-up through the server-owned `AutoTuneAxisMap.GenerateQuery`, not an EF distinct+count query, so the preview matches exactly what the built channel's SmartCollection will contain. | 2026-07-17 | [link](records/sched/autotune-detailpanel-members.md) |
| `sched.autotune-per-channel-overrides` | Auto-Tune per-channel overrides reuse the Channel Builder's advanced-options DTO verbatim; per-source weights and bug-colour logo are deferred to #425. | 2026-07-17 | [link](records/sched/autotune-per-channel-overrides.md) |
| `sched.autotune-per-source-weights` | Auto-Tune per-source rotation weights and query corrections are supplied at bulk-create time via #70's MultiCollection/SmartCollection machinery, not a post-hoc PUT. | 2026-07-18 | [link](records/sched/autotune-per-source-weights.md) |
| `sched.clock-padding-existing` | Clock-boundary padding already exists via `FillerPreset`'s `FillerMode.Pad` (Classic) and `pad_to_next`/`pad_until` (Sequential/YAML); #77 is closed as verified+documented, not built new, with a one-click per-channel toggle deferred behind the #388 design-system epic. | 2026-07-17 | [link](records/sched/clock-padding-existing.md) |
| `sched.clock-padding-schedule-toggle` | A `ProgramSchedule.PadToNearestMinute` (nullable int; null = off) makes the Classic builder pad every content item up to the next N-minute clock boundary without a hand-wired Pad `FillerPreset`, by reusing the existing per-content-item Pad path in `PlayoutModeSchedulerBase.AddFiller`. It extends — does not supersede — `sched.clock-padding-existing` (#77/#388). | 2026-07-22 | [link](records/sched/clock-padding-schedule-toggle.md) |
| `sched.playbackorder-support-matrix` | Every build-time dispatch site logs a loud (non-fatal) warning on an unsupported `PlaybackOrder`, and a declared `PlaybackOrderSupport` matrix + partition tripwire test makes adding a new order safe by construction. | 2026-07-18 | [link](records/sched/playbackorder-support-matrix.md) |
| `sched.reshuffle-scoped-reset` | `POST /api/v1/playouts/{id}/reshuffle` runs `ErasePlayoutHistory` (reseeds `Playout.Seed` + clears anchors/rerun-history) then enqueues a scoped `Reset` build, so reshuffle always reseeds — even for the non-Classic kinds `Reset` alone wouldn't reseed; `Playout.Seed` is surfaced on list/detail DTOs as visible confirmation. | 2026-07-16 | [link](records/sched/reshuffle-scoped-reset.md) |
| `sched.seasonal-scheduling-existing` | Seasonal/date-conditional scheduling already ships first-class via `IAlternateScheduleItem` (Classic `ProgramScheduleAlternate`, Block `PlayoutTemplate`) evaluated by `AlternateScheduleSelector.GetScheduleForDate` (first match in `Index` order, catch-all last); #73 is closed as already-implemented with a docs-only "seasonal/holiday" recipe added, not new code. | 2026-07-17 | [link](records/sched/seasonal-scheduling-existing.md) |
| `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](records/sched/shuffle-source-builder.md) |
| `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](records/sched/weighted-shuffle.md) |
| `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](records/sched/weightedshuffle-editor.md) |
| `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](records/scheduling/ondemand-guide-refresh-on-thaw.md) |
| `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](records/security/artwork-content-type-sniff.md) |
| `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](records/security/baseline-response-headers.md) |
| `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](records/security/blazor-removal-auth-posture.md) |
| `security.contract-freeze-honesty` | The OpenAPI doc's declared security/401 scheme is generated from the same `ApiKeyAuthorizationFilter.EndpointRequiresKey` predicate the runtime enforces (so declared auth can't drift from enforced auth), every `/api/*` action returns a ResponseModel (no raw Application VMs), and Channel REST resources are keyed by immutable `Id`, never mutable `Number`. | 2026-07-12 | [link](records/security/contract-freeze-honesty.md) |
| `security.corp-same-origin` | `SecurityHeadersMiddleware` sends `Cross-Origin-Resource-Policy: same-origin` on every response including `/docs`/`/openapi`, blocking cross-origin `no-cors` embedding without affecting allowed CORS-mode fetches or server-side Jellyfin `/iptv/*` requests. | 2026-07-13 | [link](records/security/corp-same-origin.md) |
| `security.csp-permissions-policy` | `SecurityHeadersMiddleware` sends an enforcing (not report-only) `Content-Security-Policy` (no `unsafe-inline`/`unsafe-eval`; the one inline theme-bootstrap script allow-listed by hash) and a deny-all `Permissions-Policy` on the SPA/`/api`/`/artwork`/`/iptv`; `/docs` and `/openapi` keep only the baseline headers, excluded from CSP because Scalar needs inline bootstrap. | 2026-07-12 | [link](records/security/csp-permissions-policy.md) |
| `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](records/security/fail-closed-api-auth.md) |
| `security.iptv-access-token-transport` | The `/iptv` `?access_token=` value is percent-encoded (`Uri.EscapeDataString`) everywhere it is interpolated into an M3U/HLS/XMLTV URL (XMLTV additionally XML-escapes the encoded value), so a structural character can't malform the manifest or guide; Serilog logs a scrubbed request path (`access_token` `***` via `IncludeQueryInRequestPath = false` + a `RequestPathScrubbed` enricher), so a 5xx/Debug `/iptv` request never writes the token; and every dynamic token-bearing `/iptv` manifest (`channels.m3u`, `xmltv.xml`, the HLS multi-variant/media playlists) returns `Cache-Control: private, no-store`. | 2026-07-23 | [link](records/security/iptv-access-token-transport.md) |
| `security.iptv-browser-token` | Under a JWT-enabled deployment (`JWT:IssuerSigningKey` set), the browser SPA obtains a short-lived, globally-scoped `/iptv/*` access token from an authenticated `GET /api/v1/auth/iptv-token` and appends it as `?access_token=`; the endpoint answers 204 when JWT is disabled (nothing to mint). Lifetime defaults to 60 min, configurable via `JWT:BrowserTokenLifetimeMinutes`. | 2026-07-22 | [link](records/security/iptv-browser-token.md) |
| `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](records/security/session-auth-dual-credential.md) |
| `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](records/security/session-cutover-postify.md) |
| `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](records/session/shared-checkout-refresh.md) |
| `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](records/spa/add-to-layer.md) |
| `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](records/spa/app-shell-extraction.md) |
| `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](records/spa/autotune-detailpanel-slideover.md) |
| `spa.channel-editor-create-logo` | Bare-channel create is a "New blank channel" action on the channels list (reusing Blazor's add-mode defaults) that navigates into the full editor, and an external logo URL always wins over an uploaded logo, matching `ChannelEditViewModel` precedence. | 2026-07-11 | [link](records/spa/channel-editor-create-logo.md) |
| `spa.channel-renumber-prompt` | Channel renumbering uses a sequential `prompt()`-driven "Renumber" action instead of drag-to-reorder. | 2026-07-09 | [link](records/spa/channel-renumber-prompt.md) |
| `spa.channels-screen-extraction` | The Channels domain is a single-file zero-prop screen (`web/src/screens/ChannelsScreen.tsx`) with a colocated test file and no sibling helper directory, since its pure logic is too small (~30 lines) to justify a separate business-rule layer like Schedules' `itemRules.ts`. | 2026-07-11 | [link](records/spa/channels-screen-extraction.md) |
| `spa.collection-custom-order-ui` | Collection custom ordering uses per-row Move up/Move down buttons (not drag) and is offered for any manual collection with custom order enabled, not just movies-only. | 2026-07-09 | [link](records/spa/collection-custom-order-ui.md) |
| `spa.datetime-local-input` | The channel-mode date/time input uses a native `<input type="datetime-local">` instead of free-text Chronic natural-language parsing. | 2026-07-09 | [link](records/spa/datetime-local-input.md) |
| `spa.deco-templates-table` | The deco-templates editor also renders its day/deco assignment as a table, extending (not replacing) the templates-editor-table convention. | 2026-07-09 | [link](records/spa/deco-templates-table.md) |
| `spa.download-sample-gate` | The SPA disables both Download Media Sample and Download Results while a troubleshooting session is starting/running (Blazor only gated Download Results). | 2026-07-09 | [link](records/spa/download-sample-gate.md) |
| `spa.legacy-redirect-matcher` | `LegacyUiRedirects.TryGetRedirect` is a two-tier matcher — an exact `OrdinalIgnoreCase` `Map` (Tier 1) then an ordered segment-template pattern list (Tier 2, first-match-wins) — collision-free by construction, with a guard invariant that no rule may prefix-match `/api`, `/artwork`, `/docs`, `/openapi`, `/iptv`, `/app`, or `/media/sources`. | 2026-07-11 | [link](records/spa/legacy-redirect-matcher.md) |
| `spa.logs-page-size-local` | The Logs page rows-per-page preference is stored in `window.localStorage` (`ctv-logs-page-size`), not a server `ConfigElement`. | 2026-07-11 | [link](records/spa/logs-page-size-local.md) |
| `spa.playback-troubleshoot-poll` | The playback-troubleshooting screen reports FFmpeg completion by polling `GET /api/troubleshoot/playback/status` (~2s) rather than a server push channel. | 2026-07-09 | [link](records/spa/playback-troubleshoot-poll.md) |
| `spa.playout-reset-button` | The SPA keeps a single Reset action (server picks the default build mode) and drops Blazor's separate "Schedule reset" button since its capability already exists via the playout's Edit-details flow. | 2026-07-09 | [link](records/spa/playout-reset-button.md) |
| `spa.playouts-screen-extraction` | The Playouts domain (including its unguarded `PlayoutsRouteScreen` route wrapper with local pathname/popstate state) moved as one unit into `web/src/screens/PlayoutsScreen.tsx`, keeping its screen-specific sub-path route ownership colocated with the base screen; a pure structural move with no API/route/CSS/behavior change. | 2026-07-14 | [link](records/spa/playouts-screen-extraction.md) |
| `spa.rulebuilder-nesting` | The visual rule builder's `Group` nests recursively to a single shared cap, `MAX_GROUP_DEPTH` (`types.ts`, currently 5, root group = depth 0) — read by the UI's "Add group" gate, `parse.ts` and the round-trip property-test generator alike; everything else about the builder is unchanged from #176 (compile-only closed Lucene subset over the stored query string, no stored rule AST, field vocabulary from `GET /api/v1/search/fields`). | 2026-07-25 | [link](records/spa/rulebuilder-nesting.md) |
| `spa.schedules-editor-draft-save` | The schedules SPA editor mutates a local draft and flushes one explicit Save (`PUT /api/schedules/{id}/items`) instead of instant-persisting each action; Copy deep-copies all source references (fixing a Blazor omission); the shuffled-schedule GET's `EnforceProperties` lossy normalization is preserved and mirrored in the SPA's option lists. | 2026-07-11 | [link](records/spa/schedules-editor-draft-save.md) |
| `spa.sidebar-collapsible-accordions` | The shell sidebar's collapse + nav-group-accordion state persists under two hyphenated `ctv-sidebar-*` localStorage keys (matching the repo's `ctv-` convention, not the prototype's dotted names); labeled groups default-collapsed. | 2026-07-18 | [link](records/spa/sidebar-collapsible-accordions.md) |
| `spa.spa-rebuild-decision` | The UI is a full React SPA (ChicoryTV) rebuild over the REST API, not a Blazor Server reskin. | 2026-06 | [link](records/spa/spa-rebuild-decision.md) |
| `spa.templates-editor-table` | The SPA templates editor renders day/block assignment as a table, not Blazor's drag-and-drop calendar grid — an accepted, deliberate parity deviation. | 2026-07 | [link](records/spa/templates-editor-table.md) |
| `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](records/spa/topbar-primary-action.md) |
| `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](records/spa/yaml-validator-textarea.md) |
| `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](records/startup/parallel-orientation.md) |
| `testing.e2e-cleanup-scope-by-pid` | An E2E harness or agent may only kill processes whose PIDs it captured at launch — capture the PID; whoever owns the lifecycle releases it from a `trap ... EXIT INT TERM`. Never `pkill -f "dotnet ErsatzTV.dll"` (or any pattern that can match a process this run did not start). A foreign listener is reported, not reaped. | 2026-07-25 | [link](records/testing/e2e-cleanup-scope-by-pid.md) |
| `testing.e2e-local-fresh-config-dir` | Always point `scripts/e2e-local.sh` at a fresh config dir — leftover channels/schedules/DB rows bleed state between runs and corrupt assertions. (The *readiness-probe hang* this record was originally written about was fixed in #533; the fresh-dir rule stands on state-bleed grounds alone.) | 2026-07-21 | [link](records/testing/e2e-local-fresh-config-dir.md) |
| `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](records/testing/live-e2e-prepush-timing.md) |
| `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](records/testing/playwright-mcp-download-and-recovery.md) |
| `testing.scripted-playout-golden-deferred` | The `PlayoutBuildGoldenTests` in-memory golden net covers Sequential (YAML) as of #381. Scripted's *end-to-end pipeline* is excluded — `ScriptedPlayoutBuilder` runs a user-authored external program that drives the engine over HTTP loopback, which the in-memory harness can't pin — so that full-pipeline (integration) harness is deferred to #563. But the scheduling *behavior* those scripts drive lives entirely in the in-process `SchedulingEngine` (the `ScriptedScheduleController` is a 1:1 pass-through to it), which IS directly unit/golden-testable; the earlier "Scripted is un-golden-able by construction" framing overstated the constraint by conflating transport with engine. #395 extracts that shared switch to `ContentEnumeratorBuilder` and adds a direct regression net (`ContentEnumeratorBuilderTests`) over it. | 2026-07-22 | [link](records/testing/scripted-playout-golden-deferred.md) |
| `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](records/testing/troubleshoot-path-cannot-test-branding.md) |
| `api.artwork-rooted-urls` | API response DTOs return artwork as rooted, directly-usable URLs (plus passthrough for absolute/proxy URLs), never relative Blazor-convention paths. | 2026-07-07 | [link](../decisions.md#2026-07-07--api-artwork-contract-rooted-urls-produced-server-side) |
| `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-object` | `ChannelResponseModel`/`ChannelDetailResponseModel` carry a server-derived `health` object (`ChannelHealthResponseModel { Status, Faults[], PlayoutCount, BrokenSourceItemCount }`) computed **read-time** from the built timeline (`Playout.BuildStatus` + upcoming `PlayoutItem → MediaItem.State`, `Finish >= now`), kind-agnostic across all 5 `PlayoutScheduleKind` values; `Status`/`Faults` are const-string classes (`ChannelHealthStatus`, `ChannelFault`), not C# enums, so the SPA hand-maintains the union (mirrors `ChannelPreviewAvailability`). This supersedes #72's "raw fact only, no derived enum, empty-schedule/broken-source deliberately not computed" stance now that the auto-tune taxonomy churn (#383/#384) it was waiting on has landed (see `channel.origin-marker` sibling record, #414). | 2026-07-23 | [link](../decisions.md#2026-07-23--channel-health--a-server-derived-health-object-on-the-channel-dtos-built-timeline-detection-415) |
| `api.channel-preview-capability` | Whether a channel can be previewed in the browser is declared by the server, not derived by the SPA, as an additive `Preview` field (`{Availability, ManifestUrl, UnavailableReason}`) on `ChannelResponseModel`. | 2026-07-21 | [link](../decisions.md#2026-07-21--browser-channel-preview-is-a-server-declared-per-channel-capability-60) |
| `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) |
| `api.mediatr-passthrough` | The REST API is thin controllers over existing MediatR handlers, with no new service/business-logic layer. | 2026-06 | [link](../decisions.md#2026-06--rest-api-wraps-existing-mediatr-handlers-11-no-service-layer) |
| `api.openapi-mirrors-runtime` | The generated OpenAPI spec is made to match the runtime Newtonsoft wire contract (via `NewtonsoftSchemaNamingTransformer`), not the reverse. | 2026-07-09 | [link](../decisions.md#2026-07-09--openapi-spec-mirrors-the-runtime-newtonsoft-serializer-198) |
| `api.parentid-drillin` | Media drill-in (season/episode/artist/music-video) is served by an optional `parentId` query param on library-browse, not dedicated per-kind child-listing endpoints. | 2026-07-07 | [link](../decisions.md#2026-07-07--seasonepisodemusic-video-drill-in-via-parentid-not-new-child-listing-endpoints) |
| `api.playout-build-lock-409` | Every id-keyed playout/channel mutation endpoint checks `IEntityLocker.IsPlayoutLocked(id)` and returns 409 Conflict while a build is in-flight, mirroring Blazor's disabled-buttons behavior; reset-all stays 202 and silently skips locked playouts. | 2026-07-10 | [link](../decisions.md#2026-07-10--playout-api-mutations-return-409-while-the-build-lock-is-held-215) |
| `api.postcommit-cancellation-none` | Once a mutation commits, its entire compensating side effect (enqueues, publishes, reindexes, cache refresh, and any post-commit lookup gating one of those) runs on `CancellationToken.None` so a late client disconnect can't half-abort an already-committed change. | 2026-07-11 | [link](../decisions.md#2026-07-11--post-commit-side-effects-run-on-cancellationtokennone-generalized-from-251-to-254) |
| `api.put-replace-index-order` | PUT-replace-the-whole-list endpoints derive each item's `Index` from its request-array position, never a client-supplied field; alternate-schedule/playout-template rows are evaluated in `Index` order with the least-conditional row placed last as the catch-all default. | 2026-07 | [link](../decisions.md#2026-07--put-replace-list-endpoints-derive-index-from-array-order-alternate-schedules-last-row--catch-all-default) |
| `api.response-dtos` | New REST response DTOs live in `ErsatzTV.Core/Api/<Domain>/*ResponseModel.cs` with a file-scoped `#nullable enable` pragma; controllers never expose Application VM types directly. | 2026-07 | [link](../decisions.md#2026-07--response-dtos-live-in-ersatztvcoreapi-file-scoped-nullable-enable) |
| `api.schedule-item-flat-dto` | Schedule-item GET/POST/PUT use a flat, non-polymorphic `ScheduleItemResponseModel` (every subtype field promoted to a nullable top-level member) instead of the polymorphic Application VM hierarchy, with mutation field names matching `ScheduleItemRequest` 1:1 for a lossless round-trip. | 2026-07-10 | [link](../decisions.md#2026-07-10--schedule-item-get-returns-a-flat-non-polymorphic-dto-scheduleitemresponsemodel) |
| `api.scheduling-hardening` | Create/Replace handlers guard against null/whitespace `name` (`IsNullOrWhiteSpace`, not just `Length`) to prevent NRE-500s, template-item overlap validation compares by index (not record value-equality) to catch exact-duplicate items, and unreachable 404 `ProducesResponseType` attributes on create-only actions are trimmed. | 2026-07-13 | [link](../decisions.md#2026-07-13--scheduling-api-hardening-null-name-500s-duplicate-template-items-unreachable-404-172) |
| `api.search-allitems-paging` | `GET /api/v1/search/all-items` is paginated (capped page size, `Totals` field) to bound DoS exposure; the SPA add-all flow pages to completeness instead of relying on an unbounded response. | 2026-07-18 | [link](../decisions.md#2026-07-18--search-all-items-is-paged-to-cap-dos-exposure-spa-add-all-pages-to-completeness-293) |
| `api.search-field-values` | `GET /api/v1/search/fields/{name}/values?q=&limit=` returns distinct WHOLE values from the database for one of a narrow allow-list of catalog fields (not the Lucene term dictionary — analyzed `TextField`s store lowercased word tokens, e.g. "Science Fiction" → `science`/`fiction`, useless as a typeahead suggestion), 404 for an unknown field, a non-`text` field, or a `text` field with no distinct-value source; case-insensitive prefix-filtered on `q`, `limit` clamped to `[1, 50]` (default 50). | 2026-07-23 | [link](../decisions.md#2026-07-23--facet-value-typeahead-is-a-new-endpoint-allow-listed-to-text-fields-no-caching-434) |
| `api.search-paging-cap` | Search stays capped at 100 items per media kind; an overflowing kind's "See all" reuses library-browse paging instead of adding new API surface. | 2026-07-11 | [link](../decisions.md#2026-07-11--trash-see-all-reuses-library-browse-paging-search-stays-capped-per-kind-213) |
| `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) |
| `channel.origin-marker` | A new `Channel.Origin` (`ChannelOrigin` enum — `Unknown`/`UserCreated`/`AutoTuned`) records how a channel row was created and is stamped exactly once at insert (`AutoTuned` in `CreateChannelFromLineupHandler`, `UserCreated` in `CreateChannelHandler`), and is never mutated on a later edit. It is surfaced as a raw `origin` field on `ChannelResponseModel`; the SPA badges only `AutoTuned`. Rows predating the column read `Unknown` — provenance is **not** back-filled. | 2026-07-23 | [link](../decisions.md#2026-07-23--channel-origin-is-immutable-creation-provenance-stamped-at-insert-not-a-health-signal-414) |
| `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 deterministic assertions (`scripts/e2e-functional.sh`) as an advisory (non-blocking) job, not a `build` dependency or required check. Originally curl-only; since #445 the same job carries a second, headless-browser step for the contracts curl cannot express — see `ci.ui-e2e-harness`. | 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.ui-e2e-harness` | The UI-interactive E2E flows run as headless Playwright specs (`web/e2e/*.spec.ts`, driven by `scripts/e2e-ui.sh`) in a **second step of the existing advisory `functional-e2e` job**, never their own job; the browser is `chromium-headless-shell` **baked into the CI toolchain image** (`docker/ci/Dockerfile`, `PLAYWRIGHT_VERSION` kept equal to `web/package.json`'s EXACT `@playwright/test` pin), never installed per run; specs are `serial` with `retries: 0` and assert only contracts the curl harness structurally cannot reach. | 2026-07-25 | [link](../decisions.md#2026-07-25--ui-e2e-headless-playwright-flows-in-the-existing-functional-e2e-job-browser-baked-into-the-ci-image-445) |
| `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) |
| `concurrency.idempotent-concurrent-add` | A concurrent duplicate `Add*ToCollection` that loses the race on the composite-key unique constraint is treated as an idempotent no-op (skip the reindex/rebuild fan-out), not a 500 — detected via a provider-specific `TvContext.IsUniqueConstraintViolation` delegate defaulting to "no". | 2026-07-18 | [link](optimistic-concurrency.md#2026-07-18--concurrent-same-item-add-is-idempotent-not-a-500-catch-the-unique-violation-per-provider-308) |
| `concurrency.ifmatch-rfc7232` | `ConcurrencyHeaders.ParseIfMatch` is a real RFC 7232 entity-tag/list parser: a syntactically-valid tag that doesn't strong-match (weak/empty/non-canonical/out-of-range/list) returns 412, and only a genuine grammar violation returns 400. | 2026-07-12 | [link](optimistic-concurrency.md#2026-07-12--if-match-evaluates-per-rfc-7232-valid-but-non-matching--412-only-grammar-violations--400-265) |
| `concurrency.replace-all-contract` | Replace-all aggregate PUTs carry a uniform plain `int Version` concurrency token (EF `.IsConcurrencyToken()`), checked pre-save and enforced by the EF UPDATE guard, returning 412 (not 409) on a stale `If-Match`. | 2026-07-11 | [link](optimistic-concurrency.md#2026-07-11--optimistic-concurrency-contract-for-replace-all-puts-253-pr1-infra--block-reference) |
| `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-edit-token-scope` | `scripts/decisions_validate.py` arms `[decisions-edit]` only from a commit's **subject line** or a **`Decisions-Edit:` git trailer** — never from anywhere else in the message body, so a commit that merely *describes* the token cannot silently disable the rationale-rewrite guard. | 2026-07-25 | [link](../decisions.md#2026-07-25--the-decisions-edit-token-is-recognized-only-in-the-subject-line-or-a-trailer-never-in-body-prose-609) |
| `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.decision-optional-provenance` | Decision records gain two OPTIONAL fields — `stale-after: YYYY-MM-DD` on the metadata line and a `**Sources:**` line in the metadata block; the Open Knowledge Format (OKF) itself is NOT adopted as the record format. | 2026-07-25 | [link](../decisions.md#2026-07-25--okf-evaluated-and-rejected-as-a-replacement-two-of-its-optional-fields-adopted-603) |
| `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-level-attachment` | A channel can attach `GraphicsElement`s directly via a new `ChannelGraphicsElement` join table (a base layer under deco/playout-item elements), and a built-in text element (`on-now-next.yml`) is seeded once per database so the On Now/Next overlay works out of the box. | 2026-07-22 | [link](../decisions.md#2026-07-22--channel-level-graphics-element-attachment--seeded-on-nownext-text-element-74) |
| `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) |
| `locking.entitylocker-atomic-flags` | `EntityLocker` uses `Interlocked.CompareExchange`-guarded atomic flags plus a documented single-owner-release discipline (no owner tokens/leases); `Unlock*` on an already-unlocked slot returns `false` and logs a Warning rather than throwing. | 2026-07-11 | [link](../decisions.md#2026-07-11--entitylocker-atomic-flags--single-owner-release-discipline-no-owner-tokens-231) |
| `mcp.server-foundation` | `ErsatzTV.Mcp` is a fresh stdio JSON-RPC server wrapping frozen `/api/v1` with explicit narrow per-endpoint tools, read-only-by-default enforced at runtime (`ERSATZTV_ALLOW_WRITES`), machine-key auth, and opt-in `If-Match`. | 2026-07-20 | [link](../decisions.md#2026-07-20--mcp-server-ersatztvmcp-built-fresh-over-frozen-apiv1-read--cautious-writes-58) |
| `media.lastscan-null-boundary` | A never-scanned `LastScan` surfaces as `null` at the API/MCP boundary, not the `0001-01-01` MinValue sentinel — enforced by an ongoing read-boundary coercion plus a one-time data migration cleanup. | 2026-07-18 | [link](../decisions.md#2026-07-18--never-scanned-lastscan-surfaces-as-null-at-the-api-boundary-not-the-0001-01-01-minvalue-sentinel-409) |
| `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 34 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.per-agent-model-routing` | State the model tier (and effort, where the client exposes it) in the dispatch itself for every delegated agent — bounded recon → cheapest fast tier at `low`; mechanical slice against a documented contract → mid tier; judgment-heavy work → orchestrator tier; independent review → a different model family than the implementer. | 2026-07-25 | [link](workflow-process.md#2026-07-25--name-the-model-tier-for-every-dispatched-agent-a-pretooluse-gate-makes-the-silent-default-visible-583) |
| `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) |
| `release.live-e2e-required` | A PR that changes an API write-path handler must include a live-E2E pass (driving the real endpoint/screen and confirming the round-trip through a subsequent read), not only unit/characterization tests, and must state whether live-E2E ran or wasn't required. | 2026-07-12 | [link](../decisions.md#2026-07-12--live-e2e-is-a-required-step-for-api-write-path-handler-changes-303) |
| `release.merge-consent-autogrant` | When Done-when boxes are ticked, CI is green, and a fresh positive Review-verdict references head, the merge-consent hook emits `permissionDecision: allow` to actually suppress the redundant mechanical prompt — the derived state IS the consent, no separate conversational confirmation on that path. | 2026-07-12 | [link](release-ci-governance.md#2026-07-12--merge-consent-gate-auto-grants-when-satisfied-no-redundant-prompt-state-is-the-consent-314) |
| `release.migration-rehearsal-prodcopy` | Before promoting a migration-bearing release, rehearse the new image's migrations against a throwaway copy of the latest prod backup (`scripts/migration-smoke.sh`), gating PASS on the migrator's completion log line rather than HTTP readiness alone. | 2026-07-12 | [link](release-ci-governance.md#2026-07-12--release-path-rehearses-migrations-on-a-prod-db-copy-before-promoting-315) |
| `release.prepush-clean-worktree-guard` | A fail-open pre-push hook blocks a push when any file in the branch's diff vs `origin/main` also has uncommitted working-tree or index changes, since a stale-index commit (e.g. `git reset --soft` + `git add` over an edited-but-unstaged fix) can silently push, CI-test, and get reviewed a different tree than the one on disk. Scope is precise to pushed-diff files; escape hatch `ETV_ALLOW_DIRTY_PUSH=1`. | 2026-07-17 | [link](../decisions.md#2026-07-17--pre-push-guard-dont-push-a-file-whose-working-tree-copy-is-uncommitted-h13-416-session) |
| `release.promotion-floating-prod` | Prod tracks the floating `:prod` image reference; a tag build's immutable `:<version>` image is scanned first, then promotion happens via a separate manual `DeployStack`, with daily auto-update only as a fallback — tag with enough runway before 03:00 to avoid an unscanned promotion. | 2026-07-13 | [link](release-ci-governance.md#2026-07-13--release-promotion-floating-prod-exact-image-scan-before-manual-deploy-335) |
| `release.review-verdict-gate` | A PR may not merge until a `Review-verdict: <MERGEABLE\|APPROVED\|BLOCKED\|NOT-MERGEABLE> @ <head-sha>` comment references the PR's current head sha (short-sha prefix match, line-start marker only, negative wins over positive on the same head); folds into the H6 merge-consent hook as condition (c). | 2026-07-12 | [link](release-ci-governance.md#2026-07-12--review-verdict-merge-gate-latest-commit-must-be-reviewed-303-h10) |
| `rulebuilder.relative-date-macros` | The visual rule builder's `inLast`/`notInLast` date operators compile to/parse from the pre-existing `CustomMultiFieldQueryParser` macros `released_inthelast`/`released_notinthelast` and `added_inthelast`/`added_notinthelast`, value form `"<n> day\|week\|month\|year"`; there is no backend change. | 2026-07-23 | [link](../decisions.md#2026-07-23--relative-date-rule-builder-operators-are-a-frontend-only-mapping-onto-existing-lucene-macros-435) |
| `scan.collections-scan-status` | `GET /api/v1/media-sources/collections-scan-status` reports a family-global (not per-source), boolean-only active-scan set read from `IEntityLocker`; the SPA reconciles authoritatively against it (with a grace-tick helper) instead of a fixed client-side timeout. | 2026-07-12 | [link](../decisions.md#2026-07-12--external-collections-scans-get-an-authoritative-status-surface-271-the-spa-timeout-is-retired) |
| `scan.getoraddfolder-db-lookup` | `ILibraryRepository.GetOrAddFolder` resolves the existing folder via a DB query on `(LibraryPathId, Path)`, not the caller's `LibraryPath.LibraryFolders` in-memory navigation, since that navigation is only eager-loaded on the local scan path and is null on remote (Jellyfin) callers. | 2026-07-20 | [link](../decisions.md#2026-07-20--ilibraryrepositorygetoraddfolder-resolves-the-folder-from-the-db-not-the-callers-librarypathlibraryfolders-navigation-488) |
| `scan.jellyfin-mixed-content-library` | A Jellyfin library whose collection type is `mixed` (or absent) maps to one ErsatzTV library of `LibraryMediaKind.Mixed`, scanned by running the movie/television/music-video scanners in sequence against that single library — a library is a place, not a media kind. | 2026-07-20 | [link](../decisions.md#2026-07-20-489--jellyfin-mixed-content-libraries-map-to-one-library-holding-many-kinds) |
| `scan.musicvideo-server-identity` | Jellyfin music videos carry a per-library server identity (`JellyfinMusicVideo : MusicVideo` with `ItemId`/`Etag`, TPT table + ItemId index), so `JellyfinMusicVideoLibraryScanner` folds onto a shared `MediaServerMusicVideoLibraryScanner` base that diffs the **server item id** and soft-trashes (`FlagFileNotFound`) instead of diffing local paths and hard-deleting. Rows predating the identity are **adopted in place** — the identity row is inserted against the same `MediaItem` id, scoped to the scanned library's own `LibraryPath` — never deleted and re-added. | 2026-07-25 | [link](../decisions.md#2026-07-25--music-videos-carry-a-per-library-server-identity-reconciliation-is-an-itemid-diff--soft-trash-496) |
| `scan.projection-failure-sweep-guard` | `MediaServerReconciliationGuard` takes a per-enumeration projection-failure count and refuses the file-not-found sweep (logged loudly) whenever it is non-zero against a non-empty existing set — at all six sweeps, including the nested per-show season and per-season episode ones #477 left unguarded (via `ShouldFlagMissingDescendants`, which applies the failure refusal but not #477's empty-fetch branch). Deliberate guard-clause skips (STRM, virtual, unsupported type) are explicitly **not** failures and never suppress a sweep. The missing-fraction / ratio threshold floated by #477 is **rejected**, not deferred. | 2026-07-25 | [link](../decisions.md#2026-07-25--a-media-server-sweep-also-refuses-when-the-api-client-silently-dropped-items-whose-projection-threw-the-ratio-threshold-is-rejected-484) |
| `scan.zero-item-fetch-guard` | A media-server library sweep refuses to flag missing items when a successful fetch returns zero incoming items against a non-empty existing set (`MediaServerReconciliationGuard.ShouldFlagMissing`), rather than treating an ambiguous empty result as a full-library deletion. | 2026-07-19 | [link](../decisions.md#2026-07-19--a-media-server-library-sweep-refuses-to-flag-when-a-successful-fetch-returns-zero-items-rather-than-nuking-the-whole-library-477) |
| `sched.auto-tune-foundation` | Auto-tune preview enumeration uses EF distinct+count queries for exact counts, while each created channel is persisted as a live SmartCollection; coexistence with existing channels/numbers is additive-only, never mutating. | 2026-07-16 | [link](../decisions.md#2026-07-16--auto-tuning-enumerates-via-ef-persists-via-smartcollection-additive-coexistence-69) |
| `sched.autotune-detailpanel-members` | The Auto-Tune DetailPanel's per-channel content-source list is a live `ISearchIndex.Search` roll-up through the server-owned `AutoTuneAxisMap.GenerateQuery`, not an EF distinct+count query, so the preview matches exactly what the built channel's SmartCollection will contain. | 2026-07-17 | [link](../decisions.md#2026-07-17--auto-tune-detailpanel-member-list--live-search-index-roll-up-not-ef-enumeration-384) |
| `sched.autotune-per-channel-overrides` | Auto-Tune per-channel overrides reuse the Channel Builder's advanced-options DTO verbatim; per-source weights and bug-colour logo are deferred to #425. | 2026-07-17 | [link](../decisions.md#2026-07-17--auto-tune-per-channel-overrides-reuse-the-channel-builder-advanced-options-dto-weights--bug-colour-logo-split-out-to-425-385) |
| `sched.autotune-per-source-weights` | Auto-Tune per-source rotation weights and query corrections are supplied at bulk-create time via #70's MultiCollection/SmartCollection machinery, not a post-hoc PUT. | 2026-07-18 | [link](../decisions.md#2026-07-18--auto-tune-per-source-weights-ride-70s-multicollection-machinery-created-at-tune-time-not-a-post-hoc-put-425) |
| `sched.clock-padding-existing` | Clock-boundary padding already exists via `FillerPreset`'s `FillerMode.Pad` (Classic) and `pad_to_next`/`pad_until` (Sequential/YAML); #77 is closed as verified+documented, not built new, with a one-click per-channel toggle deferred behind the #388 design-system epic. | 2026-07-17 | [link](../decisions.md#2026-07-17--clock-boundary-schedule-padding-already-exists-fillermodepad-77-verified-convenience-toggle-deferred) |
| `sched.clock-padding-schedule-toggle` | A `ProgramSchedule.PadToNearestMinute` (nullable int; null = off) makes the Classic builder pad every content item up to the next N-minute clock boundary without a hand-wired Pad `FillerPreset`, by reusing the existing per-content-item Pad path in `PlayoutModeSchedulerBase.AddFiller`. It extends — does not supersede — `sched.clock-padding-existing` (#77/#388). | 2026-07-22 | [link](../decisions.md#2026-07-22--per-schedule-clock-boundary-padding-is-a-synthetic-content-less-pad-over-the-existing-per-episode-machinery-392) |
| `sched.playbackorder-support-matrix` | Every build-time dispatch site logs a loud (non-fatal) warning on an unsupported `PlaybackOrder`, and a declared `PlaybackOrderSupport` matrix + partition tripwire test makes adding a new order safe by construction. | 2026-07-18 | [link](../decisions.md#2026-07-18--unsupported-playbackorder-is-loud-at-build-time-a-declared-support-matrix-and-tripwire-test-make-new-orders-safe-by-construction-403) |
| `sched.reshuffle-scoped-reset` | `POST /api/v1/playouts/{id}/reshuffle` runs `ErasePlayoutHistory` (reseeds `Playout.Seed` + clears anchors/rerun-history) then enqueues a scoped `Reset` build, so reshuffle always reseeds — even for the non-Classic kinds `Reset` alone wouldn't reseed; `Playout.Seed` is surfaced on list/detail DTOs as visible confirmation. | 2026-07-16 | [link](../decisions.md#2026-07-16--per-playout-reshuffle--scoped-reset-build-seed-surfaced-71) |
| `sched.seasonal-scheduling-existing` | Seasonal/date-conditional scheduling already ships first-class via `IAlternateScheduleItem` (Classic `ProgramScheduleAlternate`, Block `PlayoutTemplate`) evaluated by `AlternateScheduleSelector.GetScheduleForDate` (first match in `Index` order, catch-all last); #73 is closed as already-implemented with a docs-only "seasonal/holiday" recipe added, not new code. | 2026-07-17 | [link](../decisions.md#2026-07-17--seasonal--date-conditional-scheduling-already-exists-alternate-schedules--playout-templates-73-closed-as-implemented) |
| `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) |
| `security.contract-freeze-honesty` | The OpenAPI doc's declared security/401 scheme is generated from the same `ApiKeyAuthorizationFilter.EndpointRequiresKey` predicate the runtime enforces (so declared auth can't drift from enforced auth), every `/api/*` action returns a ResponseModel (no raw Application VMs), and Channel REST resources are keyed by immutable `Id`, never mutable `Number`. | 2026-07-12 | [link](api-auth-security.md#2026-07-12-197-bundle-c--contract-freeze-honesty) |
| `security.corp-same-origin` | `SecurityHeadersMiddleware` sends `Cross-Origin-Resource-Policy: same-origin` on every response including `/docs`/`/openapi`, blocking cross-origin `no-cors` embedding without affecting allowed CORS-mode fetches or server-side Jellyfin `/iptv/*` requests. | 2026-07-13 | [link](api-auth-security.md#2026-07-13--cross-origin-resource-policy-same-origin-on-every-response-330) |
| `security.csp-permissions-policy` | `SecurityHeadersMiddleware` sends an enforcing (not report-only) `Content-Security-Policy` (no `unsafe-inline`/`unsafe-eval`; the one inline theme-bootstrap script allow-listed by hash) and a deny-all `Permissions-Policy` on the SPA/`/api`/`/artwork`/`/iptv`; `/docs` and `/openapi` keep only the baseline headers, excluded from CSP because Scalar needs inline bootstrap. | 2026-07-12 | [link](api-auth-security.md#2026-07-12--enforcing-csp--permissions-policy-on-the-host-319-zap-baseline) |
| `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.iptv-access-token-transport` | The `/iptv` `?access_token=` value is percent-encoded (`Uri.EscapeDataString`) everywhere it is interpolated into an M3U/HLS/XMLTV URL (XMLTV additionally XML-escapes the encoded value), so a structural character can't malform the manifest or guide; Serilog logs a scrubbed request path (`access_token``***` via `IncludeQueryInRequestPath = false` + a `RequestPathScrubbed` enricher), so a 5xx/Debug `/iptv` request never writes the token; and every dynamic token-bearing `/iptv` manifest (`channels.m3u`, `xmltv.xml`, the HLS multi-variant/media playlists) returns `Cache-Control: private, no-store`. | 2026-07-23 | [link](api-auth-security.md#2026-07-23--access_token-transport-hardening-percent-encode-in-m3uhls-redact-from-logs-no-store-on-tokened-manifests-421-559) |
| `security.iptv-browser-token` | Under a JWT-enabled deployment (`JWT:IssuerSigningKey` set), the browser SPA obtains a short-lived, globally-scoped `/iptv/*` access token from an authenticated `GET /api/v1/auth/iptv-token` and appends it as `?access_token=`; the endpoint answers 204 when JWT is disabled (nothing to mint). Lifetime defaults to 60 min, configurable via `JWT:BrowserTokenLifetimeMinutes`. | 2026-07-22 | [link](api-auth-security.md#2026-07-22--short-lived-browser-iptv-token-so-the-spa-reaches-iptv-under-jwt-auth-552) |
| `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) |
| `spa.channel-editor-create-logo` | Bare-channel create is a "New blank channel" action on the channels list (reusing Blazor's add-mode defaults) that navigates into the full editor, and an external logo URL always wins over an uploaded logo, matching `ChannelEditViewModel` precedence. | 2026-07-11 | [link](../decisions.md#2026-07-11--channel-editor-bare-create-entry-point--external-logo-mutual-exclusion-212) |
| `spa.channel-renumber-prompt` | Channel renumbering uses a sequential `prompt()`-driven "Renumber" action instead of drag-to-reorder. | 2026-07-09 | [link](../decisions.md#2026-07-09--channel-numbers-prompt-driven-sequential-renumber-instead-of-drag-to-reorder) |
| `spa.channels-screen-extraction` | The Channels domain is a single-file zero-prop screen (`web/src/screens/ChannelsScreen.tsx`) with a colocated test file and no sibling helper directory, since its pure logic is too small (~30 lines) to justify a separate business-rule layer like Schedules' `itemRules.ts`. | 2026-07-11 | [link](spa-modularization.md#2026-07-11--channels-screen-extraction-244-single-file-screen-no-sibling-helper-dir-epic-243-phase-1) |
| `spa.collection-custom-order-ui` | Collection custom ordering uses per-row Move up/Move down buttons (not drag) and is offered for any manual collection with custom order enabled, not just movies-only. | 2026-07-09 | [link](../decisions.md#2026-07-09--collection-custom-order-move-updown-buttons-any-kind-collections) |
| `spa.datetime-local-input` | The channel-mode date/time input uses a native `<input type="datetime-local">` instead of free-text Chronic natural-language parsing. | 2026-07-09 | [link](../decisions.md#2026-07-09--datetime-local-instead-of-chronic-natural-language-start-parsing) |
| `spa.deco-templates-table` | The deco-templates editor also renders its day/deco assignment as a table, extending (not replacing) the templates-editor-table convention. | 2026-07-09 | [link](../decisions.md#2026-07-09--table-not-calendar-convention-also-covers-the-deco-templates-editor) |
| `spa.download-sample-gate` | The SPA disables both Download Media Sample and Download Results while a troubleshooting session is starting/running (Blazor only gated Download Results). | 2026-07-09 | [link](../decisions.md#2026-07-09--spa-gates-download-media-sample-while-a-session-is-active) |
| `spa.legacy-redirect-matcher` | `LegacyUiRedirects.TryGetRedirect` is a two-tier matcher — an exact `OrdinalIgnoreCase` `Map` (Tier 1) then an ordered segment-template pattern list (Tier 2, first-match-wins) — collision-free by construction, with a guard invariant that no rule may prefix-match `/api`, `/artwork`, `/docs`, `/openapi`, `/iptv`, `/app`, or `/media/sources`. | 2026-07-11 | [link](../decisions.md#2026-07-11--legacyspa-redirect-matcher-exact-map--ordered-segment-template-patterns-204) |
| `spa.logs-page-size-local` | The Logs page rows-per-page preference is stored in `window.localStorage` (`ctv-logs-page-size`), not a server `ConfigElement`. | 2026-07-11 | [link](../decisions.md#2026-07-11--logs-page-size-is-a-client-local-preference-not-a-server-configelement) |
| `spa.playback-troubleshoot-poll` | The playback-troubleshooting screen reports FFmpeg completion by polling `GET /api/troubleshoot/playback/status` (~2s) rather than a server push channel. | 2026-07-09 | [link](../decisions.md#2026-07-09--playback-troubleshooting-completion-feedback-poll-status-no-push-channel) |
| `spa.playout-reset-button` | The SPA keeps a single Reset action (server picks the default build mode) and drops Blazor's separate "Schedule reset" button since its capability already exists via the playout's Edit-details flow. | 2026-07-09 | [link](../decisions.md#2026-07-09--per-playout-schedule-reset-button-dropped-reset-uses-the-server-default-build-mode) |
| `spa.playouts-screen-extraction` | The Playouts domain (including its unguarded `PlayoutsRouteScreen` route wrapper with local pathname/popstate state) moved as one unit into `web/src/screens/PlayoutsScreen.tsx`, keeping its screen-specific sub-path route ownership colocated with the base screen; a pure structural move with no API/route/CSS/behavior change. | 2026-07-14 | [link](spa-modularization.md#2026-07-14--playouts-screen-extraction-245-screen-owned-route-wrapper-epic-243-phase-2) |
| `spa.rulebuilder-nesting` | The visual rule builder's `Group` nests recursively to a single shared cap, `MAX_GROUP_DEPTH` (`types.ts`, currently 5, root group = depth 0) — read by the UI's "Add group" gate, `parse.ts` and the round-trip property-test generator alike; everything else about the builder is unchanged from #176 (compile-only closed Lucene subset over the stored query string, no stored rule AST, field vocabulary from `GET /api/v1/search/fields`). | 2026-07-25 | [link](../decisions.md#2026-07-25--rule-builder-group-nesting-is-bounded-arbitrary-depth-max_group_depth-not-one-level-436) |
| `spa.schedules-editor-draft-save` | The schedules SPA editor mutates a local draft and flushes one explicit Save (`PUT /api/schedules/{id}/items`) instead of instant-persisting each action; Copy deep-copies all source references (fixing a Blazor omission); the shuffled-schedule GET's `EnforceProperties` lossy normalization is preserved and mirrored in the SPA's option lists. | 2026-07-11 | [link](../decisions.md#2026-07-11--schedules-spa-editor-draftexplicit-save-over-instant-persist-copy-includes-multismartrerun-shuffled-get-normalization-preserved) |
| `spa.sidebar-collapsible-accordions` | The shell sidebar's collapse + nav-group-accordion state persists under two hyphenated `ctv-sidebar-*` localStorage keys (matching the repo's `ctv-` convention, not the prototype's dotted names); labeled groups default-collapsed. | 2026-07-18 | [link](../decisions.md#2026-07-18--collapsible-sidebar--nav-group-accordions-two-ctv-sidebar--localstorage-keys-labeled-groups-default-collapsed-396) |
| `spa.spa-rebuild-decision` | The UI is a full React SPA (ChicoryTV) rebuild over the REST API, not a Blazor Server reskin. | 2026-06 | [link](../decisions.md#2026-06--ui-rebuild-is-a-react-spa-chicorytv-on-the-rest-api-not-a-blazor-reskin) |
| `spa.templates-editor-table` | The SPA templates editor renders day/block assignment as a table, not Blazor's drag-and-drop calendar grid — an accepted, deliberate parity deviation. | 2026-07 | [link](../decisions.md#2026-07--templates-editor-in-the-spa-is-a-table-not-blazors-drag-calendar) |
| `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-cleanup-scope-by-pid` | An E2E harness or agent may only kill processes whose PIDs it captured at launch — capture the PID; whoever owns the lifecycle releases it from a `trap ... EXIT INT TERM`. Never `pkill -f "dotnet ErsatzTV.dll"` (or any pattern that can match a process this run did not start). A foreign listener is reported, not reaped. | 2026-07-25 | [link](workflow-process.md#2026-07-25--e2e-cleanup-kills-only-the-pids-it-started-never-a-pkill--f-pattern-586) |
| `testing.e2e-local-fresh-config-dir` | Always point `scripts/e2e-local.sh` at a fresh config dir — leftover channels/schedules/DB rows bleed state between runs and corrupt assertions. (The *readiness-probe hang* this record was originally written about was fixed in #533; the fresh-dir rule stands on state-bleed grounds alone.) | 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.scripted-playout-golden-deferred` | The `PlayoutBuildGoldenTests` in-memory golden net covers Sequential (YAML) as of #381. Scripted's *end-to-end pipeline* is excluded — `ScriptedPlayoutBuilder` runs a user-authored external program that drives the engine over HTTP loopback, which the in-memory harness can't pin — so that full-pipeline (integration) harness is deferred to #563. But the scheduling *behavior* those scripts drive lives entirely in the in-process `SchedulingEngine` (the `ScriptedScheduleController` is a 1:1 pass-through to it), which IS directly unit/golden-testable; the earlier "Scripted is un-golden-able by construction" framing overstated the constraint by conflating transport with engine. #395 extracts that shared switch to `ContentEnumeratorBuilder` and adds a direct regression net (`ContentEnumeratorBuilderTests`) over it. | 2026-07-22 | [link](../decisions.md#2026-07-22--sequential-yaml-playout-gets-a-golden-scripted-is-excluded-from-the-golden-net-by-construction-381) |
| `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) |
## Review due
@@ -184,6 +180,6 @@ record. Sorted soonest-first.
| Stale after | Key | Record |
| --- | --- | --- |
| 2027-01-15 | `ci.runner-placement` | [link](records/ci/runner-placement.md) |
| 2027-02-15 | `ci.infra-shaped-red-under-load` | [link](records/ci/infra-shaped-red-under-load.md) |
| 2027-03-15 | `ci.peak-anon-measurement` | [link](records/ci/peak-anon-measurement.md) |
| 2027-01-15 | `ci.runner-placement` | [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) |
| 2027-02-15 | `ci.infra-shaped-red-under-load` | [link](workflow-process.md#2026-07-21--an-infra-shaped-red-under-host-load-is-not-a-code-failure-542) |
| 2027-03-15 | `ci.peak-anon-measurement` | [link](../decisions.md#2026-07-19--ci-test-job-reports-a-sampled-true-peak-anon-not-cache-inflated-memorypeak-412) |
+508 -15
View File
@@ -13,22 +13,515 @@ contract-freeze), #206 (Blazor-removal auth posture), #283 (artwork content-type
## Contents
- [2026-07-11 — Blazor removal auth posture: no new exposure beyond phase (a); real auth deferred to #197 (#206)](#2026-07-11--blazor-removal-auth-posture-no-new-exposure-beyond-phase-a-real-auth-deferred-to-197-206)
- [2026-07-11 — Baseline security response headers + Phase-0 API hardening (#197, PR #279)](#2026-07-11--baseline-security-response-headers--phase-0-api-hardening-197-pr-279)
- [2026-07-12 — Artwork content-type is sniffed, never reflected (#283, S4/S9 stored XSS)](#2026-07-12--artwork-content-type-is-sniffed-never-reflected-283-s4s9-stored-xss)
- [2026-07-12 — Fail-closed API auth + sensitive-read tier + CORS/ForwardedHeaders lockdown (#197 Bundle A, PR #292)](#2026-07-12--fail-closed-api-auth--sensitive-read-tier--corsforwardedheaders-lockdown-197-bundle-a-pr-292)
- [2026-07-12 (#197 Bundle C — contract-freeze honesty)](#2026-07-12-197-bundle-c--contract-freeze-honesty)
- [2026-07-12 — Browser SPA session auth: `/api` accepts session OR machine key (#295 PR1, server-only)](#2026-07-12--browser-spa-session-auth-api-accepts-session-or-machine-key-295-pr1-server-only)
- [2026-07-12 — #295 PR2: SPA session cutover + #301 side-effecting-GET POST-ification](#2026-07-12--295-pr2-spa-session-cutover--301-side-effecting-get-post-ification)
- [2026-07-12 — Enforcing CSP + Permissions-Policy on the host (#319, ZAP baseline)](#2026-07-12--enforcing-csp--permissions-policy-on-the-host-319-zap-baseline)
- [2026-07-13 — Cross-origin resource policy: `same-origin` on every response (#330)](#2026-07-13--cross-origin-resource-policy-same-origin-on-every-response-330)
- [2026-07-22 — Short-lived browser IPTV token so the SPA reaches `/iptv/*` under JWT auth (#552)](#2026-07-22--short-lived-browser-iptv-token-so-the-spa-reaches-iptv-under-jwt-auth-552)
- [2026-07-23 — access_token transport hardening: percent-encode in M3U/HLS, redact from logs, no-store on tokened manifests (#421, #559)](#2026-07-23--access_token-transport-hardening-percent-encode-in-m3uhls-redact-from-logs-no-store-on-tokened-manifests-421-559)
---
## Records formerly in this file
## 2026-07-11 — Blazor removal auth posture: no new exposure beyond phase (a); real auth deferred to #197 (#206)
`key: security.blazor-removal-auth-posture` · `status: active` · `since: 2026-07-11` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** Blazor removal, auth posture sign-off, OIDC attachment point · paths: `ErsatzTV/Startup.cs`, `ErsatzTV/Pages` · issues: #206, #91, #197
**Mechanics:** `ErsatzTV/Startup.cs` (Razor Pages/OIDC registration); #91 phase (b) removal PR
Each record below moved to its own file under `records/` (ersatztv#610); the rationale is
unchanged. Resolve by **key** — that is the stable identity. A date-based pointer from
another doc or an old issue comment should land here and then follow the link.
Sign-off for the #91 phase (b) removal-gate item #206 ("deleting the last challenged Blazor page leaves
only the open SPA"). The actual authorization wiring in `ErsatzTV/Startup.cs` + `ErsatzTV/Pages` was
enumerated in code (not assumed) before clearing the gate.
- 2026-07-11 — Baseline security response headers + Phase-0 API hardening (#197, PR #279) — [`security.baseline-response-headers`](records/security/baseline-response-headers.md)
- 2026-07-11 — Blazor removal auth posture: no new exposure beyond phase (a); real auth deferred to #197 (#206) — [`security.blazor-removal-auth-posture`](records/security/blazor-removal-auth-posture.md)
- 2026-07-12 (#197 Bundle C — contract-freeze honesty) — [`security.contract-freeze-honesty`](records/security/contract-freeze-honesty.md)
- 2026-07-12 — #295 PR2: SPA session cutover + #301 side-effecting-GET POST-ification — [`security.session-cutover-postify`](records/security/session-cutover-postify.md)
- 2026-07-12 — Artwork content-type is sniffed, never reflected (#283, S4/S9 stored XSS) — [`security.artwork-content-type-sniff`](records/security/artwork-content-type-sniff.md)
- 2026-07-12 — Browser SPA session auth: `/api` accepts session OR machine key (#295 PR1, server-only) — [`security.session-auth-dual-credential`](records/security/session-auth-dual-credential.md)
- 2026-07-12 — Enforcing CSP + Permissions-Policy on the host (#319, ZAP baseline) — [`security.csp-permissions-policy`](records/security/csp-permissions-policy.md)
- 2026-07-12 — Fail-closed API auth + sensitive-read tier + CORS/ForwardedHeaders lockdown (#197 Bundle A, PR #292) — [`security.fail-closed-api-auth`](records/security/fail-closed-api-auth.md)
- 2026-07-13 — Cross-origin resource policy: `same-origin` on every response (#330) — [`security.corp-same-origin`](records/security/corp-same-origin.md)
- 2026-07-22 — Short-lived browser IPTV token so the SPA reaches `/iptv/*` under JWT auth (#552) — [`security.iptv-browser-token`](records/security/iptv-browser-token.md)
- 2026-07-23 — access_token transport hardening: percent-encode in M3U/HLS, redact from logs, no-store on tokened manifests (#421, #559) — [`security.iptv-access-token-transport`](records/security/iptv-access-token-transport.md)
**What is gated today**
- **OIDC** (`OidcHelper.IsEnabled` — active only when `Authority`/`ClientId`/`ClientSecret` are configured):
`AddAuthentication` (cookie default, `oidc` challenge) + `AddAuthorization` `DefaultPolicy =
RequireAuthenticatedUser` + **`AddRazorPages(… AuthorizeFolder("/"))`** (Startup.cs:379-385) +
`blazor.UseAuthentication()/UseAuthorization()` inside the Blazor `MapWhen` branch (Startup.cs:764-770).
`AuthorizeFolder("/")` gates **Razor Pages only**, and the sole user-facing Razor Page is
`Pages/_Host.cshtml` — the Blazor Server host (the other `.cshtml`, `Shared/_Favicons.cshtml`, is a
cosmetic partial). **So the OIDC challenge protects exactly the Blazor UI and nothing else.**
- **`/app` (SPA)** is served by its own `MapWhen(path=/app)` static-file branch (Startup.cs:701-714) with
**no authentication/authorization middleware** — open since phase (a) (`/``/app`, PR #148).
- **`/api/*` controllers** carry no `[Authorize]` (verified: zero attributes in `Controllers/`); the
Razor-Pages `AuthorizeFolder`/`DefaultPolicy` never reach them. Their only optional gate is the
per-endpoint `ApiKeyAuthorizationFilter` (API-key on mutating JSON endpoints), independent of OIDC/Blazor.
- **`/iptv/*`** is gated by `ConditionalIptvAuthorizeFilter` (JWT `JwtOnlyScheme`, active only when
`JwtHelper.IsEnabled`) in its own `MapWhen` branch (Startup.cs:797-803) — independent of Blazor.
**Posture after Blazor removal.** Removing `Pages/_Host.cshtml`, `AddRazorPages`/`AuthorizeFolder("/")`,
`blazor.UseAuthentication/UseAuthorization`, `MapBlazorHub`, and `MapFallbackToPage("/_Host")` deletes the
OIDC challenge's **only attachment point** — no user-facing surface remains challenged. **No capability is
lost:** every Blazor-served capability already has an open SPA equivalent (the #91 parity effort), and the
SPA was already the unauthenticated path since phase (a), so removal exposes nothing a user could not already
reach via `/app`.
**The one honest caveat (not a regression introduced by removal):** an OIDC-configured operator's *Blazor*
admin UI sits behind a login today; after removal there is no login-gated admin UI at all (the SPA admin UI
is open). That exposure delta already happened at **phase (a)** (the open SPA became the default admin
surface); removal only deletes the now-redundant challenged duplicate. Designing real SPA/API authentication
is deliberately deferred to **#197** (cold API security review — a HARD GATE before any remote exposure).
**Removal-PR must-not-break (independent gates that survive):** `ConditionalIptvAuthorizeFilter` (`/iptv/*`
JWT), `ApiKeyAuthorizationFilter` (mutating `/api/*`), and `JwtHelper` access_token query support. **Leave
the OIDC service registrations in place** (conditional on config, inert once no Razor Page consumes them) —
ripping OIDC out is a #197 decision, not a removal-PR one. The removal PR removes only the Blazor-attached
pieces above; `MapControllers()` + `/docs` (Scalar), currently co-hosted in the Blazor `MapWhen` branch, must
survive the surgical reduction.
## 2026-07-11 — Baseline security response headers + Phase-0 API hardening (#197, PR #279)
`key: security.baseline-response-headers` · `status: active` · `since: 2026-07-11` · `supersedes: none` · `superseded-by: none`
**Rule:** `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.
**Signals:** security headers, nosniff, Phase-0 hardening, constant-time comparison · paths: `ErsatzTV/Middleware/SecurityHeadersMiddleware` · issues: #197, #279, #283
**Mechanics:** `ErsatzTV/Middleware/SecurityHeadersMiddleware`
Phase-0 of the #197 remediation — the posture-**independent** safe subset, shipped ahead of the
fail-closed/CORS/versioning posture work tracked in #280#289.
- **Baseline security headers on every response.** New `ErsatzTV/Middleware/SecurityHeadersMiddleware`,
registered **first** in the pipeline (before the `/iptv` `MapWhen` branch and `UseCors`), so it covers
`/api`, `/iptv`, `/artwork`, static, the SPA fallback, and filter-produced 4xx alike — which is why it's
middleware, not an MVC filter. It sets `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, and
`Referrer-Policy: strict-origin-when-cross-origin`. `nosniff` is the standing backstop for the artwork
content-type MIME-sniffing risk (#283). **CSP and HSTS are deliberately NOT included here**: CSP must be
validated against the ChicoryTV SPA's inline assets, and HSTS is a proxy/TLS-termination decision — both
belong to the #197 posture design (#284/roadmap), not this baseline. Headers are set eagerly (not via
`Response.OnStarting`); safe today because the pipeline has no `UseExceptionHandler`/`UseStatusCodePages`
that would `Response.Clear()` — switch to `OnStarting` if one is ever added.
- **Constant-time API-key comparison.** `ApiKeyAuthorizationFilter` compares `X-Api-Key` with
`CryptographicOperations.FixedTimeEquals` (over UTF-8 bytes) instead of ordinal `string.Equals`, removing
the response-timing oracle on the write key. Accept/reject behavior is otherwise identical.
- **Playout pagination clamped.** `GET /api/playouts` and `GET /api/playouts/{id}/items` now clamp
`Math.Clamp(pageSize, 1, 100)` + `Math.Max(0, pageNum)` before the query — applying the api-conventions §1
clamp convention the other paged endpoints already follow (these two were passing the raw client value
straight to EF `Take()`).
The larger #197 posture (fail-closed writes, sensitive-read auth tier, CORS lockdown, `/api/v1` versioning,
the OpenAPI security scheme) is decomposed into #280#289 with the phased roadmap on #197; those PRs will
append their own decisions here as they land.
## 2026-07-12 — Artwork content-type is sniffed, never reflected (#283, S4/S9 stored XSS)
`key: security.artwork-content-type-sniff` · `status: active` · `since: 2026-07-12` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** stored XSS, content-type sniffing, artwork upload/serve · paths: `ErsatzTV.Core/Images/ImageContentTypes.DetectContentType`, `GetCachedImagePathHandler` · issues: #283, S4, S9
**Mechanics:** `ErsatzTV.Core/Images/ImageContentTypes`
The artwork upload/serve path trusted client-supplied content types at both ends, giving a stored-XSS
chain on **unauthenticated** GET sinks: upload `<script>` bytes declared `image/png`
`GET /iptv/logos/{hash}?contentType=text/html` served them as HTML in the ErsatzTV origin. The #279
`nosniff` header is not a fix here — the server was *explicitly declaring* `text/html`, which the browser
honors regardless of `nosniff`. The trust was the bug; the fix removes it at both ends.
- **Upload derives the content type from the bytes, never the declared value.** `UploadArtworkHandler`
buffers the (size-bounded) upload and calls `ErsatzTV.Core/Images/ImageContentTypes.DetectContentType`,
which uses SkiaSharp's `SKCodec` to identify the format from the image header only — pixels are **not**
decoded, so this can't be turned into a decompression-bomb vector. A payload that isn't one of the
accepted raster formats (png/jpeg/gif/webp) is rejected 422; the declared `Content-Type` is no longer
read at all (the field was dropped from the `UploadArtwork` command).
- **Serve sniffs the stored file; the `?contentType=` reflection is gone.** `GetCachedImagePath` no longer
carries a `ContentType`, and `GetImage` (`/iptv/logos`) / `GetWatermark` (`/artwork/watermarks`) dropped
their `[FromQuery] contentType` binding. `GetCachedImagePathHandler` always derives the MIME type from the
file (`MimeTypes.GetMimeTypeFromFile`) and **clamps it to the image allow-list** (`ImageContentTypes.IsAccepted`),
serving `application/octet-stream` for anything else — so a file whose bytes are not an accepted image (a
legacy cache entry poisoned before the upload sniff landed, or a hypothetical polyglot) is a non-renderable
download, never HTML/script. The removal is **structural** — there is no longer any request path that lets a
client choose the served `Content-Type`. `ArtworkContentTypeModel.UrlWithContentType` now returns the bare path, and the SPA
watermark/logo previews no longer append the query.
- **Defense-in-depth on the persisted JSON DTOs.** The `{path, contentType}` bodies (channel logo, watermark)
run their content type through `ArtworkContentTypeModel.Sanitized()`, which blanks anything outside the
image allow-list before it is stored — so a stale/hostile value can't be reflected by any future code path
even though the serve route already ignores it.
- **S9 upload-size DoS.** Kestrel `Limits.MaxRequestBodySize` is now set from `ETV_MAXIMUM_UPLOAD_MB`, so an
oversized body is rejected as it is read rather than only after the controller's post-binding `file.Length`
check (kept as the friendly-error backstop). This is a global bound; the app has no other large inbound
body (streaming is outbound GET).
`ImageContentTypes` is the single source of truth for the accepted image types (the allow-list previously
duplicated in `UploadArtworkHandler`). Both serve sinks are `[ApiExplorerSettings(IgnoreApi = true)]`, so
none of this changes the OpenAPI document.
## 2026-07-12 — Fail-closed API auth + sensitive-read tier + CORS/ForwardedHeaders lockdown (#197 Bundle A, PR #292)
`key: security.fail-closed-api-auth` · `status: active` · `since: 2026-07-12` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** fail-closed auth, sensitive-read tier, CORS allowlist, single API key · paths: `IApiKeyProvider`, `ErsatzTV/Services/ApiKeyProvider.cs`, `FileSystemLayout.ApiKeyPath` · issues: #197, #280, #281, #282, #284, #285
**Mechanics:** `docs/api-conventions.md` §5; `ApiControllerSecurityTests`
Phase-1 of the #197 remediation — the auth posture that must land before any remote exposure.
Owner decisions (confirmed this session): **single API key** (no read/write split), and
**`Api:RequireKeyForReads` defaults `true`** (the whole `/api` surface requires the key). This does
**not** affect Jellyfin/streaming: `/iptv/*` (playlist/guide/streams/logos) and `/artwork/*` are outside
the filter's `/api` scope and keep their own optional access-token; only the management API the SPA talks
to is gated.
- **Fail-closed writes (#280, S1).** The empty-key "open" branch is deleted; there is no open mode. New
`IApiKeyProvider` (`ErsatzTV/Services/ApiKeyProvider.cs`, singleton, resolved once at startup) yields a
never-empty key: `Api:WriteKey` if set, else a key persisted at `FileSystemLayout.ApiKeyPath`
(`/config/api.key`, `0600`, path logged not value), else a generated 256-bit hex key. Every mutating
`/api` request now requires `X-Api-Key`.
- **Sensitive-read tier (#282, S3/S5).** Reads are gated by `Api:RequireKeyForReads` (default true) OR a
new `[RequiresApiKey]` marker (mirror of `[SkipApiKeyAuthorization]`) applied to
`Troubleshoot`/`Logs`/`Settings`/`Maintenance`, so that tier stays gated even if an operator opts reads
open. `OPTIONS` preflight is exempt (CORS middleware owns it). `ApiControllerSecurityTests` asserts the
tier reflectively.
- **Delete dead non-`/api` mutation surfaces (#281, S2).** `SortController`
(`POST media/collections/{id}/items`, dead Blazor SortableJS residue — the SPA uses
`PUT /api/collections/{id}/custom-order`) and `AccountController` (`POST account/logout`, dead OIDC)
bypassed the key because they sat outside `/api`. Removed rather than guarded.
- **CORS opt-in (#284, S6).** `AllowAnyOrigin/Method/Header` is replaced by the `ApiCors` policy: an
exact-origin allowlist from `Api:CorsAllowedOrigins` (semicolon list) that permits `X-Api-Key`/`If-Match`
and exposes `ETag`; with no origins configured there is no cross-origin access (the SPA is same-origin).
- **ForwardedHeaders trust + scanner loopback (#285, S7/S10).** `GET /api/maintenance/gc``POST`
(crawler-triggerable GC; spec regenerated). `ForwardedHeaders` trust is configurable via
`ForwardedHeaders:KnownProxies`/`KnownNetworks` — **unconfigured preserves the current trust-all
behavior but logs a warning** (flipping the default to loopback-only would break reverse-proxy scheme/host
detection and thus M3U/XMLTV absolute URLs — the operator must name their proxy network). `ScannerController`
gains `[LocalhostOnly]` (the scanner always calls back over `http://localhost:{UiPort}/api/scan/...`), which
is only spoof-resistant once ForwardedHeaders trust is restricted — the two interlock. `search/all-items`
DoS-paging is **deferred** (it feeds the SPA "add all" flow and needs coordinated pagination; the unauth
exposure is already closed by read-gating).
- **SPA (`web/`).** The client sends the stored key (`ctv-api-key`) on **every** method (not just
mutations); a new keyless **API Key** screen (`/app/api-key`) lets the user paste the generated key, and a
shell-level banner points there on any 401. See spa-conventions §5e. **First-run/upgrade UX:** with reads
gated by default, the SPA shows no data until the key (from `/config/api.key`) is entered — an intended
consequence of the strict default.
Phase-2 (contract freeze) — the declarative OpenAPI security scheme, global 401 docs, and `/api/v1`
versioning — remains #286/#287/#288. Phase-3 follow-ups: #265, #269, #172 remainder, `search/all-items`
paging, per-key rate limiting.
## 2026-07-12 (#197 Bundle C — contract-freeze honesty)
`key: security.contract-freeze-honesty` · `status: active` · `since: 2026-07-12` · `supersedes: none` · `superseded-by: none`
**Rule:** The OpenAPI doc's declared security/401 scheme is generated from the same `ApiKeyAuthorizationFilter.EndpointRequiresKey` predicate the runtime enforces (so declared auth can't drift from enforced auth), every `/api/*` action returns a ResponseModel (no raw Application VMs), and Channel REST resources are keyed by immutable `Id`, never mutable `Number`.
**Signals:** OpenAPI contract honesty, ResponseModel wrapping, Id vs Number key · paths: `OpenApiContractHonestyTests`, `ErsatzTV.Core/Api` · issues: #197, #287, #288
**Mechanics:** `OpenApiContractHonestyTests`; `docs/api-conventions.md`
**#287 — OpenAPI contract honesty by construction.** The "v1" document now emits the `ApiKey` security
scheme plus per-operation `security`/`401` derived from the *same*
`ApiKeyAuthorizationFilter.EndpointRequiresKey` predicate the runtime filter enforces, so declared auth
can never drift from enforced auth. Every operation also gets a synthesized stable `operationId` (the
framework only assigned one when `Name=` was set — ~90 were missing), and body/param-binding operations
get the documented `400 ValidationProblemDetails` they actually return. `DayOfWeek` is now a string enum
in the schema (added to `Startup.UseStringEnumSchemas`), removing the SPA's `WithDayNames` wart. Pinned by
in-process document generation in tests (`OpenApiContractHonestyTests`) rather than the committed `v1.json`.
**#288 — Wrap the last raw ViewModels; reverse the §7a "intentional `version` leak."** Minted
`MediaCollectionResponseModel`, `ProgramScheduleResponseModel`, and `ChannelDetailResponseModel` (all
`#nullable enable`) and routed `CollectionController` / `ScheduleController` / `SmartCollectionController` /
`ResolutionController.GetResolutionByName` / the channel detail GET+writes through ResponseModels, so no
`/api/*` action returns an Application VM. This reverses the earlier §7a judgment that a ResponseModel
"purely to hide one field was disproportionate": `Version` is now header-only (ETag) on every aggregate
body — confirmed safe by grepping `web/src` (the SPA reads `version` from the ETag header, never the
response body). `ChannelDetailResponseModel` is the *full editable* field set the channel editor needs
(distinct from the lean list `ChannelResponseModel`; drops only the derived `webEncodedName`). Also flipped
`#nullable enable` onto the remaining 24 lagging `ErsatzTV.Core/Api/` files for schema honesty, and added
`pageNum` paging to `GET /api/search`.
**Channel REST resources are keyed by database `Id`, never by `Number`.** `Channel.Number` is user-mutable
(editable on update, bulk-renumbered via `/api/channels/bulk/renumber`, transiently invalid mid-renumber),
so the immutable int PK is the canonical key for all `/api/channels/*` single-item routes, sub-resources
(including `playout/reset`, re-keyed from `{channelNumber}` to `{id:int}` in Bundle C), and `Location`
headers. `Number` remains the identity on broadcast surfaces only (IPTV/M3U/XMLTV), a separate contract. A
number-based lookup endpoint may be added additively later; `UniqueId` (Guid) stays out of the REST
contract absent a federation requirement.
## 2026-07-12 — Browser SPA session auth: `/api` accepts session OR machine key (#295 PR1, server-only)
`key: security.session-auth-dual-credential` · `status: active` · `since: 2026-07-12` · `supersedes: none` · `superseded-by: none`
**Rule:** `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.
**Signals:** session auth, dual credential, CSRF, OIDC revival, local admin · paths: `ApiAuthorizationFilter`, `ErsatzTV/Startup.cs` · issues: #295, #206, #197
**Mechanics:** `docs/spa-conventions.md` §5e (PR2); `RootWriterForceVersionTests`-adjacent auth tests
Implements the ratified #295 design (Fable [PLAN-MODE] pass, issue comment 9548). Supersedes the #206
"OIDC wiring stays inert until #197" note: the retained OIDC service registration is now **revived**, and a
cookie session becomes a first-class `/api` credential alongside the machine `X-Api-Key`. **PR1 is
server-only and backward compatible** — the SPA keeps sending its stored key; the SPA login flow, the
`ApiKeyScreen`→machine-key repurpose, and `spa-conventions §5e` land in **PR2**.
**One gate, evolved (not `[Authorize]`-per-controller).** `ApiKeyAuthorizationFilter``ApiAuthorizationFilter`,
same fail-closed-by-omission logic (a forgotten `[Authorize]` fails *open* — the #280 failure mode — so the
global filter stays the gate). It now accepts a request when a valid `X-Api-Key` matches **OR** the principal
is an authenticated session; the "does this endpoint need auth?" decision is still the single shared
`EndpointRequiresKey(...)` predicate (also drives OpenAPI, so the spec can't drift). Attributes renamed to
match the widened meaning: `[RequiresApiKey]``[RequiresAuthentication]`, `[SkipApiKeyAuthorization]`
`[SkipApiAuthorization]`. `IApiKeyProvider`, the `X-Api-Key` header, and `Api:WriteKey`/`Api:RequireKeyForReads`
are unchanged — **machine/key behavior is byte-identical** (verified: no OpenAPI drift, existing filter tests
still green).
**CSRF (session only).** The machine key is CSRF-immune (a browser can't set a custom header cross-origin
without a credentialed CORS grant we never issue). A cookie session is not: a session-authenticated **mutation**
must carry the `X-CSRF` header (presence-only — a custom header forces a CORS preflight a cross-site page can't
satisfy) or is rejected **403**. Reinforced by `SameSite=Lax` + CORS without `AllowCredentials` (cross-origin
cookie auth is impossible by design). No antiforgery-token machinery.
**Cookie `ctv-session`.** Always registered (local login works with no IdP); OIDC handler added only when
`OIDC:*` is configured. `HttpOnly`, `SameSite=Lax`, `SecurePolicy=SameAsRequest` (so a plain-HTTP LAN isn't
bricked), 14-day sliding. `/api` XHR gets **401/403, not a redirect** (`OnRedirectToLogin`/`AccessDenied`).
The `UseAuthentication`/`UseAuthorization` middleware — deleted with Blazor in #91b — is **revived in the
`legacy` `MapWhen` branch only** (hosts `/api` + OIDC `/callback` + `/docs`; `/iptv` and `/app` untouched).
**Local store = `ConfigElement` rows, single admin, NO migration** (owner ruling F2):
`AuthLocalAdminUsername`, `AuthLocalAdminPasswordHash` (ASP.NET `PasswordHasher`, PBKDF2, via
`Microsoft.Extensions.Identity.Core`), `AuthSecurityStamp`. A password change rotates the stamp; the cookie
`OnValidatePrincipal` (`CookieSecurityStampValidator`) compares the claim to the stored stamp and rejects a
stale session (revocation). OIDC sessions carry an `etv:auth_method=oidc` claim and skip the stamp check
(governed by the IdP).
**Fail-closed out of the box + recovery.** An unconfigured instance keeps `/api` gated (the key still works);
first-run is a **setup-claim** (`POST /api/auth/setup`, first-claim-wins, only valid while unconfigured —
owner ruling F1). Recovery without the browser: `Auth:LocalAdmin:Password` env seed (`LocalAdminSeedService`,
overwrites + rotates the stamp on startup) or the machine key. Login hardening: per-IP rate limit
(`[EnableRateLimiting("auth")]`, 10 / 5 min) on login/setup/password, dummy-hash verify on unknown/unconfigured
user (no enumeration).
**Authelia = app-owned OIDC session; never trust proxy identity headers** (owner ruling F3): the container is
LAN-reachable bypassing the proxy, so `Remote-User`/`Remote-Email` header trust is spoofable. OIDC→Authelia
gives SSO without a double login. **`ForwardedHeaders` behaviour is kept unchanged from #285** (trust any peer
with a warning; restrict via `KnownProxies`/`:KnownNetworks`). A stricter "ignore `X-Forwarded-*` unless a proxy
is configured" default was implemented and then **reverted** after review (cold fork M1): the forwarded
scheme/host feed `/iptv` M3U/XMLTV/HLS absolute-URL generation (`Request.Scheme` in `GetChannelGuideHandler`/
`IptvController`), so ignoring them would regress stream URLs to `http`/internal-host for a proxied deployment
that hasn't set `KnownProxies`. **Deployment coordination:** operators behind a proxy should set
`ForwardedHeaders:KnownProxies`/`:KnownNetworks` — it gives the login rate limiter an unspoofable client IP and
marks the session cookie `Secure` behind TLS. The residual (a direct LAN peer can spoof `X-Forwarded-For` to
evade the per-IP login limit when unrestricted) is accepted defense-in-depth loss, mitigated by PBKDF2 +
no-enumeration.
**Review hardening (fork + independent Codex pass, folded into PR1).** Codex caught concurrency defects the
fork missed — folded in: (a) **atomic first-claim-wins** — setup writes the three credential rows in one
transaction guarded by the unique `ConfigElement.Key` index (a lost race → `DbUpdateException` → 409), so a
concurrent claim can't produce a mixed-state credential; (b) **consistent login snapshot** — login reads the
hash + stamp in one query and no longer rehashes-on-verify, so a login racing a password change can't capture a
newer stamp than the hash it verified (a concurrent change either fails the old password or leaves the issued
cookie carrying the pre-change stamp → revoked next request); (c) **env-seed waits on
`SystemStartup.WaitForDatabase`** (the migrator is a `BackgroundService`, so registration order alone didn't
guarantee the schema existed) — moved to `Services/RunOnce/`. Also: **logout + password require `X-CSRF`**
(the `[SkipApiAuthorization]` auth surface isn't covered by the filter's CSRF check → forced-logout CSRF), and
input length caps on username/password. **Logout rotates the security stamp** when called from a local session
(E2E-caught: `SignOutAsync` alone only clears the *client* cookie, leaving the stateless encrypted ticket
replayable server-side) — so signing out actually ends the session server-side; for the single admin this
revokes all local sessions ("log out everywhere"). Gated on an authenticated session so an unauthenticated
caller can't force-revoke the admin. **Deferred with a tracked gate:** side-effecting `[RequiresAuthentication]`
GETs (troubleshoot playback/archive) aren't CSRF-covered — **#301**, gating PR2 (latent in PR1: the SPA still
uses the machine key). OIDC-session revocation lever (no local stamp) noted for PR3 operator docs.
A **fix-commit re-review** (Codex, #242 discipline) then confirmed the above resolved and caught a second round:
(a) **HIGH — env-seed vs. setup race**: an attacker could claim admin in the startup window before
`LocalAdminSeedService` runs, and the seed's insert would then be swallowed (attacker's credential persists,
defeating the env recovery path). Fixed structurally: **the setup-claim endpoint is closed whenever
`Auth:LocalAdmin:Password` is configured** — the env seed owns the credential, so there is no claim to race
(this also strengthens the setup-claim TOFU posture: an operator on an untrusted network sets the env password
and browser setup is disabled). (b) **LOW**: a concurrent setup race-loser now returns **409** (not 422), and
`ClaimLocalAdmin`'s `DbUpdateException` catch re-checks existence and **rethrows genuine/transient DB errors**
rather than masking them as "already configured". (c) **MEDIUM — accepted**: two *simultaneous* authenticated
password changes are a non-serializable lost-update (last-write-wins; the loser's cookie may be immediately
revoked). Accepted for a **single-admin** system: it needs two concurrent authenticated sessions both submitting
the correct current password at the same instant, and the outcome is self-healing (re-login). Adding EF
optimistic concurrency to the credential rows is disproportionate here.
**OpenAPI = `ApiKey`-only; `/api/auth/*` excluded** (owner ruling F4): the spec's audience is machine/MCP
clients, and a browser-interactive cookie login isn't something a generated client drives, so the cookie path
is an additional accepted credential the doc needn't express. `AuthController` is `[ApiExplorerSettings(IgnoreApi
= true)]`. Verified: no `v1.json`/`v1.d.ts`/`endpoint-index` drift from this PR.
**Phasing.** PR1 = this (server only, no migration). PR2 = SPA (drop the key header for browser calls + add
`X-CSRF`, `AuthContext` + boot gate, login/setup screens, `ApiKeyScreen`→machine-key management, E2E,
`spa-conventions §5e`). PR3 = key rotation + operator docs (Authelia client + env reference). Rollout: PR1→PR2
same release, then a manual Authelia round-trip checklist before the prod pin bump.
## 2026-07-12 — #295 PR2: SPA session cutover + #301 side-effecting-GET POST-ification
`key: security.session-cutover-postify` · `status: active` · `since: 2026-07-12` · `supersedes: none` · `superseded-by: none`
**Rule:** 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`).
**Signals:** SPA cookie-only cutover, CSRF POST-ification, AuthGate boot flow · paths: `web/src/api/client.ts`, `web/src/AuthGate.tsx` · issues: #295, #301, #197
**Mechanics:** `docs/spa-conventions.md` §5e; `docs/api-conventions.md` §9; `docs/e2e-local.md`
PR1 shipped the server side (previous entry): `/api` accepts a session cookie OR the machine `X-Api-Key`, with
`X-CSRF` required on session-authenticated mutations. **PR2 is the SPA cutover** — the browser now authenticates
with the session only — plus **#301** (a session-cookie CSRF hole in side-effecting GETs).
**Browser is cookie-only; the machine key is external/MCP-only.** `web/src/api/client.ts` no longer attaches
`X-Api-Key`; it relies on the same-origin session cookie and sets `X-Csrf: '1'` on every mutating verb centrally.
The former "paste your key" `ApiKeyScreen` is repurposed to **machine-key management**: it reads the server key
from the new `GET /api/auth/machine-key` (session-gated; masked with Reveal + Copy) so an operator can hand it to
MCP / external REST clients — the browser itself never sends it again. *Why:* one credential per audience (the
ratified #295 model); leaving a browser key path alive would keep a CSRF-immune bypass around and defeat the
point.
**Boot gate, not a route** (`web/src/AuthGate.tsx`, wrapping `<App/>` in `main.tsx`): on load it calls the public
`GET /api/auth/config` then `GET /api/auth/session` and renders Setup (first-run local-admin claim) / Login
(local form + an OIDC "Sign in with SSO" button when `oidcEnabled`) / the app. Login and Setup mint **no URL**
the gate renders them at whatever `/app/*` path was requested, so a deep link survives login for free and **no
`blazor-route-parity.md`/`domain-model.md` route rows are added**. It publishes `AuthContext`
(`{ username, method, signOut, requireLogin }`); the 401 signal (`notifyUnauthorized`) now drives re-login via a
passive shell banner (never yanks a dirty draft — it consults the navigation guard first). Auth flows that expect
a 401 inline (login, change-password) pass `suppressUnauthorizedSignal`.
**#301 — POST-ify, don't gate-the-GET.** A side-effecting GET is a CSRF vector once a `SameSite=Lax` cookie is a
normal credential (it rides a cross-site top-level navigation). The three offenders became mutating verbs so the
existing filter CSRF gate covers them with zero new machinery: `GET /api/troubleshoot/playback.m3u8`
**`POST /api/troubleshoot/playback/start`** returning `200 { url }` (the open `/iptv` manifest the player then
loads — so hls.js/native-HLS needs no header injection, strictly better than X-CSRF-on-GET); the archive and
sample GETs → **POST** (SPA downloads them via a fetch-blob helper, never `window.open`). Removing the HEAD
variants also fixed a latent bug: a HEAD opened the `DeleteOnClose` stream and destroyed the artifact. Standing
rule added to `api-conventions.md §9`: **never add a side-effecting GET/HEAD under `/api`.**
**Machine-key GET discloses the key to any authenticated session** — deliberate: the session principal is the
single admin (local or OIDC), same-origin policy blocks a cross-site page from reading the response body, and it
is how the "copy the key for MCP" UX works without a rotation endpoint (rotation is a later PR). **Accepted
residual (OIDC logout):** `POST /api/auth/logout` ends the *app* cookie but not the IdP session, so an OIDC user
who clicks "Sign out" then "Sign in with SSO" returns without re-entering credentials — a `returnUrl`/RP-initiated
logout is a future nicety. Docs: `spa-conventions.md §5e` (SPA seams), `api-conventions.md §9`, `e2e-local.md`
(browser setup/login flow). Refs #295 #301 #197.
## 2026-07-12 — Enforcing CSP + Permissions-Policy on the host (#319, ZAP baseline)
`key: security.csp-permissions-policy` · `status: active` · `since: 2026-07-12` · `supersedes: none` · `superseded-by: none`
**Rule:** `SecurityHeadersMiddleware` sends an enforcing (not report-only) `Content-Security-Policy` (no `unsafe-inline`/`unsafe-eval`; the one inline theme-bootstrap script allow-listed by hash) and a deny-all `Permissions-Policy` on the SPA/`/api`/`/artwork`/`/iptv`; `/docs` and `/openapi` keep only the baseline headers, excluded from CSP because Scalar needs inline bootstrap.
**Signals:** enforcing CSP, script-src hash allowlist, Permissions-Policy · paths: `ErsatzTV/Middleware/SecurityHeadersMiddleware`, `SecurityHeadersMiddlewareTests` · issues: #319, #314, #197, #279
**Mechanics:** `SecurityHeadersMiddlewareTests.Csp_Script_Hash_Should_Match_The_Spa_Index`
Completes the CSP that the #279 baseline-headers entry deferred ("CSP must be validated against the ChicoryTV
SPA's inline assets"). Surfaced by the #314 out-of-ecosystem ZAP baseline (missing CSP/Permissions-Policy WARNs);
a **#197 exit item**. `SecurityHeadersMiddleware` now also sets `Permissions-Policy` (deny-all for
camera/microphone/geolocation/payment/usb) and an **enforcing** `Content-Security-Policy`.
- **Enforce, not report-only.** Report-only was the issue's acceptable fallback, but the SPA's asset graph is
small and fully knowable, so we ship an enforcing policy (report-only leaves the ZAP WARN and provides no real
protection). The policy: `default-src 'self'`; `script-src 'self' '<sha256 of the inline theme-bootstrap
script>'` (**no** `'unsafe-inline'`/`'unsafe-eval'` — the real XSS win); `style-src 'self' 'unsafe-inline'
https://fonts.googleapis.com`; `img-src 'self' data: blob:`; `font-src 'self' data: https://fonts.gstatic.com`;
`connect-src 'self'`; `object-src 'none'`; `base-uri 'self'`; `frame-ancestors 'none'`; `form-action 'self'`.
- **Why each relaxation.** The SPA is a static file, so a per-response nonce is impossible → the one inline
theme-bootstrap `<script>` is allow-listed **by hash**; `SecurityHeadersMiddlewareTests.Csp_Script_Hash_Should_
Match_The_Spa_Index` hashes the built `wwwroot/app/index.html` when present (else the committed `web/index.html`
source, since the built artifact is gitignored/absent in CI — Vite copies the inline script verbatim) and fails
if it drifts from the middleware constant. `style-src 'unsafe-inline'` covers
React's inline `style=""` attributes (no CSS-in-JS lib to hash). The **Google Fonts** hosts are required — the
SPA CSS `@import`s the Geist web font (caught by **live-E2E**, which the static grep missed); self-hosting the
font to drop the Google dependency is a follow-on hardening, not this issue. `img-src data: blob:` covers
favicon/generated-image data URIs and object-URL upload previews.
- **Scoped: `/docs` (Scalar) and `/openapi` are excluded.** The Scalar API-reference UI relies on inline bootstrap
scripts/styles a strict CSP would break; it keeps the baseline headers (nosniff/frame/referrer) but no CSP.
Hardening that admin surface (self-hosted Scalar or a Scalar-tuned CSP) is a #197 follow-up. Everything else —
SPA, `/api`, `/artwork`, `/iptv` — gets the CSP (non-HTML responses simply never exercise the script/style
directives). Verified by live-E2E (SPA renders clean, zero CSP violations) + curl (CSP present on `/app`/`/api`,
absent on `/docs`/`/openapi`). HSTS remains out (proxy/TLS decision). Refs #319 #314 #197.
## 2026-07-13 — Cross-origin resource policy: `same-origin` on every response (#330)
`key: security.corp-same-origin` · `status: active` · `since: 2026-07-13` · `supersedes: none` · `superseded-by: none`
**Rule:** `SecurityHeadersMiddleware` sends `Cross-Origin-Resource-Policy: same-origin` on every response including `/docs`/`/openapi`, blocking cross-origin `no-cors` embedding without affecting allowed CORS-mode fetches or server-side Jellyfin `/iptv/*` requests.
**Signals:** CORP, same-origin, cross-origin embedding · paths: `ErsatzTV/Middleware/SecurityHeadersMiddleware` · issues: #330, #319, #314
**Mechanics:** `ErsatzTV/Middleware/SecurityHeadersMiddleware`
The authenticated #314 ZAP scan found that ErsatzTV's baseline response posture omitted
`Cross-Origin-Resource-Policy`. `SecurityHeadersMiddleware` now sends
`Cross-Origin-Resource-Policy: same-origin` on every response, including `/docs` and `/openapi`.
Those two paths remain exempt only from the strict CSP that would break Scalar's inline bootstrap;
CORP has no equivalent rendering conflict and belongs with the middleware's path-independent baseline
headers.
`same-origin` requires the browser request and response to share the exact scheme, host, and port. It
blocks cross-origin `no-cors` loads, so direct browser embedding of ErsatzTV artwork or media from an
alternate origin is deliberately unsupported. It does not reject an allowed CORS-mode API fetch, so the
explicit `Api:CorsAllowedOrigins` machine-client path continues to work. It is also not enforced by
server-side HTTP clients, so Jellyfin's `/iptv/*` requests are unaffected; same-origin SPA artwork and
IPTV requests remain allowed. This is defense in depth for browser embedding and does not replace CORS
or authentication. Refs #330 #319 #314.
## 2026-07-22 — Short-lived browser IPTV token so the SPA reaches `/iptv/*` under JWT auth (#552)
`key: security.iptv-browser-token` · `status: active` · `since: 2026-07-22` · `supersedes: none` · `superseded-by: none`
**Rule:** Under a JWT-enabled deployment (`JWT:IssuerSigningKey` set), the browser SPA obtains a short-lived, globally-scoped `/iptv/*` access token from an authenticated `GET /api/v1/auth/iptv-token` and appends it as `?access_token=`; the endpoint answers 204 when JWT is disabled (nothing to mint). Lifetime defaults to 60 min, configurable via `JWT:BrowserTokenLifetimeMinutes`.
**Signals:** channel preview / playback-troubleshooting 401ing under JWT; `/iptv/*` not accepting `ctv-session`; minting a JWT for the browser · paths: `ErsatzTV/JwtHelper.cs`, `ErsatzTV/Controllers/Api/AuthController.cs`, `web/src/media/iptvToken.ts` · issues: #552, #60
**Mechanics:** `JwtHelper.GenerateBrowserToken()`; `AuthController.IptvToken`; `withIptvToken(url)` (SPA)
`/iptv/*` is gated by `ConditionalIptvAuthorizeFilter` only when `JWT:IssuerSigningKey` is configured,
and the `"jwt"` scheme accepts only a bearer token or `?access_token=`**not** the SPA's `ctv-session`
cookie (a distinct scheme). Nothing minted a JWT for the browser, so under JWT the #60 channel preview
was declared `Unavailable` and the pre-existing playback-troubleshooting screen was latently broken. This
closes both with one seam.
- **Endpoint.** `GET /api/v1/auth/iptv-token` on `AuthController` (already `[SkipApiAuthorization]` +
self-checks the principal, like `machine-key`). Requires any authenticated session (401 otherwise);
returns `{ token, expiresAt }` when `JwtHelper.IsEnabled`, else **204 No Content**`/iptv/*` is open
then, so there is nothing to append and the SPA plays the plain URL. `Cache-Control: no-store`. Excluded
from the OpenAPI document (`AuthController` is `[IgnoreApi]`, per the #295 F4 ruling — a
browser-interactive credential is not something a generated client drives).
- **A GET is correct here** despite the "no side-effecting GET under `/api`" rule (`api-conventions.md §9`):
minting a JWT writes **no server state** (stateless token, no DB row, no revocation list), so it is not a
CSRF-relevant side effect, and same-origin policy already blocks a cross-site page from reading the
credentialed response body — identical reasoning to the `machine-key` GET.
- **Scope: global.** The `"jwt"` scheme validates only signature + lifetime (no audience/channel claims),
and the token is minted only to the already-authenticated admin who can reach every channel.
Channel-scoping would mean adding claim-based auth to `ConditionalIptvAuthorizeFilter` and the streaming
path — deferred until a non-admin preview audience exists.
- **Lifetime: 60 min default, `JWT:BrowserTokenLifetimeMinutes` override, clamped to 24h.** The token
re-validates on every `/iptv/*` request, so lifetime is the max continuous watch before playback stalls;
60 min comfortably covers an operator verification session, an expired idle session just needs Retry
(mints fresh), and a security-conscious operator can tighten it. A non-positive/unparseable value falls
back to the default rather than minting an already-expired token; a value above 24h (a seconds-vs-minutes
typo would otherwise mint a multi-year bearer token) is clamped down. **Revocation is by short lifetime
only** — a stateless JWT has no per-token revocation; rotating `JWT:IssuerSigningKey` invalidates all
tokens (the existing lever). The SPA's `resetIptvTokenCache()` (called on the preview panel's Retry and on
each troubleshooting Play) makes a user-initiated retry re-mint, so a stale token or a stale "JWT disabled"
latch from a since-reconfigured backend can't wedge a recovery attempt.
- **Deferred hardening (broader than #552) — RESOLVED by `security.iptv-access-token-transport` (#421, #559).**
The `?access_token=` transport itself had pre-existing weaknesses this feature inherited, then bounded by the
short lifetime: Serilog's request log included the full query (so a token could reach logs on an `/iptv` 5xx),
and the token-bearing dynamic manifests carried no `Cache-Control: no-store`. Both predate this feature
(Jellyfin and the M3U playlist already pass `access_token` in `/iptv` URLs); their fixes were cross-cutting
changes to shared request-logging / manifest behavior, tracked as a follow-up and now landed in the record
below (which also adds the #421 M3U quote-safety encoding).
- **Only the top-level manifest needs the token.** The multi-variant playlist embeds `access_token` into
its variant URL (`IptvController.GetMultiVariantPlaylist`), and HLS segments are served by
`UseStaticFiles` at `RequestPath=/iptv/session`**outside** `ConditionalIptvAuthorizeFilter` (which is
a `[ServiceFilter]` on `IptvController` only) — so segment GETs are ungated regardless. The SPA's
`withIptvToken(url)` appends the token to the one manifest URL (a no-op when JWT is off; caches the token
in memory until shortly before expiry) and is used by both the channel-preview panel and the
playback-troubleshooting screen.
## 2026-07-23 — access_token transport hardening: percent-encode in M3U/HLS, redact from logs, no-store on tokened manifests (#421, #559)
`key: security.iptv-access-token-transport` · `status: active` · `since: 2026-07-23` · `supersedes: none` · `superseded-by: none`
**Rule:** The `/iptv` `?access_token=` value is percent-encoded (`Uri.EscapeDataString`) everywhere it is interpolated into an M3U/HLS/XMLTV URL (XMLTV additionally XML-escapes the encoded value), so a structural character can't malform the manifest or guide; Serilog logs a scrubbed request path (`access_token``***` via `IncludeQueryInRequestPath = false` + a `RequestPathScrubbed` enricher), so a 5xx/Debug `/iptv` request never writes the token; and every dynamic token-bearing `/iptv` manifest (`channels.m3u`, `xmltv.xml`, the HLS multi-variant/media playlists) returns `Cache-Control: private, no-store`.
**Signals:** access_token quote-safety, url-tvg breakout, M3U attribute escaping, XMLTV icon src encoding, request-log token redaction, no-store manifest, token replay from logs/cache · paths: `ErsatzTV.Core/Iptv/ChannelPlaylist.cs`, `ErsatzTV/Controllers/IptvController.cs`, `ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs`, `ErsatzTV/Middleware/RequestLogScrubber.cs`, `ErsatzTV/Startup.cs`, `ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs` · issues: #421, #559, #552, #376
**Mechanics:** `ChannelPlaylistAccessTokenTests`; `RequestLogScrubberTests`; `IptvControllerCacheHeaderTests`
Resolves the "deferred hardening" bullet under `security.iptv-browser-token` (#552). The shared `?access_token=`
transport had three pre-existing weaknesses that #552's short-lived browser token only *bounded* (not fixed);
they predate #552 because Jellyfin and the M3U playlist already pass `access_token` in `/iptv` URLs.
- **Percent-encode the token in M3U/HLS URLs (#421).** `ChannelPlaylist.ToM3U` interpolates the token into
quoted M3U attributes (`url-tvg="..."`, `tvg-logo="..."`) and bare stream URLs; `IptvController.AccessTokenQuery`
and the internal segmenter URL (`FFmpegLibraryProcessService.WrapSegmenter`) do the same. A token containing a
`"` could terminate a quoted attribute early, and an `&` could split the query — for strict parsers. The value
is a URL query parameter, so the correct fix is percent-encoding (`Uri.EscapeDataString`) at each interpolation
site; ASP.NET decodes `Request.Query`, so the round-trip is lossless and validation is unchanged. For a normal
base64url JWT this is a **no-op** (every character is RFC 3986 unreserved), so the M3U goldens are byte-identical.
This is the M3U analogue of #376's XMLTV `SecurityElement.Escape` fix — #376 correctly did **not** apply to M3U
(M3U is not XML), which is why #421 was split out. The **XMLTV twin** (`GetChannelGuideHandler`) is fixed the same
way for symmetry: the token there lands in a URL query value *inside* an XML attribute, so it now percent-encodes
**then** XML-escapes (`SecurityElement.Escape(Uri.EscapeDataString(token))`) — #376's XML-escape alone guarded XML
well-formedness but not URL correctness, so a token with `&` would still truncate the query after the consumer
XML-unescapes the attribute.
- **Redact the token from request logs (#559).** `UseSerilogRequestLogging` had `IncludeQueryInRequestPath = true`,
and the `ex != null` / `StatusCode > 499` branches log at **Error** regardless of path — so a 5xx on a tokened
`/iptv` URL wrote the replayable, globally-scoped token to the log under default settings. Fix:
`IncludeQueryInRequestPath = false` (built-in `RequestPath` is now path-only) plus a `RequestPathScrubbed`
diagnostic-context property — built by `RequestLogScrubber.ScrubbedPath`, which rebuilds the query with the
`access_token` value masked to `***` and everything else (e.g. `mode=segmenter`) preserved for debugging — that
the message template references in place of `{RequestPath}`. A distinct property name is required because Serilog
appends its own `RequestPath` *after* enriched properties, so reusing the name would let the built-in value win.
**Load-bearing residual:** this scrubs only `UseSerilogRequestLogging`. ASP.NET's own
`Microsoft.AspNetCore.Hosting.Diagnostics` "Request starting" line logs the raw query (token and all) at
Information; it is suppressed today solely by the `"Microsoft": "Warning"` override in `appsettings.json`, which
is **not** reachable from the runtime logging-settings UI (the `HttpLevelSwitch` overrides only
`Serilog.AspNetCore.RequestLoggingMiddleware`). Keep that override — lowering `Microsoft` to Information/Debug for
routing/auth debugging re-leaks the token. A category-agnostic scrub filter is the follow-up if that override is
ever relaxed.
- **`no-store` on token-bearing dynamic manifests (#559).** `channels.m3u`, `xmltv.xml`, and the HLS
multi-variant/media playlists embed the caller's token in their body/URLs but set no cache headers, so a browser
or intermediary could cache the token-bearing manifest. Each now returns `Cache-Control: private, no-store`. This
covers the Jellyfin-facing M3U/XMLTV as well as the #552 HLS preview manifests — all three share the same
cache-replay risk — and does not affect Jellyfin, which refetches the playlist/guide on its own schedule
regardless of HTTP cache headers. The `.ts` live streams and the ungated HLS segments (served by `UseStaticFiles`
at `/iptv/session`, outside `ConditionalIptvAuthorizeFilter`) carry no token and are unchanged.
+45 -5
View File
@@ -7,10 +7,50 @@ in the active read-path). Active successor for channel health: `api.channel-heal
---
## Records formerly in this file
## 2026-07-17 — Channel health on the API = the raw `PlayoutCount` fact on the list DTO, not a derived status enum (#72)
`key: api.channel-health-signal` · `status: superseded` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: api.channel-health-object@2026-07-23`
**Rule:** (superseded) 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).
**Signals:** channel health, PlayoutCount, config-derived vs runtime-liveness cadence, frozen /api/v1 · paths: `ChannelRepository.GetChannel`, `Mapper.GetPlayoutsCount`, `api-conventions.md` §3a · issues: #72, #383, #384, #401
**Mechanics:** superseded by `api.channel-health-object` (ersatztv#415); see `docs/decisions.md` → that record for the `health` object that replaces `PlayoutCount`-as-verdict
Each record below moved to its own file under `records/` (ersatztv#610); the rationale is
unchanged. Resolve by **key** — that is the stable identity. A date-based pointer from
another doc or an old issue comment should land here and then follow the link.
#72 asks for per-channel status in the Channels list, "especially channels that will fail to play".
- 2026-07-17 — Channel health on the API = the raw `PlayoutCount` fact on the list DTO, not a derived status enum (#72) — [`api.channel-health-signal`](api/channel-health-signal.md)
**Where it lives: `ChannelResponseModel` (the lean list DTO), not a new endpoint and not
`/channels/state`.** Channel health is *config-derived* — it changes when someone edits a playout, not
tick to tick — whereas `/channels/state` is the fast-poll runtime-liveness feed (`OnAir` = someone is
streaming *right now*). Folding health into the polled feed would recompute rarely-changing data every
tick and mix two cadences in one DTO; a third endpoint is over-engineering for one derived integer on a
list whose consumer already reads it. Precedent: `api-conventions.md` §3a stamps the server-derived
`IsLocked` onto `PlayoutListItemResponseModel` for exactly this reason. **It is also free**: `GetAll`
already `Include`s `Playouts` and `MirrorSourceChannel.Playouts` and was discarding them, so no extra
query and no N+1 on a large lineup.
**A raw fact (`int PlayoutCount`), not a `ChannelHealth` enum.** v1 has exactly one trustworthy negative
signal, and the auto-tune arc (#383/#384) is about to churn the status taxonomy (origin, auto-tune
outcomes) — freezing a server-side enum now guarantees a breaking rev of a frozen-additive `/api/v1`
surface. `PlayoutCount` mirrors the long-standing `ChannelViewModel.PlayoutCount` 1:1, is a fact rather
than a policy, and leaves the SPA to derive `0 ⇒ "No playout"` in one predicate.
**What v1 deliberately does NOT compute** — each was considered and ruled out, so don't "finish" them
without reading this:
- **Empty schedule behind an existing playout.** `EmptyScheduleHealthCheck` only understands **Classic**
`ProgramSchedule` playouts. Block, Sequential, Scripted and ExternalJson channels have no
`ProgramSchedule` at all, so a badge driven off that query would be silently absent or wrong for four of
the five schedule kinds — the #71 "verify a shared primitive covers ALL variants" trap. Needs a
per-kind emptiness notion first.
- **Broken / missing source.** `FileNotFound`/`Unavailable` are server-wide media-item counts with no
channel attribution; mapping media → collection → schedule → channel is a project, not a field.
- **User-defined vs auto-generated origin** (#72 scope item a). No honest signal exists:
`Channel` has no origin column, and `ChannelPlayoutSource.Generated` is a *playout-strategy* value that
SPA-created blank channels also carry, so it would mislabel them. Requires a new column + a dual-provider
migration, and provenance belongs to the auto-tune arc that stamps it at creation. A join through the
`"Channel Lineups"` system playlist group was **rejected**: it is a heuristic that breaks the moment a
user edits the channel. Deferred to a follow-up blocked on the auto-tune backend.
Keeping all three out held #72 to a **read-path-only** change: no migration, no write-handler live-E2E.
**Corrected in passing:** `ChannelRepository.GetChannel` never included `Playouts`, so
`GET /api/v1/channels/{id}` reported `playoutCount: 0` for every channel, which silently disabled the
channel editor's playout-source guard. Both call sites now share `Mapper.GetPlayoutsCount` (Mirror-aware).
The related *silent* server-side coercion of Mirror→Generated (a 200 that discards the caller's intent,
against the §3 "surface it, don't silently filter" rule) is filed as **#401**, not fixed here.
@@ -1,53 +0,0 @@
---
key: api.channel-health-signal
title: 2026-07-17 — Channel health on the API = the raw `PlayoutCount` fact on the list DTO, not a derived status enum (#72)
status: superseded
since: '2026-07-17'
supersedes: none
superseded-by: api.channel-health-object@2026-07-23
rule: '(superseded) 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).'
signals: 'channel health, PlayoutCount, config-derived vs runtime-liveness cadence, frozen /api/v1 · paths: `ChannelRepository.GetChannel`, `Mapper.GetPlayoutsCount`, `api-conventions.md` §3a · issues: #72, #383, #384, #401'
mechanics: superseded by `api.channel-health-object` (ersatztv#415); see `docs/decisions.md` → that record for the `health` object that replaces `PlayoutCount`-as-verdict
---
#72 asks for per-channel status in the Channels list, "especially channels that will fail to play".
**Where it lives: `ChannelResponseModel` (the lean list DTO), not a new endpoint and not
`/channels/state`.** Channel health is *config-derived* — it changes when someone edits a playout, not
tick to tick — whereas `/channels/state` is the fast-poll runtime-liveness feed (`OnAir` = someone is
streaming *right now*). Folding health into the polled feed would recompute rarely-changing data every
tick and mix two cadences in one DTO; a third endpoint is over-engineering for one derived integer on a
list whose consumer already reads it. Precedent: `api-conventions.md` §3a stamps the server-derived
`IsLocked` onto `PlayoutListItemResponseModel` for exactly this reason. **It is also free**: `GetAll`
already `Include`s `Playouts` and `MirrorSourceChannel.Playouts` and was discarding them, so no extra
query and no N+1 on a large lineup.
**A raw fact (`int PlayoutCount`), not a `ChannelHealth` enum.** v1 has exactly one trustworthy negative
signal, and the auto-tune arc (#383/#384) is about to churn the status taxonomy (origin, auto-tune
outcomes) — freezing a server-side enum now guarantees a breaking rev of a frozen-additive `/api/v1`
surface. `PlayoutCount` mirrors the long-standing `ChannelViewModel.PlayoutCount` 1:1, is a fact rather
than a policy, and leaves the SPA to derive `0 ⇒ "No playout"` in one predicate.
**What v1 deliberately does NOT compute** — each was considered and ruled out, so don't "finish" them
without reading this:
- **Empty schedule behind an existing playout.** `EmptyScheduleHealthCheck` only understands **Classic**
`ProgramSchedule` playouts. Block, Sequential, Scripted and ExternalJson channels have no
`ProgramSchedule` at all, so a badge driven off that query would be silently absent or wrong for four of
the five schedule kinds — the #71 "verify a shared primitive covers ALL variants" trap. Needs a
per-kind emptiness notion first.
- **Broken / missing source.** `FileNotFound`/`Unavailable` are server-wide media-item counts with no
channel attribution; mapping media → collection → schedule → channel is a project, not a field.
- **User-defined vs auto-generated origin** (#72 scope item a). No honest signal exists:
`Channel` has no origin column, and `ChannelPlayoutSource.Generated` is a *playout-strategy* value that
SPA-created blank channels also carry, so it would mislabel them. Requires a new column + a dual-provider
migration, and provenance belongs to the auto-tune arc that stamps it at creation. A join through the
`"Channel Lineups"` system playlist group was **rejected**: it is a heuristic that breaks the moment a
user edits the channel. Deferred to a follow-up blocked on the auto-tune backend.
Keeping all three out held #72 to a **read-path-only** change: no migration, no write-handler live-E2E.
**Corrected in passing:** `ChannelRepository.GetChannel` never included `Playouts`, so
`GET /api/v1/channels/{id}` reported `playoutCount: 0` for every channel, which silently disabled the
channel editor's playout-source guard. Both call sites now share `Mapper.GetPlayoutsCount` (Mirror-aware).
The related *silent* server-side coercion of Mirror→Generated (a 200 that discards the caller's intent,
against the §3 "surface it, don't silently filter" rule) is filed as **#401**, not fixed here.
@@ -1,33 +0,0 @@
---
key: docs.append-only-guard
title: 2026-07-12 — decisions.md is append-only, enforced; root-screenshot guard (#303 H9/H3)
status: superseded
since: '2026-07-12'
supersedes: none
superseded-by: docs.decision-lifecycle@2026-07-21
rule: (superseded) `docs/decisions.md` is append-only, enforced by hook + CI.
signals: 'decisions-guard hook · paths: `.claude/hooks/decisions-guard.sh`, `.husky/commit-msg` · issues: #303 (H9)'
mechanics: superseded by `scripts/decisions_validate.py` (ersatztv#521); see `docs/decisions.md``docs.decision-lifecycle`
---
**This log is append-only by construction, not just by convention.** A commit or PR that deletes or
modifies an existing line of `docs/decisions.md` is blocked — by the Husky `commit-msg` hook
(`.claude/hooks/decisions-guard.sh staged`) locally and the blocking `decisions-guard` CI job (same
script, `range` mode) on PRs. Shared detection, deliberately different granularity: the Husky hook
gates **each commit** (its own message must carry the token); CI gates the **PR-wide** net diff
(token in any commit of the range suffices), so the local hook is the stricter primary gate and CI the
push/bypass backstop. Insertions anywhere are always allowed, so a normal new entry (TOC line
near the top + a block appended at the bottom, both pure insertions) passes untouched. Detection is
`git diff --numstat` deleted-count > 0, which is robust to markdown `-` list markers (a byte-level `-`
prefix would false-match). The block is lifted only by the literal **`[decisions-edit]`** token in the
commit message, reserved for two cases: fixing a factual error, and superseding a reversed decision
(add the new entry, prepend a `> **Superseded …**` banner to the old one, tag its Index line
`(superseded)` — keep the old rationale, never silently rewrite). **Consolidation** of superseded
entries is a release-checklist step (`docs/ci-cd.md` → Versioning & releases), backstopped by a
non-blocking 1800-line **size floor** in the `decisions-guard` job (the read-cost point past which the
log no longer fits one default agent Read), so append-only doesn't accrete contradictory *or
unreadably-large* history between releases (Timothy's call, 2026-07-12: mark-and-keep on reversal,
consolidate at each milestone, size-floor backstop).
The companion H3 root-screenshot guard was split out during the #521 migration to the active record
`ci.root-screenshot-guard` in `docs/decisions/release-ci-governance.md`.
@@ -1,37 +0,0 @@
---
key: docs.queue-state-gitea-tracker
title: 2026-07-11 — Queue state lives in the pinned Gitea tracker (#237), not in the handoff file
status: superseded
since: '2026-07-11'
supersedes: none
superseded-by: startup.parallel-orientation@2026-07-21
rule: '(superseded) Volatile session/queue state lives in pinned Gitea tracker issue #237 (goal + arc in the body, append-only session-comment log, in-progress/review labels), not in a wholesale-rewritten handoff file; the handoff file keeps only the static kickoff prompt and append-only lore.'
signals: 'last-writer-wins race, claim/`in-progress` label, triage verdict · paths: `docs/handoffs/chicorytv-issue-queue.md` · issues: #237, #520'
mechanics: superseded by `scripts/select-queue.sh` (ersatztv#520); see `docs/decisions.md``startup.parallel-orientation`
---
With multiple sessions/agents working the repo in parallel, the old protocol — every session
wholesale-rewrites `docs/handoffs/chicorytv-issue-queue.md` on main (session state + queue +
next-session prompt) — became a last-writer-wins race. New protocol: **volatile queue state
moved to Gitea**, which is concurrency-safe by construction. Pinned tracker issue **#237**
holds the goal + ordered arc in its body (edited rarely, only on arc changes, re-read before
edit) and an append-only session-comment log (fixed template: Closed / Filed / Triage /
Arc change / Recommended next). Milestone `Blazor removal (#91 phase b)` + the `review` and
`in-progress` labels are the machine-queryable view. Sessions **claim** an issue before working
it (`in-progress` label + claim comment; the tiny read→claim race window is accepted, later
claimant backs off; stale claims — no commits/comments ~48h — may be taken over with a comment).
Every new issue gets an explicit end-of-session triage verdict — gate-blocker (milestone + arc
slot) or backlog (label only) — so review findings adjust the queue only through that step and
the arc doesn't drift. The handoff file keeps only the **static kickoff prompt** and the
**append-only Lessons lore** (per-session prompts are gone; task context lives in issue bodies).
**Why superseded (#520, 2026-07-21):** the arc completed and #237 closed (2026-07-13); a closed
tracker cannot serve as live queue state, and continuing to read it as such was a live regression
risk (an agent skimming an old comment or this very record could re-treat #237's prose as current).
`scripts/select-queue.sh` (2026-07-19) already replaced the mechanical parts of this rule with a
deterministic, live-Gitea-only query — this record's job was really "queue state lives in Gitea,
not in the handoff file," and that half is still true; what's superseded is the *specific store*
(#237's body/comments) now that maintenance/backlog mode has no arc to narrate. See
`startup.parallel-orientation` for the replacement: two concurrent session-start tracks
(orientation via the docs/decisions catalog + `docs/README.md` map, and selection via the script),
with #237 reduced to a single archival breadcrumb.
@@ -14,10 +14,30 @@ directory is and `docs/decisions/migration-map.md` for the full mapping.
---
## Records formerly in this file
## 2026-07-12 — decisions.md is append-only, enforced; root-screenshot guard (#303 H9/H3)
`key: docs.append-only-guard` · `status: superseded` · `since: 2026-07-12` · `supersedes: none` · `superseded-by: docs.decision-lifecycle@2026-07-21`
**Rule:** (superseded) `docs/decisions.md` is append-only, enforced by hook + CI.
**Signals:** decisions-guard hook · paths: `.claude/hooks/decisions-guard.sh`, `.husky/commit-msg` · issues: #303 (H9)
**Mechanics:** superseded by `scripts/decisions_validate.py` (ersatztv#521); see `docs/decisions.md``docs.decision-lifecycle`
Each record below moved to its own file under `records/` (ersatztv#610); the rationale is
unchanged. Resolve by **key** — that is the stable identity. A date-based pointer from
another doc or an old issue comment should land here and then follow the link.
**This log is append-only by construction, not just by convention.** A commit or PR that deletes or
modifies an existing line of `docs/decisions.md` is blocked — by the Husky `commit-msg` hook
(`.claude/hooks/decisions-guard.sh staged`) locally and the blocking `decisions-guard` CI job (same
script, `range` mode) on PRs. Shared detection, deliberately different granularity: the Husky hook
gates **each commit** (its own message must carry the token); CI gates the **PR-wide** net diff
(token in any commit of the range suffices), so the local hook is the stricter primary gate and CI the
push/bypass backstop. Insertions anywhere are always allowed, so a normal new entry (TOC line
near the top + a block appended at the bottom, both pure insertions) passes untouched. Detection is
`git diff --numstat` deleted-count > 0, which is robust to markdown `-` list markers (a byte-level `-`
prefix would false-match). The block is lifted only by the literal **`[decisions-edit]`** token in the
commit message, reserved for two cases: fixing a factual error, and superseding a reversed decision
(add the new entry, prepend a `> **Superseded …**` banner to the old one, tag its Index line
`(superseded)` — keep the old rationale, never silently rewrite). **Consolidation** of superseded
entries is a release-checklist step (`docs/ci-cd.md` → Versioning & releases), backstopped by a
non-blocking 1800-line **size floor** in the `decisions-guard` job (the read-cost point past which the
log no longer fits one default agent Read), so append-only doesn't accrete contradictory *or
unreadably-large* history between releases (Timothy's call, 2026-07-12: mark-and-keep on reversal,
consolidate at each milestone, size-floor backstop).
- 2026-07-12 — decisions.md is append-only, enforced; root-screenshot guard (#303 H9/H3) — [`docs.append-only-guard`](docs/append-only-guard.md)
The companion H3 root-screenshot guard was split out during the #521 migration to the active record
`ci.root-screenshot-guard` in `docs/decisions/release-ci-governance.md`.
+38 -5
View File
@@ -7,10 +7,43 @@ verbatim, never in the active read-path). Active successor for music-video recon
---
## Records formerly in this file
## 2026-07-20 — `JellyfinMusicVideoLibraryScanner` reconciles by library-scoped path diff + hard delete, not server itemId soft-trash (#494)
`key: scan.musicvideo-reconciliation` · `status: superseded` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: scan.musicvideo-server-identity@2026-07-25`
**Rule:** (superseded) `JellyfinMusicVideoLibraryScanner` reconciles removed music videos by a library-scoped local-path diff plus hard delete (`TrashMissingMusicVideos`), not the server-itemId soft-trash pattern the other media-server scanners use, because music videos carry no server identity.
**Signals:** music-video trash sweep, path-based identity, cross-kind safety, path-keyed identity, empty-fetch guard reuse, remove-stale+add-new dedup · paths: `JellyfinMusicVideoLibraryScanner.TrashMissingMusicVideos`, `FindMusicVideoPaths`/`DeleteByPath`, `IMusicVideoRepository`, `MediaServerReconciliationGuard` · issues: #494, #477, #488, #496, #500
**Mechanics:** `ScanLibrary_Should_Not_CrossDelete_Movie_Or_Show_Sharing_The_LibraryPath`; `ScanLibrary_Should_Not_Sweep_When_Jellyfin_Returns_Zero_Items`; integration tests extending the #488 harness. #500 — when mirroring the remove-stale + add-new idiom, dedup the incoming set on **the same key its add filter compares** (the filter is materialized before the loop mutates `existing`, so duplicates both pass): `Name`, `Guid` for guids, and for Plex `Actors` an artwork-preferring dedup shared with the remove filter (whose key is `(Name, artwork-presence)`). Remaining un-deduped copies of the idiom: #600. Superseded by `scan.musicvideo-server-identity` (ersatztv#496): music videos gained a `JellyfinMusicVideo` ItemId/Etag identity, so the path diff + hard delete became an itemId diff + soft `FileNotFound` trash.
Each record below moved to its own file under `records/` (ersatztv#610); the rationale is
unchanged. Resolve by **key** — that is the stable identity. A date-based pointer from
another doc or an old issue comment should land here and then follow the link.
The Jellyfin music-video scanner did add/update only — a music video removed on the Jellyfin side lingered in
ErsatzTV forever and could still be scheduled. It now runs a trash sweep at the end of `ScanLibrary`
(`TrashMissingMusicVideos`), mirroring the `MediaServer{Movie,Television,OtherVideo}LibraryScanner` "gone
upstream ⇒ remove" pattern but with a deliberately different identity function, because music videos lack the
media-server identity those base scanners rely on.
- 2026-07-20 — `JellyfinMusicVideoLibraryScanner` reconciles by library-scoped path diff + hard delete, not server itemId soft-trash (#494) — [`scan.musicvideo-reconciliation`](scan/musicvideo-reconciliation.md)
- **Identity is (LibraryPathId, path), not server itemId.** The base scanners diff `GetExisting*` (keyed by
`MediaServerItemId`) against the incoming server item ids, then soft-trash via `FlagFileNotFound`. Music videos
have **no `JellyfinMusicVideo` entity and no `ItemId`/`Etag`** — the scanner is a standalone
`IJellyfinMusicVideoLibraryScanner` that injects the *local* `IMusicVideoRepository`, which offers no
itemId-keyed existing-set or flag seam. So the sweep diffs the **local path** set instead: existing =
`FindMusicVideoPaths(libraryPath)` `.Except` the incoming items' replaced local paths, then hard-deletes the
remainder with `DeleteByPath` + `IScannerProxy.RemoveMediaItems`, and cleans now-empty artists with
`IArtistRepository.DeleteEmptyArtists`. Hard delete (not soft `FileNotFound` trash) because there is no
per-item FileNotFound seam on this path and the issue's Done-when is "removed".
- **Cross-kind safety is a property of the queries, not the media kind.** `MediaItem` is TPT with `LibraryPathId`
on the abstract base, so a Movie, Show and MusicVideo can share one `LibraryPath` (a mixed Jellyfin library).
Both `FindMusicVideoPaths` and `DeleteByPath` filter `LibraryPathId` **and** join the concrete `MusicVideo`
table, so the sweep can only ever see/delete music videos — a Movie/Show under the same `LibraryPath` is
invisible to it. Pinned by `ScanLibrary_Should_Not_CrossDelete_Movie_Or_Show_Sharing_The_LibraryPath`.
- **Reuses the #477 empty-fetch guard.** The sweep is gated by `MediaServerReconciliationGuard.ShouldFlagMissing`
— a successful fetch that returns zero items (server mid-restore / transient) is indistinguishable from a real
emptying, so the whole-library wipe is refused and logged. Pinned as a negative control by
`ScanLibrary_Should_Not_Sweep_When_Jellyfin_Returns_Zero_Items` (removing the guard flips it red).
- **Known limitation (deferred to per-library identity).** `MusicVideoRepository.GetOrAdd` dedups a path
**globally** (no `LibraryPathId` predicate), so a file served by two libraries with overlapping local paths is
a single row owned by whichever library scanned it first. If that owner later stops reporting the file while
another library still serves it, this sweep removes the shared row. A proper fix needs per-library music-video
identity (a `JellyfinMusicVideo` etag entity + migration) — the issue's "option 2 / fold into the base
scanner" refactor — tracked as #496.
- **Tests.** Integration tests (real `ArtistRepository`/`MusicVideoRepository`/`LibraryRepository` over in-memory
SQLite, extending the #488 harness) pin removal, empty-artist cleanup, cross-kind safety, and the empty-fetch
guard. Proven non-vacuous: all four fail against the pre-fix scanner except the guard control, which only
earns its keep once the sweep exists.
@@ -1,46 +0,0 @@
---
key: scan.musicvideo-reconciliation
title: 2026-07-20 — `JellyfinMusicVideoLibraryScanner` reconciles by library-scoped path diff + hard delete, not server itemId soft-trash (#494)
status: superseded
since: '2026-07-20'
supersedes: none
superseded-by: scan.musicvideo-server-identity@2026-07-25
rule: (superseded) `JellyfinMusicVideoLibraryScanner` reconciles removed music videos by a library-scoped local-path diff plus hard delete (`TrashMissingMusicVideos`), not the server-itemId soft-trash pattern the other media-server scanners use, because music videos carry no server identity.
signals: 'music-video trash sweep, path-based identity, cross-kind safety, path-keyed identity, empty-fetch guard reuse, remove-stale+add-new dedup · paths: `JellyfinMusicVideoLibraryScanner.TrashMissingMusicVideos`, `FindMusicVideoPaths`/`DeleteByPath`, `IMusicVideoRepository`, `MediaServerReconciliationGuard` · issues: #494, #477, #488, #496, #500'
mechanics: '`ScanLibrary_Should_Not_CrossDelete_Movie_Or_Show_Sharing_The_LibraryPath`; `ScanLibrary_Should_Not_Sweep_When_Jellyfin_Returns_Zero_Items`; integration tests extending the #488 harness. #500 — when mirroring the remove-stale + add-new idiom, dedup the incoming set on **the same key its add filter compares** (the filter is materialized before the loop mutates `existing`, so duplicates both pass): `Name`, `Guid` for guids, and for Plex `Actors` an artwork-preferring dedup shared with the remove filter (whose key is `(Name, artwork-presence)`). Remaining un-deduped copies of the idiom: #600. Superseded by `scan.musicvideo-server-identity` (ersatztv#496): music videos gained a `JellyfinMusicVideo` ItemId/Etag identity, so the path diff + hard delete became an itemId diff + soft `FileNotFound` trash.'
---
The Jellyfin music-video scanner did add/update only — a music video removed on the Jellyfin side lingered in
ErsatzTV forever and could still be scheduled. It now runs a trash sweep at the end of `ScanLibrary`
(`TrashMissingMusicVideos`), mirroring the `MediaServer{Movie,Television,OtherVideo}LibraryScanner` "gone
upstream ⇒ remove" pattern but with a deliberately different identity function, because music videos lack the
media-server identity those base scanners rely on.
- **Identity is (LibraryPathId, path), not server itemId.** The base scanners diff `GetExisting*` (keyed by
`MediaServerItemId`) against the incoming server item ids, then soft-trash via `FlagFileNotFound`. Music videos
have **no `JellyfinMusicVideo` entity and no `ItemId`/`Etag`** — the scanner is a standalone
`IJellyfinMusicVideoLibraryScanner` that injects the *local* `IMusicVideoRepository`, which offers no
itemId-keyed existing-set or flag seam. So the sweep diffs the **local path** set instead: existing =
`FindMusicVideoPaths(libraryPath)` `.Except` the incoming items' replaced local paths, then hard-deletes the
remainder with `DeleteByPath` + `IScannerProxy.RemoveMediaItems`, and cleans now-empty artists with
`IArtistRepository.DeleteEmptyArtists`. Hard delete (not soft `FileNotFound` trash) because there is no
per-item FileNotFound seam on this path and the issue's Done-when is "removed".
- **Cross-kind safety is a property of the queries, not the media kind.** `MediaItem` is TPT with `LibraryPathId`
on the abstract base, so a Movie, Show and MusicVideo can share one `LibraryPath` (a mixed Jellyfin library).
Both `FindMusicVideoPaths` and `DeleteByPath` filter `LibraryPathId` **and** join the concrete `MusicVideo`
table, so the sweep can only ever see/delete music videos — a Movie/Show under the same `LibraryPath` is
invisible to it. Pinned by `ScanLibrary_Should_Not_CrossDelete_Movie_Or_Show_Sharing_The_LibraryPath`.
- **Reuses the #477 empty-fetch guard.** The sweep is gated by `MediaServerReconciliationGuard.ShouldFlagMissing`
— a successful fetch that returns zero items (server mid-restore / transient) is indistinguishable from a real
emptying, so the whole-library wipe is refused and logged. Pinned as a negative control by
`ScanLibrary_Should_Not_Sweep_When_Jellyfin_Returns_Zero_Items` (removing the guard flips it red).
- **Known limitation (deferred to per-library identity).** `MusicVideoRepository.GetOrAdd` dedups a path
**globally** (no `LibraryPathId` predicate), so a file served by two libraries with overlapping local paths is
a single row owned by whichever library scanned it first. If that owner later stops reporting the file while
another library still serves it, this sweep removes the shared row. A proper fix needs per-library music-video
identity (a `JellyfinMusicVideo` etag entity + migration) — the issue's "option 2 / fold into the base
scanner" refactor — tracked as #496.
- **Tests.** Integration tests (real `ArtistRepository`/`MusicVideoRepository`/`LibraryRepository` over in-memory
SQLite, extending the #488 harness) pin removal, empty-artist cleanup, cross-kind safety, and the empty-fetch
guard. Proven non-vacuous: all four fail against the pre-fix scanner except the guard control, which only
earns its keep once the sweep exists.
+37 -5
View File
@@ -7,10 +7,42 @@ in the active read-path). Active successor for the rule builder: `spa.rulebuilde
---
## Records formerly in this file
## 2026-07-18 — SmartCollection rule builder: compile-only closed subset, no stored AST, one-level nesting (#176)
`key: spa.smartcollection-rule-builder` · `status: superseded` · `since: 2026-07-18` · `supersedes: none` · `superseded-by: spa.rulebuilder-nesting@2026-07-25`
**Rule:** (superseded) The SmartCollection visual rule builder compiles to/from a closed subset of the Lucene grammar over the existing stored query string — no new AST, one level of group nesting.
**Signals:** SmartCollection, rule builder, Lucene compile/parse · paths: `web/src/builder/rules/`, `compile.ts`, `parse.ts`, `roundtrip.test.ts` · issues: #176, #69
**Mechanics:** superseded by `spa.rulebuilder-nesting` (ersatztv#436) — the compile-only closed-subset and field-catalog stances carry forward there; only the one-level nesting cap was reversed. See `docs/decisions.md` → that record; `spa-conventions.md` §12
Each record below moved to its own file under `records/` (ersatztv#610); the rationale is
unchanged. Resolve by **key** — that is the stable identity. A date-based pointer from
another doc or an old issue comment should land here and then follow the link.
The SmartCollection create/edit dialog gained a visual rule builder (`web/src/builder/rules/`)
alongside the existing raw-Lucene textarea. **The SmartCollection still stores a plain Lucene query
string — no new stored rule AST, no schema change.** The builder compiles its in-memory rule tree
into a **closed subset** of the Lucene grammar (`compile.ts`) and parses exactly that subset back out
(`parse.ts`, the exact inverse — returns `null`, not a best-effort guess, for anything outside the
subset); escaping is total, so any builder-authored query round-trips losslessly, proven by a 500-tree
property test (`roundtrip.test.ts`, including Lucene special characters). Opening an existing
SmartCollection tries the parse first and falls back to raw-text mode on `null` (fuzzy queries,
boosts, mixed AND/OR at one nesting level, or nesting deeper than one level).
- 2026-07-18 — SmartCollection rule builder: compile-only closed subset, no stored AST, one-level nesting (#176) — [`spa.smartcollection-rule-builder`](spa/smartcollection-rule-builder.md)
**Why compile-only over persisting an authoritative rule AST**: an AST would still need a
Lucene→rules parser to open every *pre-existing* free-text query — including every query the
Auto-Tune feature (#69) generates — so the AST would buy almost nothing (it still can't represent
arbitrary hand-written Lucene) while costing a dual-provider EF migration and a second source of
truth to keep in sync with the Lucene grammar. Compile-only keeps the query string as the single
source of truth and treats the builder as a structured *editor* over it, not a new storage model.
**One-level-nesting "Kodi" model.** `types.ts` defines a top `Group` (`match: all|any`) over `Rule`s
and/or **one level** of sub-`Group`s — enough to express `type:movie AND (genre:Horror OR
genre:Thriller)`, which covers the smart-playlist patterns Kodi-style rule builders are known for.
Arbitrary/recursive nesting was scoped out as YAGNI; revisit only if a real query needs it.
**Field vocabulary comes from a new catalog endpoint, not a hardcoded list.** `GET
/api/v1/search/fields` (read-only, MCP-introspectable; see `api-conventions.md`) returns the curated,
typed, labeled field set derived from `LuceneSearchIndex` — name/label/type/group/values — and is the
single source of truth the builder's field pickers (`fieldCatalog.ts`'s `useSearchFields` hook) and
operator/value-input choices are driven from, so the builder's vocabulary can't drift from what the
index actually supports.
**Deferred as separate follow-up issues** (explicitly out of scope for #176): facet-value typeahead
for value inputs, relative-date operators, nesting deeper than one level, and inline adoption of
`RuleBuilder` by ChannelBuilder / Auto-Tune (it was built reusable for exactly that reuse — see
`spa-conventions.md` §12).
@@ -1,45 +0,0 @@
---
key: spa.smartcollection-rule-builder
title: '2026-07-18 — SmartCollection rule builder: compile-only closed subset, no stored AST, one-level nesting (#176)'
status: superseded
since: '2026-07-18'
supersedes: none
superseded-by: spa.rulebuilder-nesting@2026-07-25
rule: (superseded) The SmartCollection visual rule builder compiles to/from a closed subset of the Lucene grammar over the existing stored query string — no new AST, one level of group nesting.
signals: 'SmartCollection, rule builder, Lucene compile/parse · paths: `web/src/builder/rules/`, `compile.ts`, `parse.ts`, `roundtrip.test.ts` · issues: #176, #69'
mechanics: superseded by `spa.rulebuilder-nesting` (ersatztv#436) — the compile-only closed-subset and field-catalog stances carry forward there; only the one-level nesting cap was reversed. See `docs/decisions.md` → that record; `spa-conventions.md` §12
---
The SmartCollection create/edit dialog gained a visual rule builder (`web/src/builder/rules/`)
alongside the existing raw-Lucene textarea. **The SmartCollection still stores a plain Lucene query
string — no new stored rule AST, no schema change.** The builder compiles its in-memory rule tree
into a **closed subset** of the Lucene grammar (`compile.ts`) and parses exactly that subset back out
(`parse.ts`, the exact inverse — returns `null`, not a best-effort guess, for anything outside the
subset); escaping is total, so any builder-authored query round-trips losslessly, proven by a 500-tree
property test (`roundtrip.test.ts`, including Lucene special characters). Opening an existing
SmartCollection tries the parse first and falls back to raw-text mode on `null` (fuzzy queries,
boosts, mixed AND/OR at one nesting level, or nesting deeper than one level).
**Why compile-only over persisting an authoritative rule AST**: an AST would still need a
Lucene→rules parser to open every *pre-existing* free-text query — including every query the
Auto-Tune feature (#69) generates — so the AST would buy almost nothing (it still can't represent
arbitrary hand-written Lucene) while costing a dual-provider EF migration and a second source of
truth to keep in sync with the Lucene grammar. Compile-only keeps the query string as the single
source of truth and treats the builder as a structured *editor* over it, not a new storage model.
**One-level-nesting "Kodi" model.** `types.ts` defines a top `Group` (`match: all|any`) over `Rule`s
and/or **one level** of sub-`Group`s — enough to express `type:movie AND (genre:Horror OR
genre:Thriller)`, which covers the smart-playlist patterns Kodi-style rule builders are known for.
Arbitrary/recursive nesting was scoped out as YAGNI; revisit only if a real query needs it.
**Field vocabulary comes from a new catalog endpoint, not a hardcoded list.** `GET
/api/v1/search/fields` (read-only, MCP-introspectable; see `api-conventions.md`) returns the curated,
typed, labeled field set derived from `LuceneSearchIndex` — name/label/type/group/values — and is the
single source of truth the builder's field pickers (`fieldCatalog.ts`'s `useSearchFields` hook) and
operator/value-input choices are driven from, so the builder's vocabulary can't drift from what the
index actually supports.
**Deferred as separate follow-up issues** (explicitly out of scope for #176): facet-value typeahead
for value inputs, relative-date operators, nesting deeper than one level, and inline adoption of
`RuleBuilder` by ChannelBuilder / Auto-Tune (it was built reusable for exactly that reuse — see
`spa-conventions.md` §12).
+29 -5
View File
@@ -7,10 +7,34 @@ never in the active read-path). Active successor: `startup.parallel-orientation`
---
## Records formerly in this file
## 2026-07-11 — Queue state lives in the pinned Gitea tracker (#237), not in the handoff file
`key: docs.queue-state-gitea-tracker` · `status: superseded` · `since: 2026-07-11` · `supersedes: none` · `superseded-by: startup.parallel-orientation@2026-07-21`
**Rule:** (superseded) Volatile session/queue state lives in pinned Gitea tracker issue #237 (goal + arc in the body, append-only session-comment log, in-progress/review labels), not in a wholesale-rewritten handoff file; the handoff file keeps only the static kickoff prompt and append-only lore.
**Signals:** last-writer-wins race, claim/`in-progress` label, triage verdict · paths: `docs/handoffs/chicorytv-issue-queue.md` · issues: #237, #520
**Mechanics:** superseded by `scripts/select-queue.sh` (ersatztv#520); see `docs/decisions.md``startup.parallel-orientation`
Each record below moved to its own file under `records/` (ersatztv#610); the rationale is
unchanged. Resolve by **key** — that is the stable identity. A date-based pointer from
another doc or an old issue comment should land here and then follow the link.
With multiple sessions/agents working the repo in parallel, the old protocol — every session
wholesale-rewrites `docs/handoffs/chicorytv-issue-queue.md` on main (session state + queue +
next-session prompt) — became a last-writer-wins race. New protocol: **volatile queue state
moved to Gitea**, which is concurrency-safe by construction. Pinned tracker issue **#237**
holds the goal + ordered arc in its body (edited rarely, only on arc changes, re-read before
edit) and an append-only session-comment log (fixed template: Closed / Filed / Triage /
Arc change / Recommended next). Milestone `Blazor removal (#91 phase b)` + the `review` and
`in-progress` labels are the machine-queryable view. Sessions **claim** an issue before working
it (`in-progress` label + claim comment; the tiny read→claim race window is accepted, later
claimant backs off; stale claims — no commits/comments ~48h — may be taken over with a comment).
Every new issue gets an explicit end-of-session triage verdict — gate-blocker (milestone + arc
slot) or backlog (label only) — so review findings adjust the queue only through that step and
the arc doesn't drift. The handoff file keeps only the **static kickoff prompt** and the
**append-only Lessons lore** (per-session prompts are gone; task context lives in issue bodies).
- 2026-07-11 — Queue state lives in the pinned Gitea tracker (#237), not in the handoff file — [`docs.queue-state-gitea-tracker`](docs/queue-state-gitea-tracker.md)
**Why superseded (#520, 2026-07-21):** the arc completed and #237 closed (2026-07-13); a closed
tracker cannot serve as live queue state, and continuing to read it as such was a live regression
risk (an agent skimming an old comment or this very record could re-treat #237's prose as current).
`scripts/select-queue.sh` (2026-07-19) already replaced the mechanical parts of this rule with a
deterministic, live-Gitea-only query — this record's job was really "queue state lives in Gitea,
not in the handoff file," and that half is still true; what's superseded is the *specific store*
(#237's body/comments) now that maintenance/backlog mode has no arc to narrate. See
`startup.parallel-orientation` for the replacement: two concurrent session-start tracks
(orientation via the docs/decisions catalog + `docs/README.md` map, and selection via the script),
with #237 reduced to a single archival breadcrumb.
+301 -11
View File
@@ -11,18 +11,308 @@ cross-editor ETag rotation). Refs #197.
## Contents
- [2026-07-11 — Optimistic-concurrency contract for replace-all PUTs (#253 PR1: infra + Block reference)](#2026-07-11--optimistic-concurrency-contract-for-replace-all-puts-253-pr1-infra--block-reference)
- [2026-07-11 — #253 PR3: Diff + Scalar concurrency fan-out (Collection / Playout×2 / MultiCollection / RerunCollection)](#2026-07-11--253-pr3-diff--scalar-concurrency-fan-out-collection--playout2--multicollection--reruncollection)
- [2026-07-11 — Stable child identity for schedule-item replace (#259, split from #252/#253)](#2026-07-11--stable-child-identity-for-schedule-item-replace-259-split-from-252253)
- [2026-07-12 (#269 — non-If-Match root writers force-write past a concurrent Version bump)](#2026-07-12-269--non-if-match-root-writers-force-write-past-a-concurrent-version-bump)
- [2026-07-12 — Cross-editor ETag rotation completed for Collection/Playout config siblings (#269)](#2026-07-12--cross-editor-etag-rotation-completed-for-collectionplayout-config-siblings-269)
- [2026-07-12 — If-Match evaluates per RFC 7232: valid-but-non-matching → 412, only grammar violations → 400 (#265)](#2026-07-12--if-match-evaluates-per-rfc-7232-valid-but-non-matching--412-only-grammar-violations--400-265)
- [2026-07-18 — Concurrent same-item add is idempotent, not a 500: catch the unique-violation per provider (#308)](#2026-07-18--concurrent-same-item-add-is-idempotent-not-a-500-catch-the-unique-violation-per-provider-308)
---
## Records formerly in this file
## 2026-07-11 — Optimistic-concurrency contract for replace-all PUTs (#253 PR1: infra + Block reference)
`key: concurrency.replace-all-contract` · `status: active` · `since: 2026-07-11` · `supersedes: none` · `superseded-by: none`
**Rule:** Replace-all aggregate PUTs carry a uniform plain `int Version` concurrency token (EF `.IsConcurrencyToken()`), checked pre-save and enforced by the EF UPDATE guard, returning 412 (not 409) on a stale `If-Match`.
**Signals:** ETag, If-Match, Version token, 412 Precondition Failed · paths: `api-conventions.md` §7a, `ConcurrencyHeaders`, `ApiResults.ToErrorResult` · issues: #253, #197, #265, #259
**Mechanics:** `docs/api-conventions.md` §7a; `SaveChangesWithConcurrencyGuard`
Each record below moved to its own file under `records/` (ersatztv#610); the rationale is
unchanged. Resolve by **key** — that is the stable identity. A date-based pointer from
another doc or an old issue comment should land here and then follow the link.
Replace-all aggregate PUTs had **no** optimistic concurrency — a stale second tab silently overwrote a
fresher edit (200, no signal) across ~10 aggregate surfaces. PR1 lands the shared contract on the Block
reference aggregate; PRs 24 fan it out. The full ratified design + independent-review hardening is
[#253#issuecomment-8472](http://192.168.1.95:3000/timothy/ersatztv/issues/253#issuecomment-8472);
the mechanics live in `api-conventions.md` §7a. Decisions frozen here:
- 2026-07-11 — #253 PR3: Diff + Scalar concurrency fan-out (Collection / Playout×2 / MultiCollection / RerunCollection) — [`concurrency.diff-scalar-fanout`](records/concurrency/diff-scalar-fanout.md)
- 2026-07-11 — Optimistic-concurrency contract for replace-all PUTs (#253 PR1: infra + Block reference) — [`concurrency.replace-all-contract`](records/concurrency/replace-all-contract.md)
- 2026-07-11 — Stable child identity for schedule-item replace (#259, split from #252/#253) — [`concurrency.schedule-item-child-identity`](records/concurrency/schedule-item-child-identity.md)
- 2026-07-12 (#269 — non-If-Match root writers force-write past a concurrent Version bump) — [`concurrency.force-write-non-ifmatch`](records/concurrency/force-write-non-ifmatch.md)
- 2026-07-12 — Cross-editor ETag rotation completed for Collection/Playout config siblings (#269) — [`concurrency.etag-rotation-completion`](records/concurrency/etag-rotation-completion.md)
- 2026-07-12 — If-Match evaluates per RFC 7232: valid-but-non-matching → 412, only grammar violations → 400 (#265) — [`concurrency.ifmatch-rfc7232`](records/concurrency/ifmatch-rfc7232.md)
- 2026-07-18 — Concurrent same-item add is idempotent, not a 500: catch the unique-violation per provider (#308) — [`concurrency.idempotent-concurrent-add`](records/concurrency/idempotent-concurrent-add.md)
- **Token = uniform plain `int Version`** on each root implementing `IVersionedAggregate`, EF-mapped
`.IsConcurrencyToken()`, one dual-provider migration (`AddAggregateVersions`, `defaultValue: 0`). **Not**
a reused `DateUpdated` (tick-collision, SQLite TEXT precision, couples UI cosmetics to correctness) and
**not** a MySQL-native rowversion (portability over provider-native).
- **412 Precondition Failed**, not 409 — 409 stays the §3a EntityLocker "build in progress" guard;
distinct codes → distinct SPA UX. New `PreconditionFailedError : BaseError` → 412 in `ApiResults.ToErrorResult`.
- **Pre-check AND EF token both required.** The handler pre-check (a standalone `Either` introduced AFTER
the validation pipeline — never via `Apply`, which `Join()`-flattens the subtype to 422) gives a clean
412; the unconditional `root.Version++` + `IsConcurrencyToken` UPDATE-guard + a `SaveChangesWithConcurrencyGuard`
backstop closes the residual load→save TOCTOU (`DbUpdateConcurrencyException` → 412).
- **Unconditional bump** (not "only when a child changed"): EF writes the root row only when a scalar
differs, so a no-op PUT-back must still bump to fire the token and rotate every other client's ETag.
- **Config-only aggregate boundary**: every mutating handler of a root's *editor-visible config state*
bumps `Version` (incl. bulk `ExecuteUpdate/Delete` writers via `.SetProperty`); regenerated build output
(playout items/history) is outside the token — neither bumped nor guarded.
- **Header-only ETag**, strong tag of the decimal `Version`; parsed/emitted by `ConcurrencyHeaders`. The
successful PUT returns the new ETag (else a same-tab second save 412s against its own write).
- **Phasing**: Phase 1 (this arc) = a missing `If-Match` force-writes (zero breakage) while the SPA starts
echoing; Phase 2 (a later PR) flips missing → **428** after every editor echoes and one release soaks.
`If-Match: *` stays the scripted force-write escape hatch.
- **Child stable-identity is OUT of #253** (the "moved fill-group item inherits the wrong slot's state"
concern on the positional reconcile) — root-anchored versioning is orthogonal to it; split to **#259**.
- **If-Match status semantics** (non-canonical/weak/list → 400) are fail-safe; the stricter RFC 7232
"valid-but-non-matching → 412" refinement is deferred to #197 (**#265**).
## 2026-07-11 — #253 PR3: Diff + Scalar concurrency fan-out (Collection / Playout×2 / MultiCollection / RerunCollection)
`key: concurrency.diff-scalar-fanout` · `status: active` · `since: 2026-07-11` · `supersedes: none` · `superseded-by: none`
**Rule:** 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++`.
**Signals:** concurrency fan-out, PreconditionFailedError, SaveChangesWithConcurrencyGuard · paths: `api-conventions.md` §7a · issues: #253, #269, #232, #197
**Mechanics:** `RootWriterForceVersionTests`-adjacent handler tests; `api-conventions.md` §7a
**Context.** PR3 of the #253 optimistic-concurrency arc fans the frozen Block recipe (api-conventions §7a)
across the five Diff/Scalar aggregates. Three judgment calls beyond the mechanical copy:
**H1 — Playout `catch(Exception)`→422.** The two Playout replace handlers wrap `SaveChangesAsync` in a
`catch(Exception)` that maps any exception to a bare `BaseError` (→ 422). Rather than let the guard's
concurrency failure be reshaped into a 422, the guarded save (`SaveChangesWithConcurrencyGuard`) returns a
`PreconditionFailedError` **Left as a value** and the handler returns it before the post-commit block —
so it never reaches the catch. Proven by the pre-check-subtype tests (a `.Apply` flatten would fail
`ShouldBeOfType<PreconditionFailedError>`) plus a non-vacuous Playout racing-save test.
**M2 — the `SaveChangesAsync() > 0` gates.** RerunCollection and Collection-custom-order run their
playout-refresh **unconditionally** on a successful save (the unconditional `Version++` makes the old gate
always-true; the "nothing changed" branch is dead). MultiCollection is the exception: it saved the name
first specifically so a name-only change wouldn't rebuild playouts, so we bump `Version` on that **first**
save and leave the **second** (items) save's `> 0` gate intact — a name-only edit still bumps + rotates the
ETag but does not rebuild. Enumerating every behavior the gate provided before reworking it (the #232 lesson).
**Sibling-writer scope (deferred).** §7a's config-only boundary says every writer of an aggregate's
editor-visible config bumps `Version`. PR3 ships the five primary endpoints' full contract + the one
design-named bulk writer (`UpdateDefaultDecoHandler`, safe via `.SetProperty`). It **defers** the other
same-root non-bulk config writers (`UpdateCollectionHandler`, `RemoveItemsFromCollectionHandler`,
`UpdatePlayoutHandler`, the `ScheduleFile` handlers) and the repository-mediated `Add*ToCollection` family.
Rationale: the primary endpoints' own bump+guard fully cover the two-tab lost-update the issue targets;
the deferred writers only affect cross-editor ETag *rotation*, and adding an unconditional bump to a handler
that uses plain `SaveChangesAsync` (not the guard) converts a latent lost-update into a **new 500**
(`DbUpdateConcurrencyException`) — doing it safely needs a uniform guard+bump+412 pass of its own, better
done with the #197 contract work. Tracked as a follow-up issue.
**VMs.** `Playout.Version` surfaces via `PlayoutNameViewModel` (required arg); the three collection VMs
(`MediaCollectionViewModel`, `MultiCollectionViewModel`, `RerunCollectionViewModel`) carry `int Version = 0`
(defaulted — 0 for the selection-placeholder constructions, real value from the Mapper projection).
Header-only via ETag, never echoed in a response body (the Block precedent).
**Post-merge addendum (PR3 review, #269).** Activating the `Version` token means EF guards *every* root
UPDATE, so non-participating root-scalar writers that use plain `SaveChangesAsync` (playout settings /
schedule-file / on-demand-checkpoint, collection name) would 500 on a concurrent bump. The realistic
UPDATE writers were fixed in-PR with `ConcurrencyExtensions.SaveChangesForcingVersion` (Phase-1
force-write on conflict: adopt the stored token, retry, never revert the concurrent bump). The deferral
above is re-scoped to the DELETE handlers + repository `Add*` writers only (→ #269).
## 2026-07-11 — Stable child identity for schedule-item replace (#259, split from #252/#253)
`key: concurrency.schedule-item-child-identity` · `status: active` · `since: 2026-07-11` · `supersedes: none` · `superseded-by: none`
**Rule:** `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).
**Signals:** stable child identity, schedule-item replace, id-based reconcile · paths: `api-conventions.md` §7c · issues: #259, #252, #253, #197
**Mechanics:** `docs/api-conventions.md` §7c
`PUT /api/schedules/{id}/items` now reconciles by an optional round-tripped child id, not by array
position, so an item's persisted fill-group/shuffle state (`PlayoutScheduleItemFillGroupIndex`, FK
`OnDelete(Cascade)`) follows the logical item across reorders/inserts instead of being inherited by
whatever previously held its new slot. Contract + rules in **api-conventions §7c**. Key decisions:
- **`ScheduleItemRequest.Id` (`int?`)**: null/absent/`0` ⇒ new item (controller normalizes `0`→null so the
handler is two-state). Any id present ⇒ id-based reconcile; a fully id-less payload keeps the verbatim
positional fallback (legacy; retires with the §7a Phase-2 `If-Match`→428 flip).
- **Unknown or duplicate id ⇒ 422, nothing persisted**; the guards live in the handler **after** §7a
`CheckVersion`, so **412 precedes 422** — a client that is both version-stale and id-stale gets the reload
signal, not a payload-bug signal. Rationale for reject-not-insert on an unknown id: under Phase-1
force-write a stale id is a live lost-update signal, so silently inserting-as-new would duplicate the item
and return a different id than the client sent (the exact class §7a exists to surface). This is also the
correct #197 posture — never honor an unrecognized identifier.
- **Scope = schedule items only.** Blocks/templates/deco-templates/playlists stay positional: their children
are stateless config rows (no FK'd state to misattribute; #3/#4 have no GET child id). Child ids are added
only where a child row anchors server-side state; the contract can be retrofitted per-endpoint later
(field stays optional) — so this is not #197 ossification pressure.
- **TPT subtype change at a matched id** stays delete+insert (EF can't retype in place); state resets and a
new id is returned, so the SPA must re-seed item state from the PUT response (a stale id on a second save
now 422s).
## 2026-07-12 (#269 — non-If-Match root writers force-write past a concurrent Version bump)
`key: concurrency.force-write-non-ifmatch` · `status: active` · `since: 2026-07-12` · `supersedes: none` · `superseded-by: none`
**Rule:** 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).
**Signals:** force-write, non-If-Match writers, DbUpdateConcurrencyException · paths: `ConcurrencyExtensions.SaveChangesForcingVersion` · issues: #269, #253, #302, #197
**Mechanics:** `RootWriterForceVersionTests`; `docs/api-conventions.md` §7a
**Routing the aggregate delete handlers + `UpdateProgramScheduleHandler` through `SaveChangesForcingVersion`.**
Once #253 made each replace-all root's `Version` an `IsConcurrencyToken`, EF started guarding *every*
UPDATE **and DELETE** of that row with `WHERE Version=@orig` — so any writer that is not part of the
If-Match contract but still saves via plain `SaveChangesAsync` throws an unhandled
`DbUpdateConcurrencyException`→**500** if a replace-all editor bumps the row in its narrow load→save window.
PR3 already force-wrote the exposed *UPDATE* siblings (Playout settings/`ScheduleFile`/checkpoint,
`UpdateCollectionHandler`); a completeness sweep for #269 found the gap was wider than reported —
**18 writers** in total, all on plain `SaveChangesAsync`. **The correct exposure filter is "any handler
that leaves a versioned root `Modified` or `Deleted`", NOT just `Version`-bumpers + deletes** — an early
sweep used the narrower filter and a review of PR #302 caught what it missed (`ErasePlayoutHistory` below):
- the **nine versioned-root delete handlers** (`DeletePlayout`/`DeleteCollection`/`DeleteMultiCollection`/
`DeleteRerunCollection`/`DeletePlaylist`/`DeleteBlock`/`DeleteTemplate`/`DeleteDecoTemplate`/
`DeleteProgramSchedule`) — a DELETE is now token-guarded too;
- `UpdateProgramScheduleHandler` (bumps `Version` then saved plainly — the ProgramSchedule case PR3 only
*suspected*);
- the **seven item add/remove bumpers** that PR2 wired to bump their root but left on plain save —
`AddProgramScheduleItem`/`DeleteProgramScheduleItem` and the five
`Add{Items,Movie,Show,Season,Episode}ToPlaylist` handlers;
- **`ErasePlayoutHistoryHandler`** — modifies Playout root **scalars** (`Seed`/`Anchor`/`OnDemandCheckpoint`)
**without** bumping `Version`, inside an explicit transaction with no try/catch → the one the bumper-only
filter missed; reachable via `POST /api/playouts/{id}/erase-items-and-history`.
All now save through `ConcurrencyExtensions.SaveChangesForcingVersion`.
**Two deliberate boundaries (documented, not gaps):** (1) the background build/time-shift Playout-scalar
writers (`BuildPlayoutHandler` via `PlayoutBuilder`'s `Anchor`/`Seed`; `PlayoutTimeShifter`'s
`OnDemandCheckpoint`) are token-guarded too but **intentionally left on plain save** — they never surface a
request-path 500 (`BuildPlayoutHandler` catches → a build-failure `BaseError`; `PlayoutTimeShifter` runs only
via the background worker), and force-writing would be *wrong*: a concurrent config edit that bumped
`Version` also enqueues a rebuild, so failing the in-flight build and letting the rebuild redo it with fresh
config is correct (force-writing would persist output built from stale config). (2)
Item-add force-write can leave a duplicate/gap `Index` (accepted Phase-1 effect): the handler computes the
new index from its stale child list, so if a concurrent replace-all grew the list the item lands at a
now-colliding index (no unique constraint on `PlaylistItem.Index`/`ProgramScheduleItem.Index`) — non-
corrupting, self-correcting on the next edit, still strictly better than the pre-#269 500; a
reload-and-recompute-on-conflict refinement is a candidate for #197. Decision:
**force-write, not 412** — these endpoints take no `If-Match` (an unconditional DELETE/settings-edit should
win over a concurrent editor), matching the Phase-1 force-write posture. A delete has no ETag to rotate, so
it needs only the force-write, not a `Version` bump. A genuine row-deletion race (two concurrent deletes)
still surfaces as a `DbUpdateConcurrencyException` — accepted (rare, non-corrupting, the resource is already
gone). **Still deferred to #197:** *cross-editor ETag rotation* for the non-bumping config siblings and the
scanner-shared `Add*ToCollection` family (they don't 500 — they insert children / `ExecuteDelete`, neither
of which is token-guarded — they just don't rotate an open editor's ETag). Non-vacuously tested by racing a
bump *through the handler* via a pre-tracked context (`RootWriterForceVersionTests`), plus an explicit
negative control proving the plain-save path throws.
## 2026-07-12 — Cross-editor ETag rotation completed for Collection/Playout config siblings (#269)
`key: concurrency.etag-rotation-completion` · `status: active` · `since: 2026-07-12` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** ETag rotation, no-op idempotence, SaveChangesForcingVersion rebase · paths: `CollectionEtagRotationTests`, `PlayoutScheduleFileEtagRotationTests` · issues: #269, #253, #197, #308
**Mechanics:** `docs/api-conventions.md` §7a; `CollectionEtagRotationTests`, `PlayoutScheduleFileEtagRotationTests`
The #253 optimistic-concurrency contract (§7a) had a documented tail: the non-If-Match config-sibling
writers of a versioned root mutated editor-visible state **without** bumping `Version`, so editing through
them did not rotate an open editor's ETag (a cross-editor invalidation gap — never a lost-update or a 500,
which the primary endpoints' bump+guard already cover). #269's first slice (PR #302) removed the 500 exposure
by routing those writers through `SaveChangesForcingVersion`; this slice completes the **rotation**.
Handlers now bumping `Version` (all via `SaveChangesForcingVersion`, since they take no `If-Match` → a
concurrent replace-all bump force-writes, never 412/500): the Collection `Add*ToCollection` family (11
handlers) and `RemoveItemsFromCollectionHandler` bump `Collection.Version`; `UpdateCollectionHandler`
(name/flag), `UpdatePlayoutHandler` (`DailyRebuildTime`), and the three `ScheduleFile` writers
(`UpdateSequential`/`UpdateScripted`/`UpdateExternalJsonPlayout`) — which already force-wrote — now also bump.
Decisions frozen (ratified with Fable before implementation, feeding the #197 contract freeze):
- **Rotate on every editor-visible config change, no per-aggregate carve-outs.** §7a's config-only boundary
("every mutating handler of a root's editor-visible config bumps `Version`") already held for Playlist
`Add*`/schedule item writers; the Collection/Playout siblings were an inconsistency, not a judgment call. A
membership add rotating an open custom-order editor's ETag (→ 412 → reload) is correct: its list is genuinely
stale. Blast radius of the aggressive-but-safe rotation is a reload, never data loss.
- **No-op idempotence — the trap Fable caught.** These handlers gate their reindex/`BuildPlayout` fan-out on
`SaveChanges() > 0`. An *unconditional* bump makes that gate always-true, so an idempotent re-add / same-value
re-submit would fire spurious rebuilds across every playout using the aggregate. Fix: short-circuit a genuine
no-op **before** the bump — the Add handlers by an explicit membership check (which also fixes the latent
duplicate-`CollectionItem` insert on a *sequential* re-add; two *concurrent* same-item adds can still both
pass the check and the loser 500s on the composite-PK unique violation — `SaveChangesForcingVersion` catches
only `DbUpdateConcurrencyException`, not `DbUpdateException`. That race is narrow and pre-existing, deferred
to #308), the scalar writers (`UpdateCollection`, `UpdatePlayout`, the
three `ScheduleFile` writers) by `ChangeTracker.HasChanges()`. A no-op neither bumps nor rebuilds nor rotates
the ETag — which is itself correct (nothing changed).
- **The `Add*ToCollection` family is not repository-mediated.** #269's original framing ("repository-mediated,
shared with the scanner hot path") was wrong: `IMediaCollectionRepository` is read-only; each handler loads
the `Collection` into its own `dbContext` and writes directly. So the rotation bump is a pure API-layer
concern and the scanner's separate membership-write path is untouched — a background scan does **not** rotate
the editor ETag (correct: background indexing is not an editor action).
- **Force-write rebases the bump, never adopts the stored token verbatim (Codex review of this PR).**
`SaveChangesForcingVersion` originally resolved a conflict by setting current=original=stored — which
silently *discarded* a sibling's pending `Version++` when a versioned writer committed in its load→save
window (sibling loads 1, bumps to pending 2, concurrent PUT commits 2 → retry wrote 2, so the concurrent
writer's ETag "2" stayed valid and the rotation was lost under exactly the race it exists for). Fixed in
this PR (it affects all 25 bumpers routed through the helper, including the pre-existing playlist/schedule
ones): the retry now rebases — original = stored, current = stored + (pending current pending original) —
so a bumper lands at stored+1 and a non-bumper (delta 0, e.g. `ErasePlayoutHistory`) adopts stored unchanged.
The race tests assert the post-race Version (3, not 2) and fail against the verbatim-adopt implementation.
- **No new status codes.** These endpoints take no `If-Match` and force-write, so they never 412; no
`[ProducesResponseType(...412...)]` and no OpenAPI regen (response types unchanged). Only §7a prose changes.
Tests: `CollectionEtagRotationTests` (rotation + no-op-without-bump-or-rebuild + force-write-past-concurrent-bump
for Add/Remove/Update) and `PlayoutScheduleFileEtagRotationTests` (ScheduleFile rotation + no-op-without-refresh),
the no-op guard proven non-vacuous by inverting the membership check. The `#265` RFC-7232 If-Match parser
refinement (valid-but-non-matching/weak/list → 412 not 400) is a **separate** PR (disjoint surface: the shared
parser + `CheckVersion`, not the handler saves). Refs #253 #269 #197 · `api-conventions.md` §7a.
## 2026-07-12 — If-Match evaluates per RFC 7232: valid-but-non-matching → 412, only grammar violations → 400 (#265)
`key: concurrency.ifmatch-rfc7232` · `status: active` · `since: 2026-07-12` · `supersedes: none` · `superseded-by: none`
**Rule:** `ConcurrencyHeaders.ParseIfMatch` is a real RFC 7232 entity-tag/list parser: a syntactically-valid tag that doesn't strong-match (weak/empty/non-canonical/out-of-range/list) returns 412, and only a genuine grammar violation returns 400.
**Signals:** RFC 7232, If-Match parsing, strong-tag matching · paths: `ConcurrencyHeaders`, `IfMatchCondition`, `VersionedAggregateExtensions.CheckVersion` · issues: #265, #253, #197
**Mechanics:** `docs/api-conventions.md` §7a
Closing the last #253 concurrency-contract piece. `ConcurrencyHeaders.ParseIfMatch` previously classified
**any** non-canonical/weak/list `If-Match` value as `Malformed → 400` (a deliberate fail-safe: reject rather
than risk a stale write, deferred from the reference-aggregate PR). That was RFC-incorrect. Per **RFC 7232
§3.1**, a syntactically-valid entity-tag that simply doesn't strong-match must return **412 Precondition
Failed**, and **400** is reserved for a genuine grammar violation.
**What changed.** The parser is now a real RFC 7232 entity-tag/list parser (`If-Match = "*" / 1#entity-tag`).
It **scans** the list (it does *not* `Split(',')` — a comma is a valid `etagc`, so it can appear inside a quoted
opaque-tag: `"3,5"` is ONE tag, and a comma separates members only outside the quotes), trims only RFC OWS
(SP/HTAB — not `string.Trim()`, which would strip NBSP and let `" * "` masquerade as the `*` force-write),
validates each member as `[ "W/" ] DQUOTE *etagc DQUOTE`, and collects the versions of the **strong** members
whose opaque text is the exact canonical decimal we emit. Outcomes:
- **weak** (`W/"3"`), **empty** (`""`), **non-canonical** (`"03"`, `"3.0"`, `"+3"`), **out-of-range**
(`"99999999999999999999"`) → valid tags that contribute no version → **412** (a `Version`-kind with an
*empty* candidate set is a guaranteed no-match).
- **list** (`"3", "5"`) → any strong member that matches proceeds; weak/non-canonical members drop out.
- genuine grammar violations (unquoted `3`, SP inside the tag `" 3 "`, unterminated `"3`, `garbage`, a
separator-only header) → **400**.
**Type reshape.** `IfMatchCondition.ExpectedVersion : Option<int>``ExpectedVersions : Option<Seq<int>>`
(`None` = force-write; `Some(set)` = strong-match against the set, empty ⇒ always 412), and
`VersionedAggregateExtensions.CheckVersion(Option<int>)``CheckVersion(Option<Seq<int>>)` = set membership.
This threads through all 10 replace/update commands + handlers + request mappers + 9 controllers uniformly; no
wire-contract change (400 and 412 were already declared on every PUT; the field is header-derived and internal,
so no OpenAPI/DTO change).
*Why now, not #197:* it is the shared parser all replace-all PUTs copy, and the 412-vs-404 ordering the issue
worried about was already correct (each handler loads/validates → 404 before `CheckVersion`). *Why safe:* the
first-party SPA only ever echoes the single canonical strong tag we emit, so no shipped client changes behavior;
the change only makes a hand-written/tooling `If-Match` get the RFC-correct status. Docs: `api-conventions.md`
§7a. Refs #265 #253 #197.
## 2026-07-18 — Concurrent same-item add is idempotent, not a 500: catch the unique-violation per provider (#308)
`key: concurrency.idempotent-concurrent-add` · `status: active` · `since: 2026-07-18` · `supersedes: none` · `superseded-by: none`
**Rule:** A concurrent duplicate `Add*ToCollection` that loses the race on the composite-key unique constraint is treated as an idempotent no-op (skip the reindex/rebuild fan-out), not a 500 — detected via a provider-specific `TvContext.IsUniqueConstraintViolation` delegate defaulting to "no".
**Signals:** idempotent add, unique-constraint violation, provider error classifier · paths: `TvContext.IsUniqueConstraintViolation`, `SqliteErrorClassifier`, `MySqlErrorClassifier` · issues: #308, #269, #253
**Mechanics:** `docs/api-conventions.md` §7a ("Idempotent insert under concurrency"); `ConcurrencyExtensions.TrySaveChangesForcingVersion`
**Decision.** The `Add*ToCollection` family's membership pre-check (#269) is not atomic with the insert, so two
*concurrent* adds of the same item both observe it absent and both stage the `CollectionItem` composite key; the
loser's `SaveChangesForcingVersion` threw a unique/PK-violation `DbUpdateException` (SQLite error 19 / MySQL 1062)
it did not catch → **500**. We now treat that loss as an **idempotent no-op**, not an error: the desired end state
(the item is a member) already holds because the racing winner inserted it, rotated the ETag, and fanned out the
rebuild.
**Mechanism.** A `bool`-returning sibling `ConcurrencyExtensions.TrySaveChangesForcingVersion` wraps
`SaveChangesForcingVersion` and catches *only* a classified unique/PK violation, returning `false`. The 10
single-item handlers return `Unit.Default` on `false` (skip the reindex/rebuild fan-out — the winner did it). The
bulk `AddItemsToCollection` handler cannot no-op — that would silently drop the non-colliding items when a batch
partially overlaps a concurrent add — so it **retries** on a fresh context against recomputed membership (bounded
loop; the common no-collision path runs once).
**Provider seam.** Detection is provider-specific but the Application layer must not reference the provider
packages, so it follows the existing `TvContext` static-provider-config idiom (`IsSqlite`, `LastInsertedRowId`): a
settable `TvContext.IsUniqueConstraintViolation` delegate, pointed at `SqliteErrorClassifier` (extended codes 1555
PK / 2067 UNIQUE) or `MySqlErrorClassifier` (`Number == 1062`) from `Startup.cs`, defaulting to a conservative
"no" so an unwired provider never silently swallows a save failure. Chosen over DI to avoid threading a new
service through 11 handlers, and because the provider discriminator already lives as a `TvContext` static.
**Scope boundary.** `Add*ToPlaylist` is deliberately **untouched**: `PlaylistItem` has its own identity PK and no
unique index on `(PlaylistId, MediaItemId)` — a playlist may legitimately contain the same item more than once, so
there is no constraint to violate.
**Tests.** A negative-control anchor proves the race genuinely throws a classified `DbUpdateException`; the fix's
end-to-end handler tests reproduce a *real cross-connection* race via a shared-cache SQLite harness + a
`SavingChanges` interceptor that inserts the conflicting row on another connection mid-save (the single-connection
in-memory fixture cannot). Every fix-dependent test was verified to fail with the catch disabled. Mechanics:
`api-conventions.md` §7a ("Idempotent insert under concurrency"). Refs #308 #269 #253.
@@ -1,23 +0,0 @@
---
key: api.artwork-rooted-urls
title: '2026-07-07 — API artwork contract: rooted URLs produced server-side'
status: active
since: '2026-07-07'
supersedes: none
superseded-by: none
rule: API response DTOs return artwork as rooted, directly-usable URLs (plus passthrough for absolute/proxy URLs), never relative Blazor-convention paths.
signals: 'no SPA `<base href>`, `ApiArtwork` helper · paths: `ErsatzTV.Core/Api/ApiArtwork.cs`, `ErsatzTV.Application/LibraryBrowse/Queries/GetLibraryBrowseItemsHandler.cs` · issues: none cited (PR #181, PR #183)'
mechanics: '`ErsatzTV.Core/Api/ApiArtwork.cs`'
---
API response DTOs return artwork as rooted, directly-usable URLs (`/artwork/posters/...`,
`/artwork/thumbnails/...`, `/artwork/fanart/...`), plus passthrough for absolute `http(s)://` URLs
and Jellyfin/Emby proxy variants. Established by PR #181
(`ErsatzTV.Application/LibraryBrowse/Queries/GetLibraryBrowseItemsHandler.cs`, private `Artwork(...)`
helper — comment: *"Returns a rooted, directly-usable artwork URL for the SPA's `<img src>`... the
SPA [needs it pre-rooted]"*), then generalized into the reusable `ApiArtwork` helper
(`ErsatzTV.Core/Api/ApiArtwork.cs`, PR #183). Root cause: the SPA has no `<base href>`, unlike
Blazor, so relative artwork paths that worked for Blazor pages 404 in the SPA. Do **not** reuse the
Application-layer Mappers used by Blazor (e.g. `MediaCards`/`Television` mappers) for new API
DTOs — those still return old Blazor-convention relative paths; map from the domain/VM directly and
root the path via `ApiArtwork`.
@@ -1,64 +0,0 @@
---
key: api.async-op-contract
title: 2026-07-11 — Async-op API contract normalization + playout build observability + F9 scan endpoints (#235)
status: active
since: '2026-07-11'
supersedes: none
superseded-by: none
rule: 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.
signals: '`QueueShowScanResult`, `ResetAllPlayoutsResponseModel`, `MaintenanceController.EmptyTrash`/`CleanArtwork` · paths: `LibrariesController.ScanShow`, `ChannelController.ResetPlayout`, `PlayoutController.ResetAll` · issues: #235, adversarial-reviewer#20 F7/F8/F9, #232, #215'
mechanics: '`docs/api-conventions.md` §3a/§3b'
---
Reviewer#20 F7/F8/F9. Normalizes the queue-triggering `/api/*` endpoints onto one contract, closes the two
F9 `Libraries.razor` parity gaps, and hardens the Trakt batch-lock lifecycle. Much of the F8 surface was
**already normalized** by #232 (library scan → `QueueLibraryScanResult` 202/404/409/422) and #215 (per-id
playout mutations + reset → 409 lock guard) — this issue finished the remaining outliers.
**Normalized async-op contract** (queue-triggering endpoints): **202 Accepted** = work queued; **404
ProblemDetails** = entity missing (controller pre-check); **409 ProblemDetails** = lock held (the running
job, or a mutation racing it — §3a/§3b); **422 ProblemDetails** = domain precondition (sync disabled /
unsupported / start failed). Trakt was the reference implementation. Changes made:
- `MaintenanceController.EmptyTrash` — error path **500 text/plain → 404/422 ProblemDetails** (`ToErrorResult`).
- `MaintenanceController.CleanArtwork` — silent **200 → 202** (fire-and-forget enqueue). No SPA consumer.
- `LibrariesController.ScanShow` — conflated **400 `{error}` → 202/404/409/422** via a new
`QueueShowScanResult` enum (6 outcomes incl. an honest `ScanFailed`→422, distinct from `Unsupported`).
- `ChannelController.ResetPlayout`**200 → 202** (queue-triggering; 404/409 unchanged).
- `PlayoutController.ResetAll`**202 (no body) → 202 + `ResetAllPlayoutsResponseModel`** reporting
`queuedPlayoutIds` / `skippedLocked` / `skippedUnsupported` (replaces the silent skip; still 202, still
skips locked/ExternalJson by design per §3a — now it *reports* what it skipped).
- `TroubleshootController.TroubleshootPlayback` — bare body-less `NotFound()` → **404/422 ProblemDetails**
with distinguishing detail. **Status codes the SPA HLS player depends on were preserved** — verified
`HlsPlayer.tsx` never branches on this endpoint's status (playback state comes from the separate
`/api/troubleshoot/playback/status` poll); only the error *body* was enriched.
**Playout build observability**: the list endpoint (`GET /api/playouts`) already stamped `isLocked` +
`BuildStatus` on `PlayoutListItemResponseModel` (#215); this issue adds **`isLocked` to the single-playout
`GET /api/playouts/{id}`** (`PlayoutResponseModel`), so the detail poll surface carries the §3a lock flag
too. No dedicated `GET /api/playouts/{id}/status` push channel was added — the flag on the existing GETs is
the HTTP-observable substitute for Blazor's live lock event, matching the `GET /api/trakt/status` precedent.
**F9 parity endpoints** (the `Libraries.razor` deletion gate — #202 did NOT close these):
- **Deep scan**: `POST /api/libraries/{id}/scan` gains `?deep=false`, threaded through
`QueueLibraryScanByLibraryId(LibraryId, DeepScan=false)` into `ForceSynchronize{Plex,Jellyfin,Emby}LibraryById(id, deep)`
(was hardcoded `false`). Non-breaking: existing callers omit it.
- **External-collections scan**: new `POST /api/media-sources/{plex|jellyfin|emby}/{id}/scan-collections?deep=false`
on the three #202 media-source controllers, dispatching `Synchronize{X}Collections(id, ForceScan:true, deep)`.
Each pre-checks source existence (404), acquires the per-source **collections** lock (`Lock{X}Collections()`
the lock *is* the running scan, so a false = **409**), then enqueues and returns 202; the controller
compensating-unlocks in a `catch` if the enqueue throws (§3b), and `ScannerService` releases in its `finally`.
Thin SPA clients shipped (`scanLibrary(id, deep)`, `scanCollections`); **the SPA deep-scan / collections
buttons are the removal PR's remaining parity work** (parity doc §5).
**F7 Trakt batch-lock leak fix**: the global Trakt lock was released only when the *terminal* batch message
(`Unlock: true`) was processed; a `WorkerService` shutdown/cancellation before that message leaked the lock
permanently (subsequent Trakt ops 409 until restart — same class as #231/#233/#234). Fix: `WorkerService`
now releases the Trakt lock in a `finally` on read-loop exit if still held. Non-vacuous regression test proven
against an inverted-condition control.
**Accepted-by-design** (per the issue's decision-record ask): the worker's channels are **unbounded** and
there is **no shutdown drain** — messages still queued at process exit are dropped. This is acceptable because
the entity locks are **in-memory singletons that die with the process**, so a dropped message can't strand a
lock across restarts (the F7 `finally` covers the *within-process* shutdown-break leak, which is the only way
a lock outlives its batch while the process keeps running). Adding a bounded-channel backpressure / graceful
drain is out of scope and would not fix a correctness bug.
@@ -1,21 +0,0 @@
---
key: api.channel-health-object
title: 2026-07-23 — Channel health = a server-derived `health` object on the channel DTOs, built-timeline detection (#415)
status: active
since: '2026-07-23'
supersedes: api.channel-health-signal@2026-07-17
superseded-by: none
rule: '`ChannelResponseModel`/`ChannelDetailResponseModel` carry a server-derived `health` object (`ChannelHealthResponseModel { Status, Faults[], PlayoutCount, BrokenSourceItemCount }`) computed **read-time** from the built timeline (`Playout.BuildStatus` + upcoming `PlayoutItem → MediaItem.State`, `Finish >= now`), kind-agnostic across all 5 `PlayoutScheduleKind` values; `Status`/`Faults` are const-string classes (`ChannelHealthStatus`, `ChannelFault`), not C# enums, so the SPA hand-maintains the union (mirrors `ChannelPreviewAvailability`). This supersedes #72''s "raw fact only, no derived enum, empty-schedule/broken-source deliberately not computed" stance now that the auto-tune taxonomy churn (#383/#384) it was waiting on has landed (see `channel.origin-marker` sibling record, #414).'
signals: 'channel health, ChannelHealthResponseModel, ChannelHealthStatus, ChannelFault, Healthy/Problems/Unknown, NoPlayout/NeverBuilt/BuildFailed/EmptyUpcoming/BrokenSource, built-timeline detection, BuildStatus, PlayoutItem MediaItem.State FileNotFound Unavailable, assessable gate, on-demand suppresses absence signals, Problems rollup filter, willNeverPlay hasProblems · paths: `ErsatzTV.Core/Api/Channels/ChannelHealthResponseModel.cs`, `ChannelRepository.GetAll`, `Mapper.GetHealth`, `GetAllChannelsForApiHandler`, `web/src/screens/ChannelsScreen.tsx`, `api-conventions.md`, `domain-model.md`, `spa-conventions.md` · issues: #415, #72, #71, #383, #384, #414'
mechanics: '`docs/superpowers/specs/2026-07-23-channel-fault-detection-design.md` (full design); `api-conventions.md` (health object shape); `domain-model.md` (channel-health row); `spa-conventions.md` (Problems filter + badge convention)'
---
#415 was deferred from #72 scope item (b): "empty schedule" and "broken/missing source" faults were real but uncomputed, each explicitly ruled out in the superseded record for a stated reason. This record reverses both rulings now that the blocking condition — the #383/#384 auto-tune status taxonomy churning the DTO shape — has resolved (#414 landed the origin column as a sibling, non-health field).
**Built-timeline (kind-agnostic) detection, not per-kind config introspection.** Every fault falls out of what the scheduler has already materialized — `Playout.BuildStatus` (`{LastBuild, Success, Message}`) for never-built/build-failed, and `Playout.Items` (the built `PlayoutItem` timeline, each carrying `MediaItemId`/`MediaItem`) for empty-upcoming and broken-source. Because the timeline is the same shape for all five `PlayoutScheduleKind` values (Classic, Block, Sequential, Scripted, ExternalJson), coverage is *by construction* — the #71 "verify a shared primitive covers ALL variants" trap, which the superseded record's own `EmptyScheduleHealthCheck` (Classic-only) fell into, cannot bite here. Scripted, which has no schedule entity to introspect at all, needs no special case. `MediaItem.State` flips on scan (not build), which rules out a build-time snapshot — detection is necessarily read-time, costed as one bounded `GROUP BY PlayoutId` aggregate query (not an N+1) over upcoming `PlayoutItem`s.
**Five-fault taxonomy, rolled up to one `status`.** `NoPlayout` (0 playouts, the absorbed #72 fact), `NeverBuilt` (assessable playout never built), `BuildFailed` (last build `Success == false`), `EmptyUpcoming` (built OK, 0 upcoming items), `BrokenSource` (≥1 upcoming item pointing at a `FileNotFound`/`Unavailable` `MediaItem`). Rollup: `Problems` if any contributing playout has a fault, `Healthy` if any is assessable-and-clean with none, `Unknown` if nothing is assessable — never a false `Healthy` and never a false `Problems`.
**The assessable gate distinguishes absence signals from presence signals.** `NeverBuilt`/`EmptyUpcoming` are inferred from *missing* content and are suppressed for `PlayoutMode == OnDemand` (an idle on-demand playout legitimately has no fresh build and drains its timeline between tune-ins — without suppression this is a false-positive storm across every on-demand channel; a suppressed absence signal contributes `Unknown`, not a false `Problems`). `BuildFailed`/`BrokenSource` are proven by content that *is* there and is bad, so they stay live in every `PlayoutMode` — they only fire when the bad thing actually exists and so cannot false-positive on legitimate idleness.
**Server owns the rollup so SPA and MCP read one verdict.** `health` rides the same `list channels`/`get channel` response both clients already fetch — no second endpoint to correlate by id, and no client re-deriving policy from raw facts (the thing the superseded record explicitly avoided freezing before the taxonomy existed). `PlayoutCount` is retained unchanged on the DTO for backward compatibility (additive-only `/api/v1` freeze); the SPA's "Problems" filter (`web/src/screens/ChannelsScreen.tsx`, `hasProblems`, replacing the old single-fault `willNeverPlay`/"No playout" filter) and per-row badges read `health.status`/`health.faults` instead.
@@ -1,37 +0,0 @@
---
key: api.channel-preview-capability
title: 2026-07-21 — Browser channel preview is a server-declared per-channel capability (#60)
status: active
since: '2026-07-21'
supersedes: none
superseded-by: none
rule: Whether a channel can be previewed in the browser is declared by the server, not derived by the SPA, as an additive `Preview` field (`{Availability, ManifestUrl, UnavailableReason}`) on `ChannelResponseModel`.
signals: 'a Play button that does nothing; preview eligibility inferred from a display string; a green preview on a Transport Stream channel being read as validating its configured pipeline · paths: `ErsatzTV.Core/Api/Channels/ChannelPreviewResponseModel.cs`, `ErsatzTV.Application/Channels/Mapper.cs`, `web/src/screens/channels/ChannelPreviewPanel.tsx` · issues: #60, #552'
mechanics: '`Mapper.GetPreview(StreamingMode, channelNumber, isEnabled, playoutCount)` is pure and JWT-agnostic'
---
`ChannelPreviewAvailability` is one of `Available`, `ForcedHlsOnly`, or `Unavailable`, computed in one
place from the real `StreamingMode` enum plus the channel's enabled/playout state. The SPA renders and
acts on it and derives nothing — deriving it client-side would mean keying behavior off
`Mapper.GetStreamingMode`'s human-readable display label, where a copy tweak would silently break
playback.
`Unavailable` covers two causes, checked in this order (first match wins): the channel is disabled
(`IptvController` 404s a disabled channel, so preview must not even try), and the channel has zero
playouts (a manifest request against one blocks indefinitely). At first pass these two were keying
preview on `StreamingMode` alone, so a disabled or playout-less channel was declared `Available` and
then failed confusingly.
Only the two HLS modes are browser-playable; a browser cannot play the `video/mp2t` that the
Transport Stream modes serve over `/iptv/*`. Those are declared `ForcedHlsOnly`: preview is offered
only as an explicit opt-in that requests `/iptv/channel/{n}.m3u8?mode=segmenter`, and is always shown
with a caveat that the check does not exercise the channel's configured pipeline. Fatal HLS errors
are reported, never auto-recovered — a diagnostic surface must show the fault rather than retry past
it; a user-initiated Retry re-issues the manifest request via a real `playToken` because the manifest
GET starts a server-side session, so a byte-identical repeat URL would otherwise be a no-op.
Originally, a JWT-enabled deployment made preview `Unavailable` (reason `IPTV JWT authentication is
enabled`) because `/iptv/*` does not accept the SPA's `ctv-session` cookie and nothing minted a JWT
for the browser. #552 closed that: the SPA now mints a short-lived token and appends it as
`?access_token=`, so this projection no longer inspects JWT status at all. See
`security.iptv-browser-token`.
@@ -1,17 +0,0 @@
---
key: api.decode-by-id
title: 2026-07-07 — Decode-style endpoints take a row id and look up server-side
status: active
since: '2026-07-07'
supersedes: none
superseded-by: none
rule: 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.
signals: '`PlayoutHistoryDetailsResponseModel` · paths: `PlayoutController.GetHistoryDetails` · issues: none cited (PR #182)'
mechanics: '`GET /api/playouts/history/{id}`, `PlayoutController.GetHistoryDetails`'
---
Endpoints that decode/expand opaque stored state accept a database row id and resolve server-side,
rather than accepting client-supplied serialized state to decode. Established by
`GET /api/playouts/history/{id}` (`PlayoutController.GetHistoryDetails`, PR #182) — the row's raw
JSON (`Key`/`Details`) is decoded server-side into `PlayoutHistoryDetailsResponseModel`, the client
never round-trips the raw payload itself.
@@ -1,19 +0,0 @@
---
key: api.from-lineup-clear-to-none
title: 2026-07-21 — `from-lineup` advanced overrides express "clear to none" via a typed `clear` enum list (#135)
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`).
@@ -1,52 +0,0 @@
---
key: api.healthcheck-remediation-dto
title: 2026-07-17 — Health-check remediation is server-declared `{Kind, Target}` on an additive DTO; the SPA acts on it (#164)
status: active
since: '2026-07-17'
supersedes: none
superseded-by: none
rule: 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.
signals: 'health checks, remediation, AppRoute/ExternalDoc · paths: `HealthCheckResponseModel`, `HealthCheckLink` · issues: #164, #286, #108'
mechanics: '`HealthCheckResponseModel.Remediation`; Application `Mapper.GetStatus`'
---
#164 asked to make the ~14 health checks *actionable* — the Dashboard health panel showed problems
with no way to investigate or fix them. Two structural decisions came out of it.
**Remediation is server-declared metadata, not SPA-derived.** Each check that has a fix knows where the
fix lives, so the *check* declares it. The domain `HealthCheckLink` grew from `(string Link)` to
`(string Target, HealthCheckLinkKind Kind)` with `Kind ∈ {ExternalDoc, AppRoute}` and two factories
(`HealthCheckLink.ExternalDoc(url)` / `HealthCheckLink.AppRoute("/app/...")`). Only the 4 checks that
built links and the API mapper touched `.Link`, so the widening was local. The SPA then *acts* on the
kind: `AppRoute` → client-side `navigateToPath(target)` button; `ExternalDoc` → new-tab anchor. The
human label is derived SPA-side from the route (a small lookup + prettified fallback) rather than sent
over the wire — keeping the DTO minimal.
**The DTO evolved additively (`/api/v1` is frozen-additive, #286).** `HealthCheckResponseModel` kept
its existing `Detail` and gained `Brief` (← the domain `BriefMessage` the old mapper silently dropped)
and `Remediation { Kind, Target }` (a nested `HealthCheckRemediationResponseModel`). The old flat
`string? Link` is **kept and still populated** (mirrors `Remediation.Target`) but documented deprecated —
we don't remove a frozen field, and existing consumers keep working. `Remediation.Kind` is a plain
string ("ExternalDoc"/"AppRoute") mapped in the Application `Mapper` exactly like `Status`
("pass"/"fail"/…), not a wire enum — matching the established pattern for that DTO.
**Three defects the audit surfaced, fixed here.** (1) The Application `Mapper.GetStatus` threw
`ArgumentOutOfRangeException` on `NotApplicable`; the handler filters `NotApplicable` before mapping so
it was latent, but the mapper is now **total** (defense-in-depth — a future caller that skips the filter
can't 500 the endpoint). `InternalsVisibleTo("ErsatzTV.Tests")` was added to the Application assembly
(mirroring Core's precedent) to unit-test that totality directly. (2) Two checks linked to **stale
Blazor routes** (`media/trash`, `search?query=…`) — repointed to the SPA `/app/trash` and
`/app/search?query=…` as `AppRoute`s. (3) A dead `Open Classic UI``/system/health` link lingered in
`SettingsScreen` (a #91b leftover that just 302'd to `/app`); removed (see `blazor-route-parity.md`
Section 4 correction).
**Actionable checks that had no link gained an `AppRoute`** (metadata → `/app/libraries`, empty
schedules → `/app/schedules`, HW-accel / VAAPI → `/app/ffmpeg-profiles`, FFmpeg reports → `/app/settings`).
Pure-noise / no-clean-action checks (UnifiedDocker, MacOsConfigFolder, FFmpegCapabilities, the Info-tier
nags) were left untouched — semantic-tier changes (e.g. adding a Pass path, demoting a nag) were
deliberately **not** bundled into a remediation-UX PR.
**Deferred (own issue): a TTL cache for `PerformHealthChecks`** (#108 — every `GET /api/v1/health`
re-runs all 14 checks, 4 shelling out to ffmpeg, and the existing summary cache is write-only dead
code). Orthogonal to the UX; filed separately so a SPA-polled health panel gets a cache before it
polls.
@@ -1,40 +0,0 @@
---
key: api.healthcheck-ttl-cache
title: 2026-07-19 — Health-check results are TTL-cached; `?refresh=true` forces a fresh run (#431)
status: active
since: '2026-07-19'
supersedes: none
superseded-by: none
rule: 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.
signals: 'health check caching, TTL, refresh query param · paths: `HealthCheckService._memoryCache`, api-conventions.md §1/§3b · issues: #431, #164'
mechanics: '`PerformHealthChecks(forceRefresh, ...)`; `GET /api/v1/health?refresh=true`'
---
`HealthCheckService.PerformHealthChecks` re-ran all 14 checks on **every** call, four of which shell out to
`ffmpeg`/`ffprobe` via CliWrap — so a bare `GET /api/v1/health` spawned ~4 subprocesses per request. The
existing `HealthCheckSummary` cache was **write-only** (populated + published, never read back to short-circuit
a re-run). Harmless while the SPA Dashboard health panel refreshes on-demand only, but a real cost the moment
anything *polls* health (a status widget, an MCP client, monitoring). Split out of #164 as the orthogonal
performance half.
- **A short TTL cache of the full result list lives inside `HealthCheckService`.** A `_memoryCache` entry
(`"healthcheck.results"`, `TimeSpan.FromSeconds(30)`) holds the last `List<HealthCheckResult>`; a non-forced
call returns it directly on a hit, skipping both the 14 checks and the summary `Publish`. Chosen over
"make the existing summary cache read-through" because the API returns the full per-check list, not the
2-int summary — the summary entry (`"healthcheck.summary"`, read by `GetHealthCheckSummary`) is kept as-is
(un-expiring) so its fallback behavior is unchanged.
- **`PerformHealthChecks` gained a `bool forceRefresh` first parameter** (interface signature change; one
implementer, 3 live callers). `forceRefresh: true` bypasses the cache and repopulates it.
- **The refresh surface is an optional `?refresh=` query param on the existing GET**, following the
`?deep=` bool-query-param exemplar (`api-conventions.md` §1/§3b) — additive, backward-compatible, no new
endpoint. `[FromQuery] bool refresh``GetAllHealthCheckResultsForApi(Refresh)``PerformHealthChecks(request.Refresh, …)`.
The SPA "Refresh health" button calls `/api/v1/health?refresh=true`; the initial/poll load calls the bare
path (cached). A separate `POST …/refresh` endpoint was rejected as unnecessary surface for a read.
- **Who forces vs. who reads the cache:** the API GET poll path reads the cache; the **startup**
`RunHealthChecksService` and the **troubleshooting** support bundle force a fresh run (both want current
state — startup is a cold cache anyway, and a diagnostic bundle should reflect *now*, not a ≤30s-old poll).
The legacy `GetAllHealthCheckResults` handler is dead (no senders) and reads the cache.
- **Thundering-herd on a cold cache was left out of scope** (no request-coalescing lock): polling is sequential
per client and the TTL collapses steady-state load, so at most a handful of exactly-simultaneous cold callers
re-run — a once-per-30s edge, not the repeated per-request cost the issue targets. Recorded here so a later
reviewer doesn't read the absence of a `SemaphoreSlim` as an oversight.
@@ -1,21 +0,0 @@
---
key: api.logs-sort-params
title: '2026-07-11 — Logs column sorting: allow-listed `sortField`/`sortDirection` on `GET /api/logs`'
status: active
since: '2026-07-11'
supersedes: none
superseded-by: none
rule: '`GET /api/logs` takes allow-listed `sortField` (`timestamp`|`level`) and `sortDirection` (`asc`|`desc`) query params, normalized (not rejected) on an unrecognized value.'
signals: '`MudTableSortLabel` parity, clamp-not-422 normalization · paths: `LogsController.GetLogs`, `LogsScreen.tsx` · issues: none cited'
mechanics: '`LogsController.GetLogs`'
---
Parity for `Logs.razor`'s `MudTableSortLabel` columns (Timestamp, Level — Message was never
sortable in Blazor either). `LogsController.GetLogs` adds `sortField` (`timestamp` | `level`,
default `timestamp`) and `sortDirection` (`asc` | `desc`, default `desc`) query params, normalized
server-side the same way `pageNum`/`pageSize` are clamped rather than rejected with a 422: an
unrecognized `sortField` silently falls back to `timestamp`, an unrecognized `sortDirection` falls
back to `desc` — the pre-existing default behavior (newest-first) is unreachable to break via a bad
query string. `LogsScreen.tsx` renders the two sortable headers as buttons with a chevron
indicating the active field/direction; clicking the active column toggles direction, clicking the
other column switches to it ascending.
@@ -1,21 +0,0 @@
---
key: api.mediatr-passthrough
title: 2026-06 — REST API wraps existing MediatR handlers 1:1, no service layer
status: active
since: 2026-06
supersedes: none
superseded-by: none
rule: The REST API is thin controllers over existing MediatR handlers, with no new service/business-logic layer.
signals: 'CQRS passthrough, `Either<BaseError, T>` mapping, handler-level fixes not controller papering · paths: `docs/rest-api.md`, `docs/api-conventions.md` §3 · issues: #2, #172'
mechanics: '`docs/api-conventions.md` §3'
---
The REST API (#2, `docs/rest-api.md`) is thin controllers over the existing MediatR
Create/Update/Delete handlers — no new service/business-logic layer was introduced, since nearly
every handler already returns `Either<BaseError, T>`, which maps cleanly to HTTP status codes.
Latent handler bugs (missing existence checks, `KeyNotFoundException` risk, etc.) are fixed **at
the handler**, converting what would have 500'd into a proper 404/422 — not papered over in the
controller. Established across the #2a#2e gap-issue PRs. Deep FK ids nested inside item-list
request bodies (e.g. a schedule item's `CollectionId`) are deliberately **not** existence-checked at
that depth, to avoid N+1 validation queries — precedent set by the schedules endpoints (#172); see
`docs/api-conventions.md` §3 for the up-to-date statement of this rule.
@@ -1,25 +0,0 @@
---
key: api.openapi-mirrors-runtime
title: 2026-07-09 — OpenAPI spec mirrors the runtime Newtonsoft serializer (#198)
status: active
since: '2026-07-09'
supersedes: none
superseded-by: none
rule: The generated OpenAPI spec is made to match the runtime Newtonsoft wire contract (via `NewtonsoftSchemaNamingTransformer`), not the reverse.
signals: '`CustomContractResolver`, `CustomNamingStrategy`, System.Text.Json drift · paths: `OpenApiSerializerContractTests` · issues: #198'
mechanics: '`NewtonsoftSchemaNamingTransformer`, `OpenApiSerializerContractTests`'
---
The generated OpenAPI document is made to follow the **runtime** JSON contract, not the reverse. Runtime
`/api/*` responses are serialized by Newtonsoft via `CustomContractResolver`/`CustomNamingStrategy`
(camelCase + a `FFmpegProfileId``ffmpegProfileId` special case + `[JsonProperty]` overrides such as
`ChannelResponseModel.FFmpegProfile``ffmpegProfile`), while `Microsoft.AspNetCore.OpenApi` generates the
spec from System.Text.Json metadata, whose camelCase drifted (`fFmpegProfileId`, `fFmpegProfile`). That
drift fed the SPA the wrong key. Rather than hand-patch the spec or change the wire format (breaking clients),
we added `NewtonsoftSchemaNamingTransformer` — an OpenAPI schema transformer registered on all three
documents that renames each schema property through the *same* Newtonsoft contract resolver the runtime uses,
so the spec matches the wire format by construction. A contract test
(`OpenApiSerializerContractTests`) serializes representative DTOs through the real runtime settings and pins
the spec property sets to them. Decision: **the wire format is the source of truth; the spec follows it via the
real contract resolver.** This also fixed a latent SPA bug (the channel-list "FFmpeg profile" column read
`fFmpegProfile` and always showed "Unassigned"). Issue #198.
@@ -1,58 +0,0 @@
---
key: api.paging-zero-based
title: 2026-07-25 — Paging is 0-based everywhere; every wrapper must say so (OpenAPI still doesn't) (#616)
status: active
since: '2026-07-25'
supersedes: none
superseded-by: none
rule: '`pageNum` is 0-based across the entire `/api/v1` surface and every wrapper of it (MCP tool catalog, SPA hooks, docs); the page offset is always derived from the EFFECTIVE (bounded) `pageSize`, never the requested one, so a `pageSize` above an endpoint''s cap narrows the page without widening the offset. The cap itself is per-endpoint (100 typical, 200 auto-tune members, 1000 search/all-items) and must not be documented as one number. A paging parameter description that omits or contradicts "0-based" is a defect.'
signals: '`pageNum`, `pageSize`, `Math.Clamp(pageSize`, `Skip(pageNum * pageSize)`, "1-based", off-by-one paging, short result set, MCP `Page()` · paths: `ErsatzTV.Mcp/ToolCatalog.cs`, `ErsatzTV/Controllers/Api/*Controller.cs` · issues: #616, #487, #58'
mechanics: '`Math.Max(0, pageNum)` + a per-endpoint upper bound on `pageSize` (`Math.Clamp(pageSize, 1, MaxPageSize)` in most controllers) then `Skip(PageNum * PageSize)` in the handler'
---
Every paged controller on `/api/v1` defaults `pageNum` to `0`, floors it at 0 (`Math.Max(0, pageNum)`
`search/all-items` uses `Math.Clamp(pageNum, 0, 2_000_000)` because it additionally needs an upper
bound to keep `pageNum * pageSize` inside `int`), bounds `pageSize` above by a per-endpoint maximum,
and passes **both bounded values** to a handler that skips `PageNum * PageSize`. That makes paging uniformly 0-based, and makes the offset a function
of the effective size rather than the requested one.
The *maximum* is deliberately not uniform and must not be documented as if it were: most reads clamp
`Math.Clamp(pageSize, 1, MaxPageSize)` with `MaxPageSize = 100`, `GetAutoTuneChannelMembers` uses
`pageSize <= 0 ? 100 : Math.Min(pageSize, 200)`, and `GET /api/v1/search/all-items` defaults to 500
and caps at 1000. So `pageSize=500` is narrowed to 100 on a collection listing and honored verbatim
on all-items. The invariant that holds everywhere is the *derivation* (offset from the effective
size), not any single cap.
The convention was correct in code and unwritten everywhere else, which is how it produced a bug
report. The MCP tool catalog described `pageNum` as "1-based page number", so a caller that started
at `pageNum=1` skipped the first page: a 15-item collection returned 0 items and a 204-item
collection returned 104. Nothing errored — the caller just got a short set, which reads as *data
loss*, not as an off-by-one, and cost a verification pass being chased as one (#487).
The same report's second claim — that `pageSize` is capped for the returned page while the offset
still honours the requested value — was **not** reproducible and is not true of any endpoint. The
observation behind it (`pageSize=500&pageNum=2` on a 204-item collection returning 4 items) is
exactly correct 0-based behaviour at the clamped width of 100: page 2 is items 201204. Both
behaviours are now pinned by mutation-verified tests in `GetCollectionItemsHandlerTests`, so the next
reader does not have to re-derive which half was real.
**Direction of the fix.** The alternative was to make the MCP layer 1-based and translate. Rejected:
`/api/v1` is additive-only post-freeze (`api.versioning-v1`), 0-based is already load-bearing in a
dozen controllers and the SPA, and a 1-based wrapper over a 0-based API would make the *same
parameter name* mean different things on two surfaces a reader routinely reads together — trading a
documented off-by-one for an undocumented one. Accuracy in the description is the cheaper contract.
**Where "0-based" is stated, and where it still isn't.** The MCP tool catalog and these docs say it
explicitly. The generated OpenAPI `pageNum` parameters carry **no description at all** (12 of them),
so a REST consumer reading only `v1.json` still has to infer the base from the default — a real
remaining gap, tracked separately rather than fixed here. Treat "every wrapper says 0-based" as the
target this record sets, not a property already true of the OpenAPI surface.
**Corollary — ids in paged rows.** A row that names a related entity should expose that entity's id,
not only its display fields, wherever a caller is expected to act on that entity. This is a rule about
actionable ids, not an audit result: `PlayoutListItemResponseModel.ScheduleName` still ships without
a schedule id, which is fine while nothing asks a caller to address a schedule from that row. `reset_channel_playout` takes a *channel* id while playout rows exposed only
the playout `id` plus channel name/number; the id spaces overlap numerically, so passing the row's id
silently reset a different channel and returned a plausible 202. List rows gained `channelId` in #297;
#616 added it to `PlayoutResponseModel` (the detail response) and named the trap in the MCP argument
description.
@@ -1,17 +0,0 @@
---
key: api.parentid-drillin
title: 2026-07-07 — Season/episode/music-video drill-in via `parentId`, not new child-listing endpoints
status: active
since: '2026-07-07'
supersedes: none
superseded-by: none
rule: Media drill-in (season/episode/artist/music-video) is served by an optional `parentId` query param on library-browse, not dedicated per-kind child-listing endpoints.
signals: 'library-picker season drill-in, media-detail browsing · paths: library-browse endpoint · issues: none cited (PRs #181/#183)'
mechanics: library-browse endpoint `parentId` param
---
Rather than adding dedicated child-listing endpoints per media kind (e.g. "list episodes of a
season"), the library-browse endpoint takes an optional `parentId` query param and the SPA drills
in by re-querying with it. Established across PRs #181/#183 (library-picker season drill-in, then
media-detail's season/episode/artist/music-video browsing). Avoids a combinatorial explosion of
per-kind child endpoints.
@@ -1,46 +0,0 @@
---
key: api.playout-build-lock-409
title: 2026-07-10 — Playout API mutations return 409 while the build lock is held (#215)
status: active
since: '2026-07-10'
supersedes: none
superseded-by: none
rule: Every id-keyed playout/channel mutation endpoint checks `IEntityLocker.IsPlayoutLocked(id)` and returns 409 Conflict while a build is in-flight, mirroring Blazor's disabled-buttons behavior; reset-all stays 202 and silently skips locked playouts.
signals: '`ApiResults.ConflictProblem`, `PlayoutListItemResponseModel.IsLocked`, advisory check-then-act · paths: `PlayoutController.cs`, `ChannelController.cs` · issues: #215, adversarial-reviewer#18'
mechanics: '`ApiResults.ConflictProblem`'
---
Blazor disabled per-playout Reset/Erase/Delete/Edit while a `BuildPlayout` was in flight
(`EntityLocker.IsPlayoutLocked`, `Playouts.razor` + per-kind editors); the REST API had no
equivalent, so a client could race an in-flight build with a destructive `ExecuteDelete` and leave
a half-built playout. Adversarial-reviewer#18 promoted this to a #91-phase-(b) removal gate: after
Blazor is deleted the invariant would vanish entirely.
Decision: enforce the invariant **server-side** on the API rather than re-implementing a live push
channel. `PlayoutController` and `ChannelController` inject `IEntityLocker`; every id-keyed mutation
`PUT /api/playouts/{id}`, `.../deco`, `.../alternate-schedules`, `.../templates`,
`POST .../erase-items`, `.../erase-items-and-history`, `DELETE /api/playouts/{id}`, and
`POST /api/channels/{channelNumber}/playout/reset` — checks `IsPlayoutLocked(id)` first and returns
**409 Conflict** (`ApiResults.ConflictProblem`, new shared helper mirroring `NotFoundProblem`) while
the build lock is held. The PUTs are gated too (not just the destructive ops): the target invariant
is "no mutation during a build", matching Blazor's edit-disable.
- **The guard is advisory check-then-act, not mutual exclusion** — same posture as Blazor's disabled
buttons. A `BuildPlayout` already sitting in the worker queue can take the lock a few milliseconds
after the check passes, so the original race is *narrowed*, not eliminated; consequences remain
self-healing (the next rebuild corrects a half-mutated playout). True prevention — having each
mutation acquire the playout lock for its duration — was deliberately not taken: `LockPlayout`
publishes `PlayoutUpdatedNotification` (UI churn per mutation) and would make mutations block
builds, a semantics change out of scope for restoring Blazor parity.
- **`reset-all` is deliberately NOT gated** — it stays 202. `ResetAllPlayoutsHandler` already
*silently skips* locked playouts, which matches Blazor and the handler semantics; a fire-and-forget
bulk enqueue always accepts.
- **SPA mirrors the lock via data, not a push channel**`PlayoutListItemResponseModel` gains an
`IsLocked` bool (set from `IsPlayoutLocked` in the controller's list projection). The playouts
screen disables Reset/Erase/Erase-and-history/Delete for a locked row and shows a "Building…"
Badge; on a 409 from any mutation it surfaces the error and calls `query.refresh()` so the row
picks up the flag. No new polling was added (the existing 30s channel-state poll is unchanged).
Precedent for the 409 shape: `TraktController` (left as-is with its own private `ConflictProblem()`
to keep the diff small). Convention recorded in `api-conventions.md` §3a.
@@ -1,50 +0,0 @@
---
key: api.postcommit-cancellation-none
title: '2026-07-11 — Post-commit side effects run on `CancellationToken.None` (generalized from #251 to #254)'
status: active
since: '2026-07-11'
supersedes: none
superseded-by: none
rule: Once a mutation commits, its entire compensating side effect (enqueues, publishes, reindexes, cache refresh, and any post-commit lookup gating one of those) runs on `CancellationToken.None` so a late client disconnect can't half-abort an already-committed change.
signals: '`WriteAsync` enqueue, `mediator.Publish`, search-index reindex · paths: `docs/api-conventions.md` §7b, `MediaCollections/`, `ProgramSchedules/`, `Playouts/`, `Channels/` handlers · issues: #251, #254, adversarial-reviewer#22'
mechanics: '`docs/api-conventions.md` §7b'
---
Audit #22 (adversarial-reviewer) found ~20 command handlers threading the request `cancellationToken`
into work that runs **after** `SaveChangesAsync` commits — the post-commit `WriteAsync` enqueue that
rebuilds/refreshes the affected entity, `mediator.Publish`, search-index reindex, cache refresh. A late
HTTP-client disconnect cancels that token, so the *already-committed* mutation throws on the way out
**and silently drops its side effect** (the playout rebuild is never queued → the persisted edit never
takes visible effect until a manual Reset). #251 fixed this for the deco handlers; #254 generalizes the
policy across the codebase.
**Decision.** Once a mutation has committed, the *entire* compensating side effect — enqueues, publishes,
reindexes, cache refreshes, and any post-commit lookup that **gates** one of those enqueues — runs on
`CancellationToken.None`. The commit is the point of no return; past it the side effect must not be
half-abortable. Full convention + the two boundaries in `docs/api-conventions.md` §7b.
**Scope of the #254 sweep (this PR).** Swept the single-`SaveChanges` handlers under `MediaCollections/`,
`ProgramSchedules/`, `Playouts/`, `Channels/` (20 handlers). Deliberately **excluded**:
- **`BuildPlayoutHandler`** — a background/worker handler; its token is the worker shutdown token, not a
client-disconnect token, so its downstream enqueues *correctly* honor cancellation.
- **`UpdateFFmpegSettingsHandler` + the two `Configuration/` settings handlers** — they commit via several
sequential `IConfigElementRepository.Upsert` calls with an interleaved enqueue; "when is it committed"
is a partial-commit-under-cancellation question broader than the clean single-`SaveChanges` F4 pattern.
Left for a separate follow-up.
- **Response-projection reloads** (`ReplaceProgramScheduleItemsHandler` / `AddProgramScheduleItemHandler`
post-commit graph reload that builds the *returned* view model) keep the request token — a cancelled
response after a durable commit + `None`-enqueue loses nothing.
- Handlers a no-token `WriteAsync()` already makes behaviorally correct (`default` == `None`) were left
alone (explicit-`None` there is cosmetic).
Also folded in the two other #254 items on the same handlers: the channel-guide `{number}.xml` delete in
`DeleteChannelHandler`/`DeletePlayoutHandler` now routes through `IFileSystem.File.Delete` (observable
under `MockFileSystem`) **before** the commit (a post-commit delete orphans the xml on a crash; the guide
xml is regenerable on demand, so a pre-commit delete is the safe ordering), and
`ReplacePlayoutAlternateScheduleItemsHandler` now rejects an empty item list in the handler (not only at
the controller pre-guard) so a direct caller can't trip the `Max()`-on-empty crash.
**Coordination note for #253 PR2PR4.** Those PRs add `Version++` (pre-commit) to the mutating handlers of
the versioned aggregates — several of which this sweep also touched (post-commit token, a different line
region). Low git-conflict risk, but merge `main` in and expect to see the `CancellationToken.None`
convention already present on the post-commit enqueues.
@@ -1,20 +0,0 @@
---
key: api.put-replace-index-order
title: 2026-07 — PUT-replace list endpoints derive `Index` from array order; alternate-schedules last row = catch-all default
status: active
since: 2026-07
supersedes: none
superseded-by: none
rule: PUT-replace-the-whole-list endpoints derive each item's `Index` from its request-array position, never a client-supplied field; alternate-schedule/playout-template rows are evaluated in `Index` order with the least-conditional row placed last as the catch-all default.
signals: '`ReplaceScheduleItemsRequest.ToCommand`, `IAlternateScheduleItem`, first-match-wins · paths: `AlternateScheduleSelector.cs` · issues: #179'
mechanics: 'PR #179, `AlternateScheduleSelector.cs`'
---
For "replace the whole list" endpoints (PUT over a collection — schedule items, template items,
etc.), the item's `Index` is derived from its position in the request array, not from a
client-supplied index/order field — established by `ReplaceScheduleItemsRequest.ToCommand`
(`Items.Select((item, index) => item.ToReplaceCommand(index))`). Separately, `ProgramScheduleAlternate`
and `PlayoutTemplate` rows (both `IAlternateScheduleItem`) are evaluated in `Index` order,
first-match-wins; the convention is to place the least-conditional (or unconditional) row **last**
so it acts as the catch-all default. Established by the alternate-schedules work (PR #179,
`AlternateScheduleSelector.cs`).
@@ -1,18 +0,0 @@
---
key: api.response-dtos
title: 2026-07 — Response DTOs live in `ErsatzTV.Core/Api`, file-scoped `#nullable enable`
status: active
since: 2026-07
supersedes: none
superseded-by: none
rule: New REST response DTOs live in `ErsatzTV.Core/Api/<Domain>/*ResponseModel.cs` with a file-scoped `#nullable enable` pragma; controllers never expose Application VM types directly.
signals: 'nullable-disabled Core project, ResponseModel mirroring ViewModel shape · paths: `ErsatzTV.Core/Api`, `docs/api-conventions.md` §2 · issues: none cited'
mechanics: '`docs/api-conventions.md` §2'
---
New REST response DTOs go in `ErsatzTV.Core/Api/<Domain>/*ResponseModel.cs` and mirror the shape of
the corresponding Application-layer ViewModel — controllers never expose VM types directly. Because
`ErsatzTV.Core.csproj` sets `<Nullable>disable</Nullable>` project-wide, any response-model file
with an optional member needs its own `#nullable enable` pragma at the top (most already have one).
`ErsatzTV.Application` has no nullable context at all — do not add `?` annotations to types living
there; that's a Core/Api-layer-only convention. Full detail: `docs/api-conventions.md` §2.
@@ -1,29 +0,0 @@
---
key: api.schedule-item-flat-dto
title: 2026-07-10 — Schedule-item GET returns a flat, non-polymorphic DTO (`ScheduleItemResponseModel`)
status: active
since: '2026-07-10'
supersedes: none
superseded-by: none
rule: Schedule-item GET/POST/PUT use a flat, non-polymorphic `ScheduleItemResponseModel` (every subtype field promoted to a nullable top-level member) instead of the polymorphic Application VM hierarchy, with mutation field names matching `ScheduleItemRequest` 1:1 for a lossless round-trip.
signals: '`ScheduleItemResponseMapper`, `ScheduleItemResponseRoundTripTests`, `EnforceProperties` normalization · paths: `ErsatzTV.Core/Api/Scheduling/` · issues: #126, #207, #212'
mechanics: '`ScheduleItemResponseRoundTripTests`'
---
`GET/POST/PUT /api/schedules/{id}/items` return `ScheduleItemResponseModel` /
`ScheduleItemsResponseModel` (`ErsatzTV.Core/Api/Scheduling/`), **not** the Application-layer
`ProgramScheduleItemViewModel` hierarchy (One/Flood/Multiple/Duration subtypes). The polymorphic VM
only described its base shape in OpenAPI, so the SPA couldn't see the subtype fields (issue #126).
The flat DTO promotes every subtype field to a nullable top-level member — `multipleMode`,
`multipleCount` (renamed from the VM's `Count`), `playoutDuration`, `tailMode`,
`discardToFillAttempts` — mapped by pattern-matching the concrete VM in
`ScheduleItemResponseMapper` (`ErsatzTV.Application/ProgramSchedules/`). Its **mutation fields are
named 1:1 with `ScheduleItemRequest`** so a GET maps losslessly back to a PUT/POST
(`ScheduleItemResponseRoundTripTests` is the release gate proving the fixed point). It also carries
picker-hydration fields the editor needs: `collectionName`/`smartCollectionName`/…/`playlistName`,
`playlistGroupId` (to preselect the playlist's group), per-filler names, `watermarks` /
`graphicsElements` as `NamedIdResponseModel` lists, the computed `name`, and `durationEstimate`.
`GetProgramScheduleItemsHandler.EnforceProperties` still rewrites StartType→Dynamic, Flood→One and
Playlist/Rerun→PlaybackOrder None when `ShuffleScheduleItems` is on — that lossy normalization is
deliberate and lives on the read side (documented + tested). New shared `NamedIdResponseModel`
(`ErsatzTV.Core/Api/`) is the generic `{id, name}` embed for API responses. Issues #126/#207/#212.
@@ -1,43 +0,0 @@
---
key: api.scheduling-hardening
title: '2026-07-13 — Scheduling API hardening: null-name 500s, duplicate template items, unreachable 404 (#172)'
status: active
since: '2026-07-13'
supersedes: none
superseded-by: none
rule: Create/Replace handlers guard against null/whitespace `name` (`IsNullOrWhiteSpace`, not just `Length`) to prevent NRE-500s, template-item overlap validation compares by index (not record value-equality) to catch exact-duplicate items, and unreachable 404 `ProducesResponseType` attributes on create-only actions are trimmed.
signals: '`BlockTemplateItem` record value-equality bypass, `api-conventions.md` §3b handler-hardening checklist · paths: `ReplaceTemplateItemsHandler`, `api-conventions.md` §3b · issues: #172, #144'
mechanics: '`docs/api-conventions.md` §3b'
---
Cleared the still-live findings from issue #172 (consolidated non-blocking nits from the #144 S1/S2
reviews). Most of the 2026-07-07 list had already been ratified deliberate (§8 "(none)" synthesized
rows; §3b deep-FK non-existence-check) or fixed since (the unauthenticated `/api/logs` +
`/api/troubleshoot/info` GETs now carry `[RequiresAuthentication]` per §9; the Trakt matched-items link
points at the live `/app/search`; `GET /api/search` already fans out via `Task.WhenAll`). Three were
genuinely live:
- **Null/empty `name` → 500 (10 handlers).** Create + Replace/Update handlers for Block, Template,
DecoTemplate, Deco (8, all genuine 500s), plus `UpdateFFmpegProfile` (genuine 500; `CreateFFmpegProfile`
was already guarded) and `CreatePlaylist` (its DTO coalesces `null``""`, so an empty-name persist, not a
500) all did `if (request.Name.Length > 50)` on a client-nullable `string Name` → unhandled
`NullReferenceException`. Fixed to `if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length >
50)` — kills the NRE, and also rejects empty/whitespace names (matching the group-create handlers'
`NotEmpty` behavior, closing a latent "block/template named ''" gap). Chose the one-line guard over
refactoring each handler onto the `NotEmpty`/`NotLongerThan` combinator to keep the blast radius tiny and
preserve each handler's existing error message + 422 mapping. Convention captured in `api-conventions.md`
§3b handler-hardening checklist.
- **Exact-duplicate template items bypassed overlap validation.** `ReplaceTemplateItemsHandler`'s O(n²)
overlap loop skipped on `item == otherItem`, but `BlockTemplateItem` is a `record`, so two value-identical
items (same BlockId + StartTime → same computed EndTime) were value-equal and skipped — both persisted
unvalidated. Switched to index-based iteration (`i != j`) so identical items at distinct positions are
compared and register as a (self-)intersection → rejected 422. (The SPA's index-based check already caught
this client-side; it was an API-only gap.)
- **Unreachable 404 on create-group actions.** `POST /api/blocks/groups` and `POST /api/templates/groups`
declared `[ProducesResponseType(ProblemDetails, 404)]` copied from precedent, but a create has no parent
lookup that can 404 (only 201/422). Trimmed — OpenAPI spec regenerated.
Deliberately **not** fixed (documented as accepted): the §8 "(none)" synthetic rows, the §3b deep-FK
non-existence-check, and the missing `Name=` on `PlayoutController` Create/Delete/Update (moot — the
"v1"-doc `OperationIdOpenApiTransformer` (#197 Bundle C) already synthesizes stable operationIds for
`Name=`-less ops, and adding `Name=` would risk renaming generated SPA client methods). Refs #172 #197.
@@ -1,51 +0,0 @@
---
key: api.search-allitems-paging
title: 2026-07-18 — Search all-items is paged to cap DoS exposure; SPA add-all pages to completeness (#293)
status: active
since: '2026-07-18'
supersedes: none
superseded-by: none
rule: '`GET /api/v1/search/all-items` is paginated (capped page size, `Totals` field) to bound DoS exposure; the SPA add-all flow pages to completeness instead of relying on an unbounded response.'
signals: 'search all-items, pagination, DoS hardening · paths: `SearchController.SearchAllItems`, `LuceneSearchIndex`, `web/src/api/search.ts` · issues: #293, #285, #308, #384'
mechanics: '`MaxAllItemsPageSize`/`DefaultAllItemsPageSize` clamps; `getAllSearchItemIds`'
---
`GET /api/v1/search/all-items` (`SearchController.SearchAllItems``QuerySearchIndexAllItemsHandler`) fired
ten index searches with **`limit: 0`** (= "return every hit", `LuceneSearchIndex` line ~244), so a single
broad query (e.g. one matching the whole library) materialized *every* matching doc across all ten media
kinds into ten `List<int>` buckets and serialized them in one response — unbounded work per request. #285
closed the original *unauthenticated* exposure (the endpoint is now behind `Api:RequireKeyForReads`, default
true); the residual was DoS-hardening against an **authenticated** caller with a very broad query. Deferred
from #285 because the SPA "add all to collection/playlist" flow materializes the full id set before the add
POST, so a naive hard cap would silently truncate "add all".
**Decision (issue option (a), operator-confirmed): paginate the endpoint and teach the SPA add-all flow to
page to completeness** — rather than option (b) (a generous cap + truncation signal). Chosen because
"add all" must stay complete for real use, and it matches the sibling `GET /api/v1/search` /
`GET /api/v1/channels/auto-tune/members` (#384) paging convention already in the codebase.
- **Endpoint (additive).** `SearchAllItems` gains optional `pageNum` (0-based) + `pageSize`, clamped exactly
like the §1 Logs / sibling `Search` precedent: `pageSize = Math.Clamp(pageSize, 1, MaxAllItemsPageSize)`
with `MaxAllItemsPageSize = 1000`, `DefaultAllItemsPageSize = 500`. `pageNum` is clamped
`Math.Clamp(pageNum, 0, MaxAllItemsPageNum)` with `MaxAllItemsPageNum = 2_000_000` — the upper bound keeps
`pageNum * pageSize` (the search skip) inside `int` range so an absurd page number can't overflow to a 500
(the sibling `Search` only floors at 0; the all-items endpoint hardens the upper bound too since this is a
DoS-hardening change). The clamp is applied per media kind (a page returns ≤ `pageSize` ids of
*each* of the ten kinds), so one response is bounded to ≤ 10 × `pageSize` ids. `QuerySearchIndexAllItems`
carries `PageNum`/`PageSize`; the handler passes `skip = PageNum × PageSize`, `limit = PageSize` into
`ISearchIndex.Search` (native skip/limit) and reads `SearchResult.TotalCount` (the true total, free) per
kind.
- **Response (additive, frozen-v1-safe).** The ten `…Ids` buckets are unchanged; a new non-null nested
`Totals` (`SearchResultAllItemsTotalsResponseModel`, ten `…Count` ints) is added so a client knows how many
ids exist per kind and can page to completeness. Nothing is removed or retyped (#286 additive-only holds).
- **Deliberate default-behavior change.** A caller that sends no `pageSize` now gets one page (default 500 /
kind) plus `Totals`, not the entire id set. This is the security change the issue asks for; it is safe here
because the only in-repo consumer is the SPA (updated in the same PR) and any external/MCP caller can read
`Totals` and page. Recorded as intentional, not a regression.
- **SPA pages to completeness.** `web/src/api/search.ts` `getSearchAllItems(query, pageNum, pageSize)` gains
the params; a new `getAllSearchItemIds(query)` loops pages (requesting `pageSize = 1000`, the server max),
accumulating every bucket until each kind has collected its `Totals` count (with an empty-page safety break
against total-count drift), and returns the merged `SearchAllItemIds`. `SearchScreen.addAll` calls it
instead of the single-shot fetch; the #221 stale-query guard and the single add POST are unchanged.
- **Out of scope (unchanged):** the add POST itself still accepts the full merged id set in one request body
— bounding *that* surface is a separate concern (see #308 for the add path); #293 is the GET.
@@ -1,46 +0,0 @@
---
key: api.search-field-values
title: 2026-07-23 — Facet-value typeahead is a new endpoint, allow-listed to text fields, no caching (#434)
status: active
since: '2026-07-23'
supersedes: none
superseded-by: none
rule: '`GET /api/v1/search/fields/{name}/values?q=&limit=` returns distinct WHOLE values from the database for one of a narrow allow-list of catalog fields (not the Lucene term dictionary — analyzed `TextField`s store lowercased word tokens, e.g. "Science Fiction" → `science`/`fiction`, useless as a typeahead suggestion), 404 for an unknown field, a non-`text` field, or a `text` field with no distinct-value source; case-insensitive prefix-filtered on `q`, `limit` clamped to `[1, 50]` (default 50).'
signals: 'facet-value typeahead, rule builder value combobox, distinct field values, GetSearchFieldValues, text field allow-list, DB-sourced distinct values, content_rating split · paths: `ErsatzTV/Controllers/Api/SearchController.cs`, `ErsatzTV.Application/Search/Queries/GetSearchFieldValues.cs`, `ErsatzTV.Application/Search/Queries/GetSearchFieldValuesHandler.cs`, `web/src/api/search.ts` · issues: #434, #176'
mechanics: '`SearchController.GetSearchFieldValues`; `GetSearchFieldValuesHandler`; api-conventions.md; spa-conventions.md §12'
---
Enum fields (e.g. `type`, `content_rating` group) already ship their allowed values inline on
`SearchFieldResponseModel` from the existing `GET /api/v1/search/fields` catalog (`spa.smartcollection-rule-builder`,
#176), so they need no endpoint — a client already has the full value set. **Text** fields (title, studio,
genre-as-free-text, etc.) don't: their values are whatever strings the library actually contains, so the
rule builder's value input for a text field needs a live lookup rather than a fixed list.
The handler allow-lists on `field.Type != "text"` (matching the same `SearchFieldCatalog.Fields` the
`/fields` endpoint serves) and returns `Option.None` → 404 for anything else, rather than silently returning
an empty list for a field that will never have values — a 404 tells a caller "wrong field kind," an empty
200 would look like "no matches yet."
**DB-sourced, not the search index.** The handler injects `IDbContextFactory<TvContext>` and resolves an
explicit per-field-name `IQueryable<string>` (or, for a few special cases, an in-memory list) rather than
querying `ISearchIndex`: `genre`/`show_genre``Set<Genre>()`, `studio``Set<Studio>()`, `director`
`Set<Director>()`, `writer``Set<Writer>()`, `actor``Actors`, `artist``ArtistMetadata.Title` (entity
artists only — free-text music-video/song artist credits are a known, intentionally-uncovered gap), `tag`
`Set<Tag>()` excluding `Tag.NfoCountryTypeId`/`Tag.PlexNetworkTypeId` (reapplying the indexer's own
exclusions so country/network strings don't leak in as tags), `network``Set<Tag>()` filtered to
`Tag.PlexNetworkTypeId`, `collection``Collections`, `video_codec``MediaStreams` filtered to
`MediaStreamKind.Video`, `album``MusicVideoMetadata.Album` concatenated with `SongMetadata.Album`. Every
DB-sourced field runs the same pipeline: `.Where(v => v.ToLower().StartsWith(qLower)).Distinct().OrderBy(v =>
v).Take(limit)`, translated to SQL by EF for both SQLite and MySQL. Two fields are computed in memory instead
of queried: `state` (the fixed 4-value `MediaItemState` enum) and `video_dynamic_range` (the literal
`["hdr", "sdr"]`). `content_rating` is special-cased: the DB stores an unsplit `"PG-13/TV-14"` string across
`MovieMetadata`/`ShowMetadata`/`OtherVideoMetadata`/`RemoteStreamMetadata`, so the handler pulls the distinct
raw strings then `Split('/')`s, trims, and dedupes in memory before the same prefix-filter/sort/take — this
matches what search actually matches on, rather than surfacing the compound string as one facet value.
**`title`, `show_title`, `album_artist` are explicitly NOT supported** (404, free-text fallback): `title`/
`show_title` are near-unique free-text fields spanning ~9 metadata tables where a distinct list of every
title isn't a useful facet; `album_artist` backs onto `SongMetadata.AlbumArtists`, a value-converted
`IList<string>` column EF can't translate into a server-side distinct query.
**Why a thin query, not a cache.** No result cache, no debounce on the server side (the SPA combobox
debounces the keystroke) — each per-field query is a bounded, indexed `Distinct`/`Take`; adding a cache
before there's a measured cost would be premature.
@@ -1,17 +0,0 @@
---
key: api.search-paging-cap
title: 2026-07-11 — Trash "See all" reuses library-browse paging; search stays capped per kind (#213)
status: active
since: '2026-07-11'
supersedes: none
superseded-by: none
rule: Search stays capped at 100 items per media kind; an overflowing kind's "See all" reuses library-browse paging instead of adding new API surface.
signals: '`GetLibraryBrowseItems`, `state:FileNotFound` query · paths: `GET /api/v1/search`, `GET /api/v1/library/browse` · issues: #213'
mechanics: '`GET /api/v1/library/browse`'
---
`GET /api/v1/search` still returns at most 100 items per media kind, which is the cheap first page for
the common case. For an overflowing kind, the SPA's "See all N …" action pages `GET
/api/v1/library/browse` with `query=state:FileNotFound`, `mediaType`, `pageNum`, and `pageSize=100`, then
appends the results client-side. This reuses the same `GetLibraryBrowseItems` query behind search,
adds no API surface, and only pays for follow-up requests when a kind exceeds the first-page cap.
@@ -1,51 +0,0 @@
---
key: api.versioning-v1
title: '2026-07-13 — API versioning: the whole `/api` surface is mounted at `/api/v1`, additive-only after freeze (#286)'
status: active
since: '2026-07-13'
supersedes: none
superseded-by: none
rule: 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`.
signals: '`ApiVersionRewriteMiddleware`, `ApiRouteVersioningTests`, RFC 8594 Deprecation header · paths: `docs/api-conventions.md` §1/§9 · issues: #286, #197'
mechanics: '`ApiRouteVersioningTests`, `docs/api-conventions.md` §1/§9'
---
The #197 cold review's C1 **BLOCKER**: `/api/*` was entirely unversioned (`info.version` was cosmetic), so the
first breaking change would silently break the SPA and any external/MCP client with no negotiation path. This is
the Phase-2 contract-freeze gate — versioning can't be added compatibly *after* the contract ossifies, so it
lands before freeze.
**What changed.** Every route under `/api` was swept to `/api/v1` — all 251 controller route attributes, the ~24
`Location`-header literals, the scanner callback URL (`CallLibraryScannerHandler.GetBaseUrl`), and the
`Startup` request-log path literal. This is **uniform**: the machine JSON API, the browser-session auth surface
(`/api/v1/auth/*`, still `IgnoreApi`), the internal loopback callbacks (`/api/v1/scan/*`) and the scripted-build
surface (`/api/v1/scripted/*`) are all versioned, so there is no unversioned corner and the compat rewrite needs
no exclusion list. The OpenAPI `v1.json` (160 paths), `endpoint-index.md`, and the SPA (945 request literals +
its test mocks, incl. regex/positional URL parsers) were regenerated/swept in lockstep. **No wire-DTO or
status-code change** — only the path prefix moved.
**Legacy compat = an in-pipeline rewrite, NOT a redirect** (`ApiVersionRewriteMiddleware`, sequenced before
`UseRouting` in the API branch). A legacy caller hitting an unversioned `/api/foo` has its request *path*
rewritten to `/api/v1/foo` and continues in-pipeline — method, body, auth headers and query string all survive,
so curl / the future MCP server / bookmarked URLs keep working with no round-trip (a 307/308 redirect would have
been fragile for non-GET + custom-header clients). Rewritten (legacy) responses carry RFC 8594 `Deprecation: true`
+ `Link: </docs>; rel="deprecation"`, and a `Sunset` header when `Api:LegacyRoutesSunset` is configured. An
already-versioned path (`/api/v1/*`) passes through untouched; a future `/api/v2/*` is **not** forced back to v1
(the middleware only fills in a *missing* version).
**Freeze semantics (owner decisions):** once shipped, `/api/v1` is **additive-only** — new endpoints/optional
fields are fine; renaming/removing/retyping an existing one requires a new `/api/v2`, never an in-place break.
The legacy-rewrite compat shim has a **2-release sunset window** (owner-chosen) before removal; the actual removal
is a tracked Phase-3 follow-up, not this PR. Existing pre-freeze warts (e.g. channel `{id}` vs `{channelNumber}`,
the synthesized negative-id "(none)" group rows) are frozen as-is per their own prior decisions.
**Route-convention standardization (#286, owner-requested).** The leading-slash inconsistency (238 absolute
`"/api/…"` method routes vs 13 relative `"api/…"`) is resolved: the standard is a **leading-slash absolute route
on each method's `[Http*]` attribute, no class-level `[Route]`** — except the two controllers where many actions
share a parametrized prefix (`ScannerController` `{scanId}`, `ScriptedScheduleController` `{buildId}`, ~40
methods), which keep a leading-slash absolute **class** `[Route("/api/v1/…")]` with relative method segments (the
right tool for a shared prefix). Enforced by `ApiRouteVersioningTests`: it reflects over every `[ApiController]`
in `Controllers.Api`, computes each action's *effective* route (ASP.NET's class+method combination rule), and
asserts it matches `^/api/v\d+/` — so a new controller that drifts (relative or unversioned) fails CI, the
"fix-it-while-you're-in-the-file" gate the `dotnet format` rules use. Browser-nav endpoints deliberately outside
`/api` (e.g. `GET /auth/oidc/login`) are out of scope for the test. Docs: `api-conventions.md` §1/§9. Refs #286 #197.
@@ -1,30 +0,0 @@
---
key: blazor.rollback-tag
title: 2026-07-11 — Pre-removal Blazor rollback tag `blazor-final` (#205)
status: active
since: '2026-07-11'
supersedes: none
superseded-by: none
rule: 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.
signals: '`git tag -a blazor-final`, `ersatztv:blazor-final` test-image restore path · paths: none · issues: #205'
mechanics: '`git tag blazor-final`'
---
Removal-gate item #205: the removal PR deletes both the Blazor reference implementation and the
`/system/health` escape hatch, so a post-deletion parity gap would otherwise be an archaeology exercise
(guessing which release tag still matches `main` minus Blazor). Decision + procedure, to run as the **first
action of the Step 2 deletion PR merge** (not before — `main` moves until then):
1. On the `main` commit **immediately preceding** the removal merge (the last commit that still contains
`ErsatzTV/Pages/**`), cut an annotated tag and push it:
`git tag -a blazor-final -m "Last commit with the legacy Blazor Server UI (pre-#91-phase-b removal)"`
then `git push origin blazor-final`. The tag name is **`blazor-final`** (not `v*`) so it does **not**
trigger the `v*` prod-release build in `.gitea/workflows/docker-build.yml`.
2. **Restore path** (if a gap surfaces post-removal): `git checkout blazor-final` → `docker build -f
docker/Dockerfile -t ersatztv:blazor-final .` → pin the **test** container to that image while the gap is
fixed forward on `main`. Alternatively `git revert` the single deletion merge commit (keep the deletion as
one squash/merge commit specifically to make this a one-liner).
3. Document the tag + restore path in the removal PR body; update this entry with the tag's commit sha when
cut.
Not cut this session — `main` still carries Blazor and will advance before the removal PR.
@@ -1,54 +0,0 @@
---
key: blazor.ui-removed
title: 2026-07-11 — Blazor Server UI removed (#91 phase b)
status: active
since: '2026-07-11'
supersedes: none
superseded-by: none
rule: 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`.
signals: '`blazor-final` rollback tag, #206 auth-posture sign-off, forbidden-prefix guard lifted for `/media/sources` · paths: `ErsatzTV/Pages/**`, `ErsatzTV/Shared/**`, `ErsatzTV/ViewModels/**`, `ErsatzTV/Validators/**` · issues: #91, #145, #151, #152, #153, #155, #202, #207, #212, #213, #235, #204, #206, #25'
mechanics: '`Startup.cs` MapFallback, `LegacyUiRedirects.cs`'
---
The #91 phase (b) removal PR deletes the legacy Blazor Server UI now that the ChicoryTV SPA has parity
(all gates cleared: #145, #151/#152/#153/#155, #202, #207, #212/#213, #235 F9). The SPA is the only UI.
**Deleted.** `ErsatzTV/Pages/**` (all `.razor`, incl. `_Host.cshtml`, `FragmentNavigationBase.cs`,
`MultiSelectBase.cs`), `ErsatzTV/Shared/**` (all `.razor` + `_Favicons.cshtml`), `ErsatzTV/ViewModels/**`
(39 Blazor edit-form VMs), `ErsatzTV/Validators/**` (10 Blazor edit-VM FluentValidation validators),
`App.razor`, `_Imports.razor`, `ErsatzTV/Locals/Shared/**` + `ErsatzTV/Locals/Pages/**` (Blazor
localization resx — `ErsatzTV/Locals/Resources.*` is KEPT), `ErsatzTV/wwwroot/css/**` (site.css),
`ErsatzTV/wwwroot/lib/**` (jquery, jqueryui, sortablejs, hls, media-chrome, roboto), `libman.json`, and
`ErsatzTV.Tests/Pages/MultiSelectBaseTests.cs`.
**9 packages pruned** (from both `Directory.Packages.props` and `ErsatzTV/ErsatzTV.csproj`; each verified
to have zero remaining consumers after the Blazor deletion): **MudBlazor**, **Heron.MudCalendar**,
**Blazored.FluentValidation**, **BlazorSortable** — unambiguous Blazor UI; **MediatR.Courier.DependencyInjection**
`ICourier` was consumed only by the deleted pages, and the app's `mediator.Publish` notification path is
plain MediatR (unaffected by the `AddCourier` removal); **Markdig**, **HtmlSanitizer**, **Chronic.Core**,
**NaturalSort.Extension** — verified zero non-Blazor consumers post-deletion.
**Startup surgical reduction.** Removed `AddRazorPages` (+`AuthorizeFolder("/")`), `AddServerSideBlazor`,
`AddMudServices`, `AddSortable`, `AddCourier`, the Blazor-attached `UseAuthentication`/`UseAuthorization`,
`MapBlazorHub`, and `MapFallbackToPage("/_Host")`. The former "blazor" `MapWhen` branch (lambda param
renamed `blazor``legacy`) is KEPT — it still co-hosts `MapControllers()`, `/docs` (Scalar), dev
`MapOpenApi()`, and the `LegacyUiRedirects` middleware. `MapFallbackToPage("/_Host")` is REPLACED by a
catch-all `endpoints.MapFallback(...)` that 302-redirects any unmatched path to `PathBase + "/app"`
EXCEPT paths under `/api`, `/artwork`, `/docs`, `/openapi` (those get a genuine 404, per #204's design).
**KEPT** (not removed): the OIDC/JWT/API-key SERVICE registrations (inert unless configured; real auth is
#197), `ConditionalIptvAuthorizeFilter` (`/iptv/*`), and `ApiKeyAuthorizationFilter` (mutating `/api/*`) —
per the #206 auth-posture sign-off (deleting the Blazor page challenged nothing beyond phase (a)).
**LegacyUiRedirects.** Added redirects for all 14 `/media/sources/*` routes → their `/app/libraries/*`
SPA screens (7 Tier-1 exact + 7 Tier-2 `{id}` patterns; the last Section-2 rows in
`blazor-route-parity.md`), and LIFTED the #204-era `/media/sources` forbidden-prefix guard (its Blazor
pages were replaced by #202's SPA screens). The forbidden-prefix guard now covers only `/api`, `/artwork`,
`/docs`, `/openapi`, `/iptv`, `/app`.
Also removed the now-dead ersatztv#25 razor-Sonar `<NoWarn>S6966;S3267;…</NoWarn>` line from
`ErsatzTV.csproj` — those Sonar rules only needed suppression in `.razor` `@code`; on `.cs` they run at
`suggestion` via `.editorconfig`, so removal is safe (closes part of #25's burn-down).
**Rollback.** The tag `blazor-final` was cut on the pre-removal `main` commit as the first step (see the
2026-07-11 "Pre-removal Blazor rollback tag `blazor-final` (#205)" entry above for the exact command +
restore path). Not a `v*` tag → no prod release build.
@@ -1,17 +0,0 @@
---
key: channel.origin-marker
title: 2026-07-23 — Channel origin is immutable creation-provenance, stamped at insert, not a health signal (#414)
status: active
since: '2026-07-23'
supersedes: none
superseded-by: none
rule: A new `Channel.Origin` (`ChannelOrigin` enum — `Unknown`/`UserCreated`/`AutoTuned`) records how a channel row was created and is stamped exactly once at insert (`AutoTuned` in `CreateChannelFromLineupHandler`, `UserCreated` in `CreateChannelHandler`), and is never mutated on a later edit. It is surfaced as a raw `origin` field on `ChannelResponseModel`; the SPA badges only `AutoTuned`. Rows predating the column read `Unknown` — provenance is **not** back-filled.
signals: 'channel origin, auto-tuned vs user-created channel, ChannelOrigin enum, immutable creation provenance, Origin column stamp at insert, do not back-fill origin, Channel Lineups playlist-group heuristic rejected, auto-generated then user-edited stays AutoTuned · paths: `ErsatzTV.Core/Domain/ChannelOrigin.cs`, `Channel.Origin`, `CreateChannelFromLineupHandler.BuildChannel`, `CreateChannelHandler`, `ChannelResponseModel`, `web/src/screens/ChannelsScreen.tsx`, `/app/channels` · issues: #414, #415, #72'
mechanics: '`CreateChannelHandlerTests` (UserCreated stamp), `CreateChannelFromLineupHandlerTests` (AutoTuned stamp), `ChannelsScreen.test.tsx` (badge only on AutoTuned); dual-provider migration `Add_Channel_Origin`'
---
This is #72 scope item (a), deferred in `api.channel-health-signal` because "no honest signal exists": `ChannelPlayoutSource.Generated` is a *playout-strategy* value that SPA-created blank channels also carry, so it would mislabel them, and a join through the `"Channel Lineups"` system playlist group was rejected as a heuristic that breaks the moment a user edits the channel. The fix is a dedicated `Origin` column — a fact, not a derivation.
**Immutable provenance, not a mutable "still managed" flag.** `Origin` records how the row was *born* and a later user edit never changes it, so "auto-generated then user-edited" stays `AutoTuned`. This deliberately avoids reviving the fragile "detect when it's been edited away" heuristic the issue rejected. A future "has diverged from its auto-tune template" signal, if wanted, is a *separate* concern owned by the #383/#384 auto-tune arc (which knows the template), not this column — mirroring the `api.channel-health-signal` reasoning that kept health a raw fact rather than freezing a policy enum.
**`Unknown = 0` is the honest legacy default.** A new non-null int column defaults existing rows to `0`; making that `Unknown` (rather than `UserCreated`) means pre-migration rows say "we never recorded this" instead of asserting a provenance we cannot know. The SPA badges only `AutoTuned`, so `Unknown` and `UserCreated` both render unbadged. Enum (not `bool IsAutoTuned`) so a future origin (e.g. `Imported`) is additive without a wire-contract break. Stamped in `CreateChannelFromLineupHandler.BuildChannel`, which is the single channel-construction primitive `CreateAutoTunedChannelsHandler` delegates to, so both the lineup endpoint and bulk auto-tune are covered by one stamp site. Empty-schedule and broken-source fault detection remain deferred to #415.
@@ -1,21 +0,0 @@
---
key: ci.batch-pushes-no-cancel-route
title: '2026-07-21 — Batch your pushes: there is no agent-side cancel route on Gitea 1.25.4 (#542)'
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.
@@ -1,30 +0,0 @@
---
key: ci.build-once-rejected
title: '2026-07-18 — CI build-once was measured and rejected; keep the #420 tree-skip'
status: active
since: '2026-07-18'
supersedes: none
superseded-by: none
rule: '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.'
signals: 'CI build-once, artifact tar/transport cost, tree-identity skip · paths: `docs/ci-cd.md` → Cross-run tree-identity skip · issues: #420, #398, #455'
mechanics: 'docs/ci-cd.md → Cross-run tree-identity skip; PR #455 measurement'
---
Build-once (a `compile` job producing a single artifact, consumed by `test`/`migrations`/
`functional-e2e` via `--no-build`) was fully implemented and went **green on CI** (PR #455, run
830), then **rejected on measurement**: it traded a ~12% slot-occupancy saving for a ~4085%
**per-run wall-clock regression**.
- **Why it regressed.** The `bin`+`obj` artifact is 2.5 GB raw / 972 MB gz; tar alone costs ~82s CPU
plus ~180s transport, consuming most of the ~465s the shared compile was meant to save. Worse,
`compile` serializes **before** `migrations`' long ef-replay, which is pure DB work a shared build
cannot shorten — the bottleneck was never the redundant compiles.
- **Incidental finding worth recording.** `actions/upload-artifact@v4` does not work on this Gitea
instance — it throws `GHESNotSupportedError`, because the `@actions/artifact` v2 client library
rejects any non-`github.com` host. `@v3` is required for any future artifact use here.
- **Kept: the #420 cross-run tree-identity skip** (`docs/ci-cd.md` → Cross-run tree-identity skip).
It is independent of build-once — it only *skips* redundant work on identical-tree main pushes, at
zero wall-clock cost, rather than trying to share a build across jobs. Don't re-attempt build-once
unless the runner's artifact storage or network changes materially.
Refs: #398 (closed), #420, PR #455.
@@ -1,21 +0,0 @@
---
key: ci.cancelled-is-not-a-verdict
title: 2026-07-21 — `cancelled` is not `failure`; a cancelled run is no verdict (#542)
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.
@@ -1,51 +0,0 @@
---
key: ci.decisions-edit-trailer
title: 2026-07-25 — The rationale-edit marker is a git trailer, not a substring anywhere in the commit range (#609)
status: active
since: '2026-07-25'
supersedes: none
superseded-by: none
rule: The body-diff exemption is armed by an affirmative `Decisions-Edit:` **git trailer** (`yes`/`true`/`1`, case-insensitive, read with `unfold`) on some NON-MERGE commit in the PR's merge-base range — never by a substring search over the message text. A non-affirmative value (`no`) does not arm it, the retired `[decisions-edit]` substring arms nothing (the validator emits a `::warning::` nudge when it sees one without a trailer), and a git error leaves the guard ON.
signals: 'decisions-edit, Decisions-Edit trailer, rationale-prose edit, body-diff guard, decisions_validate, vacuous gate, green no-op · paths: `scripts/decisions_validate.py`, `scripts/tests/test_decisions_validate.py` · issues: #609, #603, #521'
mechanics: '`git log --no-merges --format=''%(trailers:key=Decisions-Edit,valueonly,unfold)'' <mb>..<head>`; `dv._edit_trailer_armed`'
---
**The guard could disable itself by being described.** The original check was
`EDIT_TOKEN.lower() in git log --format=%B mb..head`, so *any* commit message containing the literal
string anywhere set `token = True` and suppressed all three rewrite comparisons (active survivors,
active→archive laundering, archive survivors). It fired live in PR #605: a commit message explaining
*why no token was needed* contained the bracketed token, and armed it. The result was a **green
`--base/--head` run that was vacuous on the body-diff dimension**, in the one PR that hand-resolved a
merge conflict inside the corpus the guard exists to police. No content was damaged, but the gate
reported success while checking nothing — the "a gate can merge green yet be a no-op" class.
**Why a trailer and not an own-line match.** Requiring the token alone on its own line fixes the
observed instance, but not the class: this repo's docs commits routinely *quote example commit
messages*, and the commit introducing this very record does so. An own-line matcher would arm on the
example. Git recognises a trailer only in the message's final paragraph, so a `Decisions-Edit: yes`
line quoted mid-body followed by more prose parses as nothing at all (verified against git 2.55).
Requiring an *affirmative value* closes the same trap one level up: `Decisions-Edit: no`, the natural
way to record a deliberate non-edit, must not read as consent.
**Three exactness requirements the naive trailer read still gets wrong** (all found in review, all
regression-tested). `--no-merges`: on a `pull_request` event `actions/checkout` lands on a synthetic
merge commit whose body the forge composes from the PR *description*, so without it a description
ending in an example marker arms a guard no author armed. That exclusion is not free: merging main
into a PR branch is discouraged but not mechanically blocked (`prepush-rebase-check.sh` only refuses a
branch that is *behind* main, and a merge makes main an ancestor), so an author who marks ONLY such a
merge commit has a legitimate rewrite rejected — a LOUD failure costing one extra commit, deliberately
preferred over silently disabling the guard. `unfold`: a folded `Decisions-Edit: no` + continuation
` yes` otherwise yields two lines, and the continuation arms on its own — inverting the value the
author wrote. And resolving the marker **fails closed** on a git error, the one deliberate exception
to this module's fail-open posture: a fail-open marker lookup is #609 through a different door.
**The residual, stated rather than papered over:** a quoted example that is the *final* paragraph of
an ordinary commit message is, by git's own grammar, a trailer — and will arm. That is inherent to any
trailer scheme; what the change buys is that merely *mentioning* or *discussing* the marker, which is
what actually happened in #605, can no longer disable anything.
**The marker's scope is unchanged** — still narrow, still only for a rationale-prose edit to a
surviving or archived record (`docs.decision-lifecycle`); only its form changed. It coexists with the
`Co-Authored-By:` trailer the `commit-msg` hook already mandates, so armed commits carry a
multi-trailer block. The retired form is left detectable on purpose: a contributor with the old habit
gets a warning naming the form change, not a bare "prose changed" failure.
@@ -1,22 +0,0 @@
---
key: ci.decisions-lifecycle-flake
title: '2026-07-21 — A lone `decisions lifecycle` red is a known infra flake: do nothing (#542)'
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.
@@ -1,25 +0,0 @@
---
key: ci.docs-only-detect-shallow-safe
title: '2026-07-17 — Docs-only detect must be shallow-checkout safe: FETCH_HEAD + two-dot, not origin/main + three-dot (#416 follow-up)'
status: active
since: '2026-07-17'
supersedes: none
superseded-by: none
rule: '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.'
signals: 'shallow clone, FETCH_HEAD, two-dot vs three-dot diff, fetch-depth 1 · paths: `scripts/ci-detect-docs-only.sh` · issues: #416, #422'
mechanics: '`docs/ci-cd.md` → "Docs-only skip"; verified via a real shallow `file://` clone reproduction'
---
The #416 docs-only skip shipped (#422) safe but **ineffective**: every docs-only PR still ran the full
matrix. Root cause — `test`/`migrations` check out `fetch-depth: 1`, and in a shallow clone
`origin/<base>` has no remote-tracking ref and there is no merge-base, so the detect script's
three-dot `git diff origin/main...HEAD` errored → `|| true` → empty diff → the fail-safe returned
`docs_only=false` → full matrix. Confirmed in a real shallow `file://` clone (`origin/main` did not
resolve; three-dot errored; `git diff FETCH_HEAD HEAD` returned the changed files correctly).
Decision: the detect diffs against **`FETCH_HEAD`** (always written by `git fetch`, resolves in a
shallow clone) with a **two-dot** tree diff (no merge-base). `api-docs`/`format` were unaffected only
because they use `fetch-depth: 0` — a difference the first cut missed. Meta-lesson reinforced: a CI
gating change can pass every local test and merge green while being a complete no-op in CI; only
real-PR verification that **measures the effect** (job durations, not just a green check) catches it —
which is exactly what #416's Done-when demanded. Fixed in the #416 follow-up PR.
@@ -1,44 +0,0 @@
---
key: ci.docs-only-skip-steps
title: 2026-07-17 — Docs-only CI skip gates STEPS in always-running required jobs, never `if:`-skips them (#416)
status: active
since: '2026-07-17'
supersedes: none
superseded-by: none
rule: '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.'
signals: 'docs-only CI skip, required-context branch protection, step-level gating vs job-level `if:` · paths: `.gitea/workflows/docker-build.yml`, `scripts/ci-detect-docs-only.sh` · issues: #416, #418, #420, #398'
mechanics: '`docs/ci-cd.md` → "Docs-only skip"'
---
A change touching only `docs/**` or `*.md` ran the entire `docker-build.yml` matrix (`test`,
`migrations` incl. its `mysql:8.4` service, `functional-e2e`, `format`, `api-docs`) — ~9 min of warm
CI to validate Markdown. `docker-build.yml` had no path filtering.
**Why not `paths-ignore` / an `if:`-skipped job — the trap.** `main`'s branch protection requires
two contexts *by name* (`Build & test (.NET)`, `EF migration integrity (SQLite + MySql)`). If a
docs-only PR produced **no run** for them, those contexts never report and the PR can **never merge**
— the naive fix bricks docs PRs instead of speeding them. A probe (throwaway PR #418) confirmed that
on Gitea **1.25.4** an `if:`-skipped job reports commit-status state **`skipped`** (a distinct state,
not `success`); how branch protection treats a `skipped` *required* context is not something we rely
on.
**The decision.** Each heavy job (`test`, `migrations`, `functional-e2e`, `build`) runs
`scripts/ci-detect-docs-only.sh` as its first post-checkout step (`id: detect`) and gates every real
step on `if: steps.detect.outputs.docs_only != 'true'`. The job **always runs** and reports
`success` in seconds on docs-only — so the two required contexts report unconditionally (safe by
construction). Non-required jobs may skip freely (production proves a `skipped` non-required context
doesn't block merge — `build` is `skipped` on every PR), so `build` skips its image steps on a
**docs-only push to `main`** (docs aren't in the image); tag builds force `docs_only=false` so a
release is never skipped. `api-docs`/`format` already self-short-circuit; `docs-reminder`/
`decisions-guard`/`ci-image-pin` keep running.
**Detection biases toward running MORE.** `docs_only=true` only when *every* changed path is docs;
any code path, a tag build, a non-merge push, or an undeterminable diff → `false` (run everything). A
false `true` would skip real tests on a code change (a correctness bug); a false `false` merely wastes
CI. Trade-off accepted: `migrations`' `mysql` service still starts on a docs-only run (a `services:`
container starts with the job regardless of step `if:`), but the 787-migration replay — the expensive
part — is skipped.
Two adjacent redundancies are deliberately **out of scope**: the whole matrix re-running on a PR and
again on the merge-to-`main` over identical code (#420), and the within-run triple `dotnet build`
(#398). Full mechanism in `ci-cd.md` → "Docs-only skip".
@@ -1,40 +0,0 @@
---
key: ci.format-gate-folder-mode
title: 2026-07-19 — The `format` gate runs `dotnet format whitespace . --folder`, not the full solution format (#469)
status: active
since: '2026-07-19'
supersedes: none
superseded-by: none
rule: 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.
signals: 'dotnet format, folder mode, CI format gate · paths: `.gitea/workflows` format job, `.editorconfig` · issues: #469, #406, #311'
mechanics: '`dotnet format whitespace . --folder --verify-no-changes --include <files>`'
---
The blocking `format` CI job (and the matching Husky pre-commit hook) verify changed `.cs` files with
`dotnet format whitespace . --folder --verify-no-changes --include <files>` instead of the previous
`dotnet format ErsatzTV.sln --no-restore --verify-no-changes --include <files>`.
- **Why.** `--include` narrows *which* files are checked, never what gets loaded. The full recipe loaded
the whole ~10-project MSBuild workspace and built a Roslyn compilation per project before checking a
single line — a fixed cost independent of how few files changed. Measured **~480s** for a whole-solution
`dotnet format` locally (matching the issue's "7+ min"). `--folder` treats the tree as a plain folder of
files and skips MSBuild/Roslyn entirely: **~0.5s**, and it needs no `dotnet restore`, so the job's
NuGet-cache + Restore steps were deleted. It also drops the job's ~3.95 GiB Roslyn heap (the #406
memory note about `format` not shrinking is now moot).
- **Coverage is unchanged, not merely "good enough".** Folder mode reads `.editorconfig` and enforces
exactly the two things this gate exists for — **whitespace** (indent/EOL/trailing/final-newline) and
**charset** (no UTF-8 BOM). Proven non-vacuous: exits non-zero with `error WHITESPACE` on an injected
trailing-whitespace line and `error CHARSET` on a prepended BOM; exits 0 on a clean file. What it drops
is the style/analyzer pass — but the *full* gate never enforced that either: a probe injecting a
`warning`-severity naming violation (`local_constants` not `ALL_UPPER`) **passed** the full solution
format (exit 0): the only `.editorconfig` rule above `:suggestion`/`:none` severity is that one naming
rule, and naming violations have no `dotnet format` batch code-fixer, so `--verify-no-changes` reports
no change regardless of severity. The analyzers that must block (`NU1904`,
`S3981`) are enforced at compile time via `WarningsAsErrors` in `Directory.Build.props`, never by this
job.
- **Fix command for a violation:** `dotnet format whitespace . --folder --include <files>`. The full
`dotnet format ErsatzTV.sln --include <files>` is a superset (also applies style) and still works, so
existing muscle memory and the #311 lore's `dotnet format --include` guidance are not broken.
- **Lane left on `ubuntu-latest`.** The job is now seconds-long and low-memory, so it could move to a
lighter lane, but that re-touches the per-lane memory-cap accounting (#406/#604) and is a
server-management capacity call — deliberately out of scope here.
@@ -1,39 +0,0 @@
---
key: ci.functional-e2e-harness
title: '2026-07-16 — Functional-E2E CI harness: advisory curl-contract job over an app booted from source (#299)'
status: active
since: '2026-07-16'
supersedes: none
superseded-by: none
rule: 'The `functional-e2e` CI job boots the PR''s own code from source via `dotnet run` (`scripts/e2e-local.sh`) and runs deterministic assertions (`scripts/e2e-functional.sh`) as an advisory (non-blocking) job, not a `build` dependency or required check. Originally curl-only; since #445 the same job carries a second, headless-browser step for the contracts curl cannot express — see `ci.ui-e2e-harness`.'
signals: 'staged rollout precedent (`migrations` job), racy/interactive flows deferred · paths: `scripts/e2e-local.sh`, `scripts/e2e-functional.sh` · issues: #299'
mechanics: '`scripts/e2e-functional.sh`'
---
The manual live-E2E curl flows sessions had been re-running by hand (and leaving only as PR/issue
comments) are now a CI regression net. Two decisions shaped it:
**Boot from source + `dotnet run`, not the built image.** The only "E2E" in CI before this was the
smoke test in the `build` job, which runs against the *pushed* image — so it exists only on `main`/`v*`
(the image isn't built on PRs) and would test a stale image, not the PR's code. To gate PRs on the PR's
own code, the `functional-e2e` job builds the SPA + solution and launches `dotnet ErsatzTV.dll` via the
same `scripts/e2e-local.sh` used locally (parameterized with `ETV_BUILD_CONFIG=Release`). The assertions
live in `scripts/e2e-functional.sh`, so the identical harness runs by hand and in CI — which is the
point of the issue (stop re-deriving the flows each session).
**Advisory, not blocking — separate job, not a `build` dependency, not a required check.** Per the
issue's "a functional-E2E flake must not block the unit-test gate." A boot-the-app job has more moving
parts (background process, port, readiness wait) than a pure unit test, so it starts advisory and gets
promoted to a required check / `build` dependency once proven reliable — the same staged rollout the
`migrations` job used. SQLite is the default provider, so it needs no DB service container.
**Scope is curl-only and deterministic; the racy/interactive flows are explicitly deferred.** The first
cut asserts the legacy→SPA redirect sweep (+ `/api`/`/artwork` never-redirect exemption), the
auth/CSRF/security-stamp flow, the library-scan status contract (404/202/`scan-status`), and the
`If-Match`/412 round-trip — all exercisable without seeded media, ffmpeg-transcode, or a browser (an
empty local library still enqueues `202`; an empty collection drives the concurrency editor). The 409
"already-scanning" re-trigger (needs a long-running scan to be non-racy), the playout-build lock 409,
and the genuinely UI-interactive Playwright flows are deferred as #299 follow-ups rather than shipped
flaky. (All three have since landed: the two 409s in #363/#444, the Playwright flows in #445
`ci.ui-e2e-harness` — as a second step of this same job.) Assertions were written against a real running instance, not the source — which caught that
`/artwork/*` returns `400` (not the `404` a static read suggested); extend the harness the same way.
@@ -1,25 +0,0 @@
---
key: ci.gitea-milestone-filter-noop
title: 2026-07-21 — Gitea's `?milestones=` issue filter silently no-ops on names containing `:` or `+` (#542)
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,26 +0,0 @@
---
key: ci.infra-shaped-red-under-load
title: 2026-07-21 — An infra-shaped red under host load is not a code failure (#542)
status: active
since: '2026-07-21'
supersedes: none
superseded-by: none
stale-after: '2027-02-15'
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.'
sources: 2026-07-17, bumblebee at load average 243 — `EF migration integrity` dead in ~11ms inside `Setup .NET`, main's `Build & push image` dead on buildx `DeadlineExceeded`; both green on re-run at normal load (#542)
---
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.
@@ -1,27 +0,0 @@
---
key: ci.killed-job-triage
title: '2026-07-21 — A killed CI job reports `conclusion: failure`; read the log tail before diagnosing the diff (#542)'
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".
@@ -1,53 +0,0 @@
---
key: ci.monitor-armed-at-pr-open
title: 2026-07-21 — Arm the CI monitor at PR-open, via the commit-status endpoint (#542)
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 · `skipped` misread as red · `Build & push image (amd64)` skipped · monitor classification · `!= "success"` filter · combined `.state` · paths: n/a · issues: #542, #216, #583'
mechanics: '`GET /api/v1/repos/timothy/ersatztv/commits/{sha}/status` for the combined verdict (`.state`); `…/statuses?limit=50` only when you need per-context detail.'
---
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.
**Classify per-context states correctly, or prefer the combined `.state`.** A monitor that enumerates
contexts and treats anything `!= "success"` as red is WRONG on this repo: `Build & push image (amd64)`
is `if:`-gated at the JOB level on `github.event_name != 'pull_request'`, so it reports **`skipped` on
every PR**, by design and regardless of content (images are built only on push-to-main and tags — see
`ci.docs-only-skip-steps`, which records the same fact from the branch-protection angle: "`build` is
`skipped` on every PR").
Such a monitor cries "NOT all green" on a perfectly green PR. Note this is *not* the docs-only skip:
the docs-only mechanism deliberately gates individual STEPS so required jobs still report `success` in
seconds — misattributing the image job's skip to docs-only is a plausible-sounding wrong diagnosis
(#583 session, 2026-07-25).
Three distinct non-`success` states, three meanings — do not collapse them:
- **`skipped`** — deliberately not applicable. Settled, and *not* red. Gitea's combined `.state` already
treats it as non-blocking (a PR with a skipped `build` reports `overall=success`), which is why the
combined endpoint is the safer thing to gate on.
- **`failure`** — a real red; diagnose it (but first check `ci.killed-job-triage` and
`ci.infra-shaped-red-under-load`).
- **`cancelled`** — no verdict at all; see `ci.cancelled-is-not-a-verdict`.
Working filter when you do enumerate — verified silent on a green PR carrying a skipped `build`, and
verified to still report a genuinely unfinished run (i.e. proven able to go dirty, per
`process.bom-format-detection-recipe`'s "verify your detector" rule):
```bash
curl -s -u "$ETV_GITEA_BASICAUTH" ".../commits/$SHA/statuses?limit=50" \
| jq -r '[.[]|{c:.context,st:.status}]|group_by(.c)|map(.[0])|.[]
|select(.st!="success" and .st!="skipped")|"NOT-GREEN: \(.c) = \(.st)"'
```
**Mind the renamed key.** The first draft of this snippet said `select(.status != …)` after the
pipeline had already renamed `.status` to `.st`, so the comparison ran against `null`, passed
*everything*, and reported a fully green PR as nine failures. Report `failure` and `cancelled` in
separate counts.
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.
@@ -1,19 +0,0 @@
---
key: ci.no-host-health-gating
title: 2026-07-21 — Do not gate or throttle pushes on host health (#542)
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.
@@ -1,46 +0,0 @@
---
key: ci.peak-anon-measurement
title: 2026-07-19 — CI `test` job reports a sampled true peak-anon, not cache-inflated `memory.peak` (#412)
status: active
since: '2026-07-19'
supersedes: none
superseded-by: none
stale-after: '2027-03-15'
rule: 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.
signals: 'CI memory measurement · paths: `scripts/ci-peak-anon.sh`, `.gitea/workflows/*.yml` · issues: #412, #411 (prose-only predecessor, no standalone record — its `memory.peak`-headline approach is superseded by this record)'
mechanics: '`scripts/ci-peak-anon.sh` header; `docs/ci-cd.md` → CI build memory'
sources: '`scripts/ci-peak-anon.sh` (the sampler itself) · cgroup v2 `memory.peak` vs `memory.stat` `anon` accounting, measured on the bumblebee runners #412'
---
**Decision.** The `test` job's memory instrument (added in #411) now reports a **sampled high-water
mark of the cgroup's `anon` memory** as the headline figure, produced by `scripts/ci-peak-anon.sh`
(a `start` step before the dotnet Build/Test/Coverage, a `report` step last). `memory.peak` and the
end-of-job `anon`/`file` split stay in the output as a cache-inflated ceiling and a reference.
**Why not just `memory.peak`.** `memory.peak` is the high-water mark of `memory.current`, which
charges reclaimable **page cache** to the cgroup alongside anon. A build does heavy
NuGet/npm/obj/bin/coverage I/O, so cache can dominate the peak — and page cache is *reclaimed* under
a tighter cap, not OOM-killed. Sizing a per-job cap (server-management#604) off `memory.peak`
therefore **inverts the decision**: a big, mostly-`file` peak reads like "the cap must stay high"
when it isn't. The OOM-forcing quantity is peak **anon**. The kernel exposes `memory.peak` but has
**no peak-anon counter**, and the end-of-job `anon` is the composition *then*, not at the peak
instant (a job that peaks mid-`dotnet test` then frees reports a misleadingly low `anon`) — so it
must be **sampled**. Details + the sampler's robustness rationale: `scripts/ci-peak-anon.sh` header
and `docs/ci-cd.md` → "CI build memory".
**Implementation note (do not "simplify" back to `memory.peak`).** The sampler is a detached
`nohup` poller that survives step-boundary re-execs (reparents to the container's PID 1) and is
reaped at container teardown; a TERM trap + `sleep & wait` stops it at once on `report`. Both steps
are `continue-on-error` with a fail-open script, so the instrument can never redden a green build.
Validated on bumblebee: it catches a transient 2.5 GiB anon spike that the end-of-job snapshot
reports as 0.
**Compiler-server A/B verdict (refines the #406 entry's "premise looking dead" read).** Measured
in the CI image, swap-off, sampled peak-anon, n=2 interleaved: **OFF (the CI config) ≈ 5.84 GiB,
consistent; ON (defaults) 6.37.6 GiB, always higher, + a ~3 GiB resident `VBCSCompiler`.**
Disabling the servers is worth it (consistent reduction, no resident server), but OFF sits *right at
6 GiB for the build phase alone* and the `test` job adds test + coverage on top — so #406's premise
("disabling brings peak *well under* 6 GiB → the budget loosens") is **not supported**. Size the cap
off the live test-job peak-anon this instrument now reports, not off the build-only A/B. The older
#411 probe (`anon 7134 MiB`) read higher than these swap-off sampled numbers and is superseded
(swap/read-method move the figure >1 GiB).
@@ -1,16 +0,0 @@
---
key: ci.root-screenshot-guard
title: '2026-07-12 — Root-screenshot guard: pre-commit refuses root-level *.png (#303 H3)'
status: active
since: '2026-07-12'
supersedes: none
superseded-by: none
rule: The Husky `pre-commit` hook refuses a staged root-level `*.png` (belt-and-suspenders with the `.gitignore` rule); nested `*.png` real assets are unaffected.
signals: 'git hooks, screenshot guard, root png · paths: `.husky/pre-commit`, `.gitignore` · issues: #303 (H3)'
mechanics: '`.husky/pre-commit`'
---
Companion guard **H3**: the Husky `pre-commit` hook refuses a staged **root-level `*.png`** (a
review/debug screenshot dropped at the repo root) — belt-and-suspenders with the `.gitignore` rule, so
a forced `git add -f` still can't land one. Nested `*.png` (real assets) are unaffected. Rationale for
both: the methodology review (#303) — make the process rules derivations/hooks, not prose to remember.
@@ -1,126 +0,0 @@
---
key: ci.runner-placement
title: '2026-07-17 — No persistent compiler servers in CI; every `services:` container gets an explicit cap; #390''s small-lane move reversed (#406)'
status: active
since: '2026-07-17'
supersedes: none
superseded-by: none
stale-after: '2027-01-15'
rule: 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).
signals: 'CI memory/swap thrash · paths: `.gitea/workflows/*.yml`, `docker/Dockerfile` · issues: #406, #390 (prose-reversed, no standalone record), server-management#604, server-management#570'
mechanics: '`docs/ci-cd.md` → CI build memory; `scripts/ci-peak-anon.sh`'
sources: 'the 2026-07-17 bumblebee incident — load 340, 21 GiB swapped, ~238 MiB free, prod down until reboot (server-management#604) · host sizing at the time: 25 GiB / 12 cores'
---
Three CI changes, all downstream of one incident: on 2026-07-17 bumblebee (the **prod media** Docker
host, 25 GiB / 12 cores) hit load **340** with **21 GiB swapped** and ~238 MiB free, taking prod
ersatztv/jellyfin down until reboot. Prod ersatztv itself was healthy at 168 MiB throughout — the
thrash was **CI-induced**, triggered by a burst of parallel merges to `main`. Infra sizing is
server-management#604's boundary; these three levers live in this repo and each does more than any
capacity knob.
**1. No persistent compiler servers.** `VBCSCompiler` is a *persistent* Roslyn server: it outlives
the `dotnet build` that started it and holds its heap for the next one. Measured at **7.8 GB RSS**
live on bumblebee — the single largest consumer on the box, and the actual reason each job needed a
10 GiB cap. In CI it buys **nothing**: each job container is torn down at the end of the run, so
there is never a "next build" to warm. The workflow's top-level `env:` now sets
`UseSharedCompilation=false`, `DOTNET_CLI_USE_MSBUILD_SERVER=0`, `MSBUILDDISABLENODEREUSE=1`
MSBuild properties set as env vars so they apply to every `dotnet` call without touching each call
site (MSBuild surfaces env vars as properties; `UseSharedCompilation` only defaults to `true` when
empty, so the env var wins). Verified locally: a default build leaves 1 `VBCSCompiler` alive, the
same build under these vars leaves **0**, and `ErsatzTV.sln` still builds clean (0 errors).
**Why the Dockerfile also sets them** — and this is the part the issue's suggestion would have
missed: the workflow `env:` reaches the *runner-side* dotnet jobs only. The `build` job compiles
inside `docker build`, where it does not propagate, so the SDK stage of `docker/Dockerfile` sets the
same three as `ENV`. That is precisely the job server-management#570 measured pegging **5.999/6
GiB** — the one that most needs it. Build-stage only; the final image is `FROM runtime-base`, so
nothing lands in the shipped image or affects runtime.
**Trade-off accepted**: without the shared server each project's `csc` is a fresh process, which
costs some build time. Worth it — the memory spike is what takes prod down, and the cap sizing that
spike forces is what starves the lanes.
**2. `services:` containers do not inherit the runner's cap.** A runner's `container.options`
(`--cpus=4 --memory=10g`) applies to the **job container only**. Verified by inspecting a live
`migrations` job: the job container reported `HostConfig.Memory=10737418240`, its `mysql:8.4`
service reported `mem=0 nanocpus=0`**unbounded**. Every migrations run was adding an uncapped
MySQL to an already-tight host. Now `--memory=2g --memory-swap=2g --cpus=2`.
**Why `--memory-swap` is not redundant** (cold-review catch, and the sharpest thing in this change):
Docker defaults an unset `--memory-swap` to **twice** `--memory`, so `--memory=2g` alone grants 2g
RAM **plus 2g of swap** — verified live: `--memory=2g``memory.max=2147483648` **and**
`memory.swap.max=2147483648`; `--memory=2g --memory-swap=2g``memory.swap.max=0`. Capping RAM
while silently permitting swap is close to the worst outcome **on the host whose swap thrash is the
entire reason for the cap**, and a swapping mysqld mid-DDL is exactly the pathology behind the known
`Command Timeout expired` migrations flake — i.e. the naive cap could have made that flake worse.
**Standing rule: prefer a loud OOM over silent swapping.** An OOM is an unambiguous "raise the cap"
signal; swapping just degrades everything and blames something else.
**The same 2× applies to the runners' 10g job slots** — each is really 10 GiB RAM *plus* 10 GiB
swap, so the "60 GiB promise on a 25 GiB host" understates by 2× and is a plausible direct mechanism
for the incident's 21 GiB swapped. That is server-management#604's boundary; reported there.
**On 2g, honestly**: 543 MiB is init+idle, **not** the 787-migration replay (which grows caches idle
never touches), so 2g is a measured *floor* plus headroom, not a measured ceiling — the `migrations`
job going green is what validates it. `--cpus=2` has no measurement behind it at all; 787 sequential
DDL statements on one connection are ~1-core-bound, so it is judgement. Recording that rather than
dressing a guess as measurement — the same failure this entry criticises below.
**Standing rule: any new `services:` container needs its own explicit cap** — it will not inherit
one, and `--memory` without `--memory-swap` silently grants 2× in swap.
**3. #390's `small`-lane move for `api-docs`/`format` reversed.** #390 moved them to dodge a ~29 min
`ubuntu-latest` queue. The queue was real, but the lane was the wrong fix, and **#390's own comment
flagged why**: *"on an API-touching PR this job does a full `dotnet build`, so it is not always a
'small' job; capacity 4 absorbs that."* "Capacity 4 absorbs that" held only because **nothing
enforces the sum** of the lanes' caps — 6 slots × 10 GiB on a 25 GiB host is a 60 GiB promise. These
are not small jobs: a live `docker stats` caught the `format` job container at **3.95 GiB**, which
#604's re-sized 2 GiB `small` lane would OOM-kill outright. #604 grows `ubuntu-latest` to 5 slots
(48 GiB ci-runner at capacity 4 + a bumblebee overflow slot) and fixes the queue at the source. This
one is **order-coupled with #604**: the small lane's caps can't tighten until it lands.
**Measurement is now continuous, not a one-off.** The `test` job's **last** step reports the
cgroup's `memory.peak` plus an `anon`/`file` breakdown (`continue-on-error`, tolerates absence).
#604 sizes both runners' caps on that number, and until now it was *inherited* rather than measured
— the 10g cap traces back to #570 observing a different job entirely. Two things that look like
details but are the whole point: it must run **last** (`memory.peak` read at step N reports the peak
only up to N, so an earlier placement silently excludes the job's later workload), and
`continue-on-error` — not `if: always()` — is what makes it advisory (`always()` controls whether a
step *runs*, not whether its failure fails the job, and `defaults.run.shell: bash` means `-e` is on).
**And then the instrument taught us the lesson twice, both times at our own expense.** The first
reading came back `peak 8305 MiB`. `memory.peak` is the high-water mark of `memory.current`, which
charges **page cache** as well as anonymous memory — proven: a container with `anon=0` that merely
reads an 800 MB file reports `memory.peak=826 MiB`, `file=800 MiB`. Page cache is *reclaimed* under
a tighter cap, not OOM-killed, so a large peak that is mostly `file` is **not** evidence a cap must
stay high. `anon` is what forces an OOM; size caps on it. The step now prints the split (end-of-job,
so indicative rather than peak-instant); a true peak-anon sample is **#412**.
**Then we made the same mistake in the opposite direction.** Having established that peak
*overstates*, the docs (and a report to #604) leaned to "so this number is probably mostly cache."
That was a guess about *magnitude* dressed in a verified fact about *mechanism* — and an independent
probe killed it: a full solution build in the CI image with shared compilation off measured `peak
9457 MiB` / **`anon 7134 MiB`** / `file 421 MiB`. **Anon dominated.** So a 6g cap looks *unsafe*,
#570's "6g proved too tight" is the rule rather than an outlier, and **#406's premise ("if this
brings peak RSS well under 6 GiB, the whole budget loosens") is looking dead** — the 7134 MiB was
measured *with* shared compilation already off. The switches are still right (no persistent 7.8 GB
server between builds); the looser budget they were supposed to buy is not.
**What is NOT established:** there is still no pre-change baseline from this instrument — the 7.8 GB
`VBCSCompiler` figure was measured host-wide across concurrent jobs, not inside one job container —
and the anon figure above is one probe, not the `test` job. #412 covers the real A/B. What *is*
established: no persistent compiler server survives a build, and `migrations` is green with mysql
capped at 2g with swap disabled.
The lesson generalizes, and note it bit *this* change twice — once in the issue's premise and once
in our own instrument: this repo's CI perf work keeps stating numbers from plausibility rather than
measurement (see #390's "24min" apt-ffmpeg estimate; real 110s, and not load-bearing). Measuring
the wrong quantity precisely is the same failure wearing a lab coat.
**What this does NOT shrink**: `format`. `dotnet format` loads Roslyn in-process via
MSBuildWorkspace and never spawns `csc`, so its measured 3.95 GiB is untouched by any of this. Do
not size the `small` lane expecting otherwise.
**Not addressed here**: the 1235 min queue waits (server-management#604) and the redundant
triple-build (#398).
@@ -1,36 +0,0 @@
---
key: ci.small-lane-git-only
title: '2026-07-20 — `runs-on: small` means git-only; the two `docker build` jobs move to `ubuntu-latest` (server-management#639)'
status: active
since: '2026-07-20'
supersedes: none
superseded-by: none
rule: '`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.'
signals: 'CI lane definition, per-job memory cap, small lane widening, memory cap vs capacity, act setup-phase hang, docker build placement · paths: `.gitea/workflows/docker-build.yml`, `.gitea/workflows/ci-image.yml`, runner config · issues: server-management#639, #406, #604, #574'
mechanics: sum-of-caps rule (#406/#604); second jazz runner at `--cpu-shares=128`; `docs/ci-cd.md`
---
- **The `small` lane is defined by what a job *does*, not by how long it usually takes.** Both jobs
removed from it here were justified as small on a runtime argument that only held in the common case:
`docker-build.yml`'s `build` is a 1-second skip on PR runs (but a real image build on main/tags), and
`ci-image.yml`'s `build` was reasoned about as "docker-only, no toolchain needed — it *builds* the
toolchain", which is true and yet describes the single heaviest job in the lane. The lane's per-job
memory cap is set by its worst member, not its median, so both of these forced `--memory=10g`.
- **That cap, not a capacity decision, is what pinned the lane at one slot.** 10 GiB per slot on a
25 GiB host that also runs prod media permits exactly one — the sum-of-caps rule from #406/#604
(6 slots × 10 GiB on a 25 GiB host produced load 340 and 21 GiB of swap). So "widen the lane" and
"keep the heavy jobs" were never simultaneously available; the earlier note in the runner config had
parked the widening indefinitely behind moving the lane to a different host.
- **Fixing the cap dominates fixing the capacity.** With both builds on `ubuntu-latest`, `small` is a
checkout plus a `git diff`, cappable at 1 GiB, so it widened from 1 slot to **4 across two hosts
while committing less RAM to CI than the single slot did**. A second runner was added on jazz at
`--cpu-shares=128` — CI on a prod media host is only acceptable while it loses every scheduling
contest to the transcoders.
- **The symptom this fixes is not queue wait.** A saturated lane also wedges *dispatched* jobs in act's
setup phase: >10 min `in_progress`, **no log file written at all**, then failure, before Checkout
runs. That produced the standing "`decisions.md` is a known flake, just rerun it" belief — the rerun
works only because it lands after load clears, so a capacity problem read as a bug in the guard. A
job that fails with zero log output is evidence about the runner, not about the job.
- **#574's skip-task queueing does not return** by moving `build` back to `ubuntu-latest`:
`needs: [test, migrations]` means it cannot be dispatched until the jobs it would have queued behind
have already finished.
@@ -1,54 +0,0 @@
---
key: ci.ui-e2e-harness
title: '2026-07-25 — UI-E2E: headless Playwright flows in the existing `functional-e2e` job, browser baked into the CI image (#445)'
status: active
since: '2026-07-25'
supersedes: none
superseded-by: none
rule: 'The UI-interactive E2E flows run as headless Playwright specs (`web/e2e/*.spec.ts`, driven by `scripts/e2e-ui.sh`) in a **second step of the existing advisory `functional-e2e` job**, never their own job; the browser is `chromium-headless-shell` **baked into the CI toolchain image** (`docker/ci/Dockerfile`, `PLAYWRIGHT_VERSION` kept equal to `web/package.json`''s EXACT `@playwright/test` pin), never installed per run; specs are `serial` with `retries: 0` and assert only contracts the curl harness structurally cannot reach.'
signals: 'UI-E2E, Playwright headless, boot-gate/Setup/Login flows, chromium-headless-shell, PLAYWRIGHT_BROWSERS_PATH=/ms-playwright, browser baked not installed, e2e-ui.sh, vitest `e2e/**` exclude, port 8410, no retries · paths: `scripts/e2e-ui.sh`, `web/playwright.config.ts`, `web/e2e/boot-gate.spec.ts`, `docker/ci/Dockerfile`, `.gitea/workflows/docker-build.yml`, `docs/e2e-local.md` · issues: #445, #363, #299'
mechanics: '`scripts/e2e-ui.sh`; `docs/e2e-local.md` -> "UI-E2E harness"; `docs/ci-cd.md` -> "CI toolchain image"'
---
Completes the last #299/#363 follow-up — the UI-interactive flows both prior records deferred.
**Only assert what curl structurally cannot.** The curl harness already covers the auth *HTTP* contracts
(setup-claim 200/409, login 401/200, CSRF 403, stamp rotation); re-asserting them through a browser buys
nothing but flake surface. Scope is the four things curl cannot express: client-side form validation
(the Setup confirm-password gate is pure React state, makes no request), `AuthGate`'s *rendered* states,
the session cookie authenticating the **SPA's own** `/api` XHRs (curl proves the cookie works for curl,
not that the app sends it), and sign-out via `UserMenu`. **This scoping rule is the durable part**
extend the browser suite only when a contract fails that test.
**Baked browser, not a per-run install.** `chromium-headless-shell` lands in `/ms-playwright` at image
build time, so the job installs nothing — the "jobs install nothing at run time" rule of the shared CI
toolchain image (#390, which has no standalone record — see `ci.runner-placement` and `docs/ci-cd.md`).
Measured on the real base: headless shell **267M** vs full `chromium` **656M** (+171M compressed pull),
and `chromium.launch()` resolves to the shell anyway; the tradeoff is that a *headed* run in the image
fails. Verified rather than assumed: Chromium runs as root in-container with **no** `--no-sandbox`
opt-out. The browser revision is tied to the npm package version, so that pin is EXACT and `e2e-ui.sh`
guards drift by **launching** a browser — not by path, since `executablePath()` reports the
full-chromium path such an image lacks.
**Why that job and not a new one** (`docs/ci-cd.md` has the detail): its `npm ci` + Release build are
already done, so a separate job would duplicate the dominant cost to add ~5s of browser work. The UI step
boots its **own** fresh instance on port 8410 — the first spec asserts the one-shot Setup gate the curl
step has already claimed.
**`serial` + `retries: 0`, and vitest must not collect these files.** Server state is shared and partly
one-shot (the setup-claim), so specs are serial/single-worker; each `test` still gets its own browser
context, which gives the login specs a signed-out browser without a logout dance. Retries are 0 even in
CI — a retry lets a flaky flow merge looking green. Coupling worth knowing: vitest's default `include`
glob would run `web/e2e/*.spec.ts` under jsdom, so `vite.config.ts` excludes `e2e/**` by spreading
`configDefaults.exclude` — not by narrowing `include` to `src/**`, which would silently stop collecting
the real vitest test under `web/scripts/`.
**Lifecycle correctness in `scripts/e2e-ui.sh`** — all four found by adversarial review, **none by a
passing run**; that is the transferable lesson (green runs never exercise the failure/interrupt paths).
(a) Install the cleanup trap *before* boot and have `e2e-local.sh` publish its PID to an opt-in
`ETV_PIDFILE` as it forks, so a mid-boot signal can still reap the server — a "kill whatever LISTENS on
the port" fallback is wrong twice over: it needs `lsof` (absent from the CI image) and it *infers*
ownership instead of proving it. (b) Re-raise signals, so a cancelled run cannot exit 0. (c) Never
`if ! cmd; then status=$?` — under `!` bash sets `$?` to the logical negation, so it reads 0 and a
FAILING run exits 0, silently passing CI. (d) `exec` the app inside the backgrounded subshell, or `$!`
is the SUBSHELL on bash 3.2 (stock macOS) and every PID-based kill targets the wrong process.
@@ -1,18 +0,0 @@
---
key: ci.verify-locally-ci-confirms
title: 2026-07-21 — Build and verify locally, then trust it; CI confirms (#542)
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.
@@ -1,18 +0,0 @@
---
key: ci.web-test-per-test-timeouts
title: 2026-07-21 — Heavy-render web tests need explicit per-test vitest timeouts on the CI VM (#542)
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.
@@ -1,51 +0,0 @@
---
key: concurrency.diff-scalar-fanout
title: '2026-07-11 — #253 PR3: Diff + Scalar concurrency fan-out (Collection / Playout×2 / MultiCollection / RerunCollection)'
status: active
since: '2026-07-11'
supersedes: none
superseded-by: none
rule: 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++`.
signals: 'concurrency fan-out, PreconditionFailedError, SaveChangesWithConcurrencyGuard · paths: `api-conventions.md` §7a · issues: #253, #269, #232, #197'
mechanics: '`RootWriterForceVersionTests`-adjacent handler tests; `api-conventions.md` §7a'
---
**Context.** PR3 of the #253 optimistic-concurrency arc fans the frozen Block recipe (api-conventions §7a)
across the five Diff/Scalar aggregates. Three judgment calls beyond the mechanical copy:
**H1 — Playout `catch(Exception)`→422.** The two Playout replace handlers wrap `SaveChangesAsync` in a
`catch(Exception)` that maps any exception to a bare `BaseError` (→ 422). Rather than let the guard's
concurrency failure be reshaped into a 422, the guarded save (`SaveChangesWithConcurrencyGuard`) returns a
`PreconditionFailedError` **Left as a value** and the handler returns it before the post-commit block —
so it never reaches the catch. Proven by the pre-check-subtype tests (a `.Apply` flatten would fail
`ShouldBeOfType<PreconditionFailedError>`) plus a non-vacuous Playout racing-save test.
**M2 — the `SaveChangesAsync() > 0` gates.** RerunCollection and Collection-custom-order run their
playout-refresh **unconditionally** on a successful save (the unconditional `Version++` makes the old gate
always-true; the "nothing changed" branch is dead). MultiCollection is the exception: it saved the name
first specifically so a name-only change wouldn't rebuild playouts, so we bump `Version` on that **first**
save and leave the **second** (items) save's `> 0` gate intact — a name-only edit still bumps + rotates the
ETag but does not rebuild. Enumerating every behavior the gate provided before reworking it (the #232 lesson).
**Sibling-writer scope (deferred).** §7a's config-only boundary says every writer of an aggregate's
editor-visible config bumps `Version`. PR3 ships the five primary endpoints' full contract + the one
design-named bulk writer (`UpdateDefaultDecoHandler`, safe via `.SetProperty`). It **defers** the other
same-root non-bulk config writers (`UpdateCollectionHandler`, `RemoveItemsFromCollectionHandler`,
`UpdatePlayoutHandler`, the `ScheduleFile` handlers) and the repository-mediated `Add*ToCollection` family.
Rationale: the primary endpoints' own bump+guard fully cover the two-tab lost-update the issue targets;
the deferred writers only affect cross-editor ETag *rotation*, and adding an unconditional bump to a handler
that uses plain `SaveChangesAsync` (not the guard) converts a latent lost-update into a **new 500**
(`DbUpdateConcurrencyException`) — doing it safely needs a uniform guard+bump+412 pass of its own, better
done with the #197 contract work. Tracked as a follow-up issue.
**VMs.** `Playout.Version` surfaces via `PlayoutNameViewModel` (required arg); the three collection VMs
(`MediaCollectionViewModel`, `MultiCollectionViewModel`, `RerunCollectionViewModel`) carry `int Version = 0`
(defaulted — 0 for the selection-placeholder constructions, real value from the Mapper projection).
Header-only via ETag, never echoed in a response body (the Block precedent).
**Post-merge addendum (PR3 review, #269).** Activating the `Version` token means EF guards *every* root
UPDATE, so non-participating root-scalar writers that use plain `SaveChangesAsync` (playout settings /
schedule-file / on-demand-checkpoint, collection name) would 500 on a concurrent bump. The realistic
UPDATE writers were fixed in-PR with `ConcurrencyExtensions.SaveChangesForcingVersion` (Phase-1
force-write on conflict: adopt the stored token, retry, never revert the concurrent bump). The deferral
above is re-scoped to the DELETE handlers + repository `Add*` writers only (→ #269).
@@ -1,63 +0,0 @@
---
key: concurrency.etag-rotation-completion
title: 2026-07-12 — Cross-editor ETag rotation completed for Collection/Playout config siblings (#269)
status: active
since: '2026-07-12'
supersedes: none
superseded-by: none
rule: 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.
signals: 'ETag rotation, no-op idempotence, SaveChangesForcingVersion rebase · paths: `CollectionEtagRotationTests`, `PlayoutScheduleFileEtagRotationTests` · issues: #269, #253, #197, #308'
mechanics: '`docs/api-conventions.md` §7a; `CollectionEtagRotationTests`, `PlayoutScheduleFileEtagRotationTests`'
---
The #253 optimistic-concurrency contract (§7a) had a documented tail: the non-If-Match config-sibling
writers of a versioned root mutated editor-visible state **without** bumping `Version`, so editing through
them did not rotate an open editor's ETag (a cross-editor invalidation gap — never a lost-update or a 500,
which the primary endpoints' bump+guard already cover). #269's first slice (PR #302) removed the 500 exposure
by routing those writers through `SaveChangesForcingVersion`; this slice completes the **rotation**.
Handlers now bumping `Version` (all via `SaveChangesForcingVersion`, since they take no `If-Match` → a
concurrent replace-all bump force-writes, never 412/500): the Collection `Add*ToCollection` family (11
handlers) and `RemoveItemsFromCollectionHandler` bump `Collection.Version`; `UpdateCollectionHandler`
(name/flag), `UpdatePlayoutHandler` (`DailyRebuildTime`), and the three `ScheduleFile` writers
(`UpdateSequential`/`UpdateScripted`/`UpdateExternalJsonPlayout`) — which already force-wrote — now also bump.
Decisions frozen (ratified with Fable before implementation, feeding the #197 contract freeze):
- **Rotate on every editor-visible config change, no per-aggregate carve-outs.** §7a's config-only boundary
("every mutating handler of a root's editor-visible config bumps `Version`") already held for Playlist
`Add*`/schedule item writers; the Collection/Playout siblings were an inconsistency, not a judgment call. A
membership add rotating an open custom-order editor's ETag (→ 412 → reload) is correct: its list is genuinely
stale. Blast radius of the aggressive-but-safe rotation is a reload, never data loss.
- **No-op idempotence — the trap Fable caught.** These handlers gate their reindex/`BuildPlayout` fan-out on
`SaveChanges() > 0`. An *unconditional* bump makes that gate always-true, so an idempotent re-add / same-value
re-submit would fire spurious rebuilds across every playout using the aggregate. Fix: short-circuit a genuine
no-op **before** the bump — the Add handlers by an explicit membership check (which also fixes the latent
duplicate-`CollectionItem` insert on a *sequential* re-add; two *concurrent* same-item adds can still both
pass the check and the loser 500s on the composite-PK unique violation — `SaveChangesForcingVersion` catches
only `DbUpdateConcurrencyException`, not `DbUpdateException`. That race is narrow and pre-existing, deferred
to #308), the scalar writers (`UpdateCollection`, `UpdatePlayout`, the
three `ScheduleFile` writers) by `ChangeTracker.HasChanges()`. A no-op neither bumps nor rebuilds nor rotates
the ETag — which is itself correct (nothing changed).
- **The `Add*ToCollection` family is not repository-mediated.** #269's original framing ("repository-mediated,
shared with the scanner hot path") was wrong: `IMediaCollectionRepository` is read-only; each handler loads
the `Collection` into its own `dbContext` and writes directly. So the rotation bump is a pure API-layer
concern and the scanner's separate membership-write path is untouched — a background scan does **not** rotate
the editor ETag (correct: background indexing is not an editor action).
- **Force-write rebases the bump, never adopts the stored token verbatim (Codex review of this PR).**
`SaveChangesForcingVersion` originally resolved a conflict by setting current=original=stored — which
silently *discarded* a sibling's pending `Version++` when a versioned writer committed in its load→save
window (sibling loads 1, bumps to pending 2, concurrent PUT commits 2 → retry wrote 2, so the concurrent
writer's ETag "2" stayed valid and the rotation was lost under exactly the race it exists for). Fixed in
this PR (it affects all 25 bumpers routed through the helper, including the pre-existing playlist/schedule
ones): the retry now rebases — original = stored, current = stored + (pending current pending original) —
so a bumper lands at stored+1 and a non-bumper (delta 0, e.g. `ErasePlayoutHistory`) adopts stored unchanged.
The race tests assert the post-race Version (3, not 2) and fail against the verbatim-adopt implementation.
- **No new status codes.** These endpoints take no `If-Match` and force-write, so they never 412; no
`[ProducesResponseType(...412...)]` and no OpenAPI regen (response types unchanged). Only §7a prose changes.
Tests: `CollectionEtagRotationTests` (rotation + no-op-without-bump-or-rebuild + force-write-past-concurrent-bump
for Add/Remove/Update) and `PlayoutScheduleFileEtagRotationTests` (ScheduleFile rotation + no-op-without-refresh),
the no-op guard proven non-vacuous by inverting the membership check. The `#265` RFC-7232 If-Match parser
refinement (valid-but-non-matching/weak/list → 412 not 400) is a **separate** PR (disjoint surface: the shared
parser + `CheckVersion`, not the handler saves). Refs #253 #269 #197 · `api-conventions.md` §7a.
@@ -1,57 +0,0 @@
---
key: concurrency.force-write-non-ifmatch
title: 2026-07-12 (#269 — non-If-Match root writers force-write past a concurrent Version bump)
status: active
since: '2026-07-12'
supersedes: none
superseded-by: none
rule: 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).
signals: 'force-write, non-If-Match writers, DbUpdateConcurrencyException · paths: `ConcurrencyExtensions.SaveChangesForcingVersion` · issues: #269, #253, #302, #197'
mechanics: '`RootWriterForceVersionTests`; `docs/api-conventions.md` §7a'
---
**Routing the aggregate delete handlers + `UpdateProgramScheduleHandler` through `SaveChangesForcingVersion`.**
Once #253 made each replace-all root's `Version` an `IsConcurrencyToken`, EF started guarding *every*
UPDATE **and DELETE** of that row with `WHERE Version=@orig` — so any writer that is not part of the
If-Match contract but still saves via plain `SaveChangesAsync` throws an unhandled
`DbUpdateConcurrencyException`→**500** if a replace-all editor bumps the row in its narrow load→save window.
PR3 already force-wrote the exposed *UPDATE* siblings (Playout settings/`ScheduleFile`/checkpoint,
`UpdateCollectionHandler`); a completeness sweep for #269 found the gap was wider than reported —
**18 writers** in total, all on plain `SaveChangesAsync`. **The correct exposure filter is "any handler
that leaves a versioned root `Modified` or `Deleted`", NOT just `Version`-bumpers + deletes** — an early
sweep used the narrower filter and a review of PR #302 caught what it missed (`ErasePlayoutHistory` below):
- the **nine versioned-root delete handlers** (`DeletePlayout`/`DeleteCollection`/`DeleteMultiCollection`/
`DeleteRerunCollection`/`DeletePlaylist`/`DeleteBlock`/`DeleteTemplate`/`DeleteDecoTemplate`/
`DeleteProgramSchedule`) — a DELETE is now token-guarded too;
- `UpdateProgramScheduleHandler` (bumps `Version` then saved plainly — the ProgramSchedule case PR3 only
*suspected*);
- the **seven item add/remove bumpers** that PR2 wired to bump their root but left on plain save —
`AddProgramScheduleItem`/`DeleteProgramScheduleItem` and the five
`Add{Items,Movie,Show,Season,Episode}ToPlaylist` handlers;
- **`ErasePlayoutHistoryHandler`** — modifies Playout root **scalars** (`Seed`/`Anchor`/`OnDemandCheckpoint`)
**without** bumping `Version`, inside an explicit transaction with no try/catch → the one the bumper-only
filter missed; reachable via `POST /api/playouts/{id}/erase-items-and-history`.
All now save through `ConcurrencyExtensions.SaveChangesForcingVersion`.
**Two deliberate boundaries (documented, not gaps):** (1) the background build/time-shift Playout-scalar
writers (`BuildPlayoutHandler` via `PlayoutBuilder`'s `Anchor`/`Seed`; `PlayoutTimeShifter`'s
`OnDemandCheckpoint`) are token-guarded too but **intentionally left on plain save** — they never surface a
request-path 500 (`BuildPlayoutHandler` catches → a build-failure `BaseError`; `PlayoutTimeShifter` runs only
via the background worker), and force-writing would be *wrong*: a concurrent config edit that bumped
`Version` also enqueues a rebuild, so failing the in-flight build and letting the rebuild redo it with fresh
config is correct (force-writing would persist output built from stale config). (2)
Item-add force-write can leave a duplicate/gap `Index` (accepted Phase-1 effect): the handler computes the
new index from its stale child list, so if a concurrent replace-all grew the list the item lands at a
now-colliding index (no unique constraint on `PlaylistItem.Index`/`ProgramScheduleItem.Index`) — non-
corrupting, self-correcting on the next edit, still strictly better than the pre-#269 500; a
reload-and-recompute-on-conflict refinement is a candidate for #197. Decision:
**force-write, not 412** — these endpoints take no `If-Match` (an unconditional DELETE/settings-edit should
win over a concurrent editor), matching the Phase-1 force-write posture. A delete has no ETag to rotate, so
it needs only the force-write, not a `Version` bump. A genuine row-deletion race (two concurrent deletes)
still surfaces as a `DbUpdateConcurrencyException` — accepted (rare, non-corrupting, the resource is already
gone). **Still deferred to #197:** *cross-editor ETag rotation* for the non-bumping config siblings and the
scanner-shared `Add*ToCollection` family (they don't 500 — they insert children / `ExecuteDelete`, neither
of which is token-guarded — they just don't rotate an open editor's ETag). Non-vacuously tested by racing a
bump *through the handler* via a pre-tracked context (`RootWriterForceVersionTests`), plus an explicit
negative control proving the plain-save path throws.
@@ -1,42 +0,0 @@
---
key: concurrency.idempotent-concurrent-add
title: '2026-07-18 — Concurrent same-item add is idempotent, not a 500: catch the unique-violation per provider (#308)'
status: active
since: '2026-07-18'
supersedes: none
superseded-by: none
rule: A concurrent duplicate `Add*ToCollection` that loses the race on the composite-key unique constraint is treated as an idempotent no-op (skip the reindex/rebuild fan-out), not a 500 — detected via a provider-specific `TvContext.IsUniqueConstraintViolation` delegate defaulting to "no".
signals: 'idempotent add, unique-constraint violation, provider error classifier · paths: `TvContext.IsUniqueConstraintViolation`, `SqliteErrorClassifier`, `MySqlErrorClassifier` · issues: #308, #269, #253'
mechanics: '`docs/api-conventions.md` §7a ("Idempotent insert under concurrency"); `ConcurrencyExtensions.TrySaveChangesForcingVersion`'
---
**Decision.** The `Add*ToCollection` family's membership pre-check (#269) is not atomic with the insert, so two
*concurrent* adds of the same item both observe it absent and both stage the `CollectionItem` composite key; the
loser's `SaveChangesForcingVersion` threw a unique/PK-violation `DbUpdateException` (SQLite error 19 / MySQL 1062)
it did not catch → **500**. We now treat that loss as an **idempotent no-op**, not an error: the desired end state
(the item is a member) already holds because the racing winner inserted it, rotated the ETag, and fanned out the
rebuild.
**Mechanism.** A `bool`-returning sibling `ConcurrencyExtensions.TrySaveChangesForcingVersion` wraps
`SaveChangesForcingVersion` and catches *only* a classified unique/PK violation, returning `false`. The 10
single-item handlers return `Unit.Default` on `false` (skip the reindex/rebuild fan-out — the winner did it). The
bulk `AddItemsToCollection` handler cannot no-op — that would silently drop the non-colliding items when a batch
partially overlaps a concurrent add — so it **retries** on a fresh context against recomputed membership (bounded
loop; the common no-collision path runs once).
**Provider seam.** Detection is provider-specific but the Application layer must not reference the provider
packages, so it follows the existing `TvContext` static-provider-config idiom (`IsSqlite`, `LastInsertedRowId`): a
settable `TvContext.IsUniqueConstraintViolation` delegate, pointed at `SqliteErrorClassifier` (extended codes 1555
PK / 2067 UNIQUE) or `MySqlErrorClassifier` (`Number == 1062`) from `Startup.cs`, defaulting to a conservative
"no" so an unwired provider never silently swallows a save failure. Chosen over DI to avoid threading a new
service through 11 handlers, and because the provider discriminator already lives as a `TvContext` static.
**Scope boundary.** `Add*ToPlaylist` is deliberately **untouched**: `PlaylistItem` has its own identity PK and no
unique index on `(PlaylistId, MediaItemId)` — a playlist may legitimately contain the same item more than once, so
there is no constraint to violate.
**Tests.** A negative-control anchor proves the race genuinely throws a classified `DbUpdateException`; the fix's
end-to-end handler tests reproduce a *real cross-connection* race via a shared-cache SQLite harness + a
`SavingChanges` interceptor that inserts the conflicting row on another connection mid-save (the single-connection
in-memory fixture cannot). Every fix-dependent test was verified to fail with the catch disabled. Mechanics:
`api-conventions.md` §7a ("Idempotent insert under concurrency"). Refs #308 #269 #253.
@@ -1,43 +0,0 @@
---
key: concurrency.ifmatch-rfc7232
title: '2026-07-12 — If-Match evaluates per RFC 7232: valid-but-non-matching → 412, only grammar violations → 400 (#265)'
status: active
since: '2026-07-12'
supersedes: none
superseded-by: none
rule: '`ConcurrencyHeaders.ParseIfMatch` is a real RFC 7232 entity-tag/list parser: a syntactically-valid tag that doesn''t strong-match (weak/empty/non-canonical/out-of-range/list) returns 412, and only a genuine grammar violation returns 400.'
signals: 'RFC 7232, If-Match parsing, strong-tag matching · paths: `ConcurrencyHeaders`, `IfMatchCondition`, `VersionedAggregateExtensions.CheckVersion` · issues: #265, #253, #197'
mechanics: '`docs/api-conventions.md` §7a'
---
Closing the last #253 concurrency-contract piece. `ConcurrencyHeaders.ParseIfMatch` previously classified
**any** non-canonical/weak/list `If-Match` value as `Malformed → 400` (a deliberate fail-safe: reject rather
than risk a stale write, deferred from the reference-aggregate PR). That was RFC-incorrect. Per **RFC 7232
§3.1**, a syntactically-valid entity-tag that simply doesn't strong-match must return **412 Precondition
Failed**, and **400** is reserved for a genuine grammar violation.
**What changed.** The parser is now a real RFC 7232 entity-tag/list parser (`If-Match = "*" / 1#entity-tag`).
It **scans** the list (it does *not* `Split(',')` — a comma is a valid `etagc`, so it can appear inside a quoted
opaque-tag: `"3,5"` is ONE tag, and a comma separates members only outside the quotes), trims only RFC OWS
(SP/HTAB — not `string.Trim()`, which would strip NBSP and let `" * "` masquerade as the `*` force-write),
validates each member as `[ "W/" ] DQUOTE *etagc DQUOTE`, and collects the versions of the **strong** members
whose opaque text is the exact canonical decimal we emit. Outcomes:
- **weak** (`W/"3"`), **empty** (`""`), **non-canonical** (`"03"`, `"3.0"`, `"+3"`), **out-of-range**
(`"99999999999999999999"`) → valid tags that contribute no version → **412** (a `Version`-kind with an
*empty* candidate set is a guaranteed no-match).
- **list** (`"3", "5"`) → any strong member that matches proceeds; weak/non-canonical members drop out.
- genuine grammar violations (unquoted `3`, SP inside the tag `" 3 "`, unterminated `"3`, `garbage`, a
separator-only header) → **400**.
**Type reshape.** `IfMatchCondition.ExpectedVersion : Option<int>``ExpectedVersions : Option<Seq<int>>`
(`None` = force-write; `Some(set)` = strong-match against the set, empty ⇒ always 412), and
`VersionedAggregateExtensions.CheckVersion(Option<int>)``CheckVersion(Option<Seq<int>>)` = set membership.
This threads through all 10 replace/update commands + handlers + request mappers + 9 controllers uniformly; no
wire-contract change (400 and 412 were already declared on every PUT; the field is header-derived and internal,
so no OpenAPI/DTO change).
*Why now, not #197:* it is the shared parser all replace-all PUTs copy, and the 412-vs-404 ordering the issue
worried about was already correct (each handler loads/validates → 404 before `CheckVersion`). *Why safe:* the
first-party SPA only ever echoes the single canonical strong tag we emit, so no shipped client changes behavior;
the change only makes a hand-written/tooling `If-Match` get the RFC-correct status. Docs: `api-conventions.md`
§7a. Refs #265 #253 #197.
@@ -1,42 +0,0 @@
---
key: concurrency.replace-all-contract
title: '2026-07-11 — Optimistic-concurrency contract for replace-all PUTs (#253 PR1: infra + Block reference)'
status: active
since: '2026-07-11'
supersedes: none
superseded-by: none
rule: Replace-all aggregate PUTs carry a uniform plain `int Version` concurrency token (EF `.IsConcurrencyToken()`), checked pre-save and enforced by the EF UPDATE guard, returning 412 (not 409) on a stale `If-Match`.
signals: 'ETag, If-Match, Version token, 412 Precondition Failed · paths: `api-conventions.md` §7a, `ConcurrencyHeaders`, `ApiResults.ToErrorResult` · issues: #253, #197, #265, #259'
mechanics: '`docs/api-conventions.md` §7a; `SaveChangesWithConcurrencyGuard`'
---
Replace-all aggregate PUTs had **no** optimistic concurrency — a stale second tab silently overwrote a
fresher edit (200, no signal) across ~10 aggregate surfaces. PR1 lands the shared contract on the Block
reference aggregate; PRs 24 fan it out. The full ratified design + independent-review hardening is
[#253#issuecomment-8472](http://192.168.1.95:3000/timothy/ersatztv/issues/253#issuecomment-8472);
the mechanics live in `api-conventions.md` §7a. Decisions frozen here:
- **Token = uniform plain `int Version`** on each root implementing `IVersionedAggregate`, EF-mapped
`.IsConcurrencyToken()`, one dual-provider migration (`AddAggregateVersions`, `defaultValue: 0`). **Not**
a reused `DateUpdated` (tick-collision, SQLite TEXT precision, couples UI cosmetics to correctness) and
**not** a MySQL-native rowversion (portability over provider-native).
- **412 Precondition Failed**, not 409 — 409 stays the §3a EntityLocker "build in progress" guard;
distinct codes → distinct SPA UX. New `PreconditionFailedError : BaseError` → 412 in `ApiResults.ToErrorResult`.
- **Pre-check AND EF token both required.** The handler pre-check (a standalone `Either` introduced AFTER
the validation pipeline — never via `Apply`, which `Join()`-flattens the subtype to 422) gives a clean
412; the unconditional `root.Version++` + `IsConcurrencyToken` UPDATE-guard + a `SaveChangesWithConcurrencyGuard`
backstop closes the residual load→save TOCTOU (`DbUpdateConcurrencyException` → 412).
- **Unconditional bump** (not "only when a child changed"): EF writes the root row only when a scalar
differs, so a no-op PUT-back must still bump to fire the token and rotate every other client's ETag.
- **Config-only aggregate boundary**: every mutating handler of a root's *editor-visible config state*
bumps `Version` (incl. bulk `ExecuteUpdate/Delete` writers via `.SetProperty`); regenerated build output
(playout items/history) is outside the token — neither bumped nor guarded.
- **Header-only ETag**, strong tag of the decimal `Version`; parsed/emitted by `ConcurrencyHeaders`. The
successful PUT returns the new ETag (else a same-tab second save 412s against its own write).
- **Phasing**: Phase 1 (this arc) = a missing `If-Match` force-writes (zero breakage) while the SPA starts
echoing; Phase 2 (a later PR) flips missing → **428** after every editor echoes and one release soaks.
`If-Match: *` stays the scripted force-write escape hatch.
- **Child stable-identity is OUT of #253** (the "moved fill-group item inherits the wrong slot's state"
concern on the positional reconcile) — root-anchored versioning is orthogonal to it; split to **#259**.
- **If-Match status semantics** (non-canonical/weak/list → 400) are fail-safe; the stricter RFC 7232
"valid-but-non-matching → 412" refinement is deferred to #197 (**#265**).
@@ -1,33 +0,0 @@
---
key: concurrency.schedule-item-child-identity
title: '2026-07-11 — Stable child identity for schedule-item replace (#259, split from #252/#253)'
status: active
since: '2026-07-11'
supersedes: none
superseded-by: none
rule: '`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).'
signals: 'stable child identity, schedule-item replace, id-based reconcile · paths: `api-conventions.md` §7c · issues: #259, #252, #253, #197'
mechanics: '`docs/api-conventions.md` §7c'
---
`PUT /api/schedules/{id}/items` now reconciles by an optional round-tripped child id, not by array
position, so an item's persisted fill-group/shuffle state (`PlayoutScheduleItemFillGroupIndex`, FK
`OnDelete(Cascade)`) follows the logical item across reorders/inserts instead of being inherited by
whatever previously held its new slot. Contract + rules in **api-conventions §7c**. Key decisions:
- **`ScheduleItemRequest.Id` (`int?`)**: null/absent/`0` ⇒ new item (controller normalizes `0`→null so the
handler is two-state). Any id present ⇒ id-based reconcile; a fully id-less payload keeps the verbatim
positional fallback (legacy; retires with the §7a Phase-2 `If-Match`→428 flip).
- **Unknown or duplicate id ⇒ 422, nothing persisted**; the guards live in the handler **after** §7a
`CheckVersion`, so **412 precedes 422** — a client that is both version-stale and id-stale gets the reload
signal, not a payload-bug signal. Rationale for reject-not-insert on an unknown id: under Phase-1
force-write a stale id is a live lost-update signal, so silently inserting-as-new would duplicate the item
and return a different id than the client sent (the exact class §7a exists to surface). This is also the
correct #197 posture — never honor an unrecognized identifier.
- **Scope = schedule items only.** Blocks/templates/deco-templates/playlists stay positional: their children
are stateless config rows (no FK'd state to misattribute; #3/#4 have no GET child id). Child ids are added
only where a child row anchors server-side state; the contract can be retrofitted per-endpoint later
(field stays optional) — so this is not #197 ossification pressure.
- **TPT subtype change at a matched id** stays delete+insert (EF can't retype in place); state resets and a
new id is returned, so the SPA must re-seed item state from the PUT response (a stale id on a second save
now 422s).
@@ -1,24 +0,0 @@
---
key: docs.convention-docs-session-start
title: 2026-07-07 — Convention docs read at session start, updated in-PR
status: active
since: '2026-07-07'
supersedes: none
superseded-by: none
rule: '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.'
signals: '`docs/README.md` index, `ApiControllerSecurityTests` drift · paths: `docs/README.md`, `docs/api-conventions.md` §6 · issues: #184, #185. Session-start *reading order* is now the task-signal map — see `startup.parallel-orientation`.'
mechanics: '`docs/README.md`'
---
`docs/api-conventions.md`, `docs/spa-conventions.md`, `docs/e2e-local.md`,
`docs/blazor-route-parity.md`, `docs/domain-model.md`, `docs/decisions.md`, and `docs/README.md`
are the standing reference set every ChicoryTV session should read before starting work, and each
one carries an explicit "update this doc in the same PR" rule rather than deferring doc updates to
a follow-up. These docs **replace per-session recon** — an agent reads the index
(`docs/README.md`) and the relevant convention doc instead of re-deriving conventions from the code
each time it starts API/SPA/E2E/parity work. A testing map and a generated-endpoint index are
tracked as still-to-come under #185. Drafting this doc set also surfaced a drift in
`ApiControllerSecurityTests.cs`'s hardcoded controller registry (several controllers under
`ErsatzTV/Controllers/Api/` are missing from it — see `docs/api-conventions.md` §6) — tracked as a
follow-up under #184 rather than fixed inline, since it's a pre-existing gap, not something this
doc-drafting pass caused.

Some files were not shown because too many files have changed in this diff Show More