Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ef581403c | ||
|
|
990d32f31a | ||
|
|
851dca2596 | ||
|
|
aa4a8fb849 | ||
|
|
243bec708d | ||
|
|
4596603020 |
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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>_ => 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,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; }
|
||||
|
||||
-7314
File diff suppressed because it is too large
Load Diff
-172
@@ -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);
|
||||
});
|
||||
|
||||
-7139
File diff suppressed because it is too large
Load Diff
-157
@@ -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
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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 3–4 concurrent dotnet/npm builds on this Mac, gate launches on FREE RAM rather than CPU load, and never set `ETV_UPDATE_GOLDENS` / `ETV_UPDATE_PLAYOUT_GOLDENS`. | 2026-07-21 | [link](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 3–4 concurrent dotnet/npm builds on this Mac, gate launches on FREE RAM rather than CPU load, and never set `ETV_UPDATE_GOLDENS` / `ETV_UPDATE_PLAYOUT_GOLDENS`. | 2026-07-21 | [link](workflow-process.md#2026-07-21--bound-parallel-builds-by-free-ram-never-regenerate-goldens-542) |
|
||||
| `process.codex-cheap-worker-launch` | For bounded tool-bearing selector/recon work, launch a Codex worker with `codex exec -m gpt-5.4-mini -c model_reasoning_effort=low -s read-only`; `spawn_agent` buys parallelism but no cost savings. | 2026-07-21 | [link](workflow-process.md#2026-07-21--launch-codex-cheap-workers-via-codex-exec-not-spawn_agent-542) |
|
||||
| `process.consistency-fix-new-code-scrutiny` | Review a "make X consistent with Y" change as new code, not as a mechanical copy — and for any timer or effect involved, ask explicitly "when does this fire?", including on mount. | 2026-07-21 | [link](workflow-process.md#2026-07-21--make-x-consistent-with-y-review-findings-are-new-code-and-get-new-code-scrutiny-542) |
|
||||
| `process.enumerate-workaround-behaviors-before-deleting` | When an issue says "delete X", enumerate every behavior X provided before removing it — a workaround often serves a second purpose that outlives the first. | 2026-07-21 | [link](workflow-process.md#2026-07-21--before-deleting-a-workaround-enumerate-every-behavior-it-provided-542) |
|
||||
| `process.foreign-worktree-plumbing-merge` | Never commit or merge inside a worktree another session created; land the merge with git plumbing against the branch ref instead. | 2026-07-21 | [link](workflow-process.md#2026-07-21--never-commit-or-merge-inside-a-worktree-you-did-not-create-542) |
|
||||
| `process.harden-with-runtime-posture-not-clamp` | When a security fix constrains a capability the roadmap will later want, make the safe state the DEFAULT OF A SWITCH rather than a wall — and read the feature's own issue for its end-state first. | 2026-07-21 | [link](workflow-process.md#2026-07-21--harden-a-soon-to-grow-feature-with-a-runtime-posture-not-a-hardcoded-clamp-542) |
|
||||
| `process.independent-review-rubric` | Run an independent review pass — preferably a different model family, otherwise a cold-context review-only agent — on any diff touching locks/concurrency, auth/security, API write-path handlers, or DB migrations, or larger than ~150 changed C# lines; skip only for a pure-SPA/docs leaf with no server-state effect, and state the skip and its reason in the PR or close comment. | 2026-07-21 | [link](workflow-process.md#2026-07-21--independent-cross-model-review-is-mandatory-on-risky-diffs-a-skip-is-a-stated-auditable-exemption-542) |
|
||||
| `process.issue-qualification-audit` | Run `scripts/issue-qualification-audit.sh` at session end and label everything it flags, including issues you filed that session. | 2026-07-21 | [link](workflow-process.md#2026-07-21--every-open-issue-carries-a-priority-label--run-the-h12-audit-at-session-end-542) |
|
||||
| `process.local-gate-before-push` | Run the local build/test gate and a cold-context, scoped "review only" adversarial review over the diff, fold the fixes, and only then push or open the PR. | 2026-07-21 | [link](workflow-process.md#2026-07-21--local-buildtest-gate--cold-context-review-run-before-the-push-not-after-542) |
|
||||
| `process.lock-ownership-enumerate-producers` | Before trusting any "single owner / no double release / no cross-release" claim, grep the whole host project for every writer of that channel message (or acquirer of that lock) — the background scheduler/worker is the usual missing producer. | 2026-07-21 | [link](workflow-process.md#2026-07-21--a-lockchannel-no-cross-release-verdict-must-enumerate-every-producer-via-grep-542) |
|
||||
| `process.one-worktree-one-committing-agent` | Never run two committing agents concurrently on one worktree — give each parallel slice its own worktree branched off the feature branch and merge back. | 2026-07-21 | [link](workflow-process.md#2026-07-21--one-worktree-one-committing-agent-542) |
|
||||
| `process.parallel-session-claim` | Apply the `in-progress` label before starting an issue, and still read its dependency notes before touching shared surfaces — a claim prevents duplicate pickup, not overlapping code changes. | 2026-07-21 | [link](workflow-process.md#2026-07-21--claim-with-in-progress-before-working-claiming-is-not-collision-safety-542) |
|
||||
| `process.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) |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
|
||||
@@ -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 2–4 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 201–204. 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 PR2–PR4.** 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 ~40–85%
|
||||
**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.3–7.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 "2–4min" 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 12–35 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 2–4 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
Reference in New Issue
Block a user